Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit a9edb8a

Browse files
authored
feat(inbox): add report usefulness feedback footer (#3969)
Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com>
1 parent d24af58 commit a9edb8a

8 files changed

Lines changed: 556 additions & 0 deletions

File tree

‎apps/mobile/src/app/inbox/[...id].tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
DismissReportSheet,
4747
} from "@/features/inbox/components/DismissReportSheet";
4848
import { ReportActivity } from "@/features/inbox/components/ReportActivity";
49+
import { ReportFeedbackFooter } from "@/features/inbox/components/ReportFeedbackFooter";
4950
import { SignalCard } from "@/features/inbox/components/SignalCard";
5051
import {
5152
type ReviewerActionExtra,
@@ -573,6 +574,9 @@ export default function ReportDetailScreen() {
573574

574575
{/* Activity log */}
575576
<ReportActivity reportId={report.id} artefacts={artefacts} />
577+
578+
{/* Usefulness feedback */}
579+
<ReportFeedbackFooter report={report} />
576580
</ScrollView>
577581

578582
<View
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { Text } from "@components/text";
2+
import type { SignalReport } from "@posthog/shared/domain-types";
3+
import * as Haptics from "expo-haptics";
4+
import { ThumbsDown, ThumbsUp } from "phosphor-react-native";
5+
import { useCallback, useState } from "react";
6+
import { Pressable, TextInput, View } from "react-native";
7+
import {
8+
ANALYTICS_EVENTS,
9+
computeReportAgeHours,
10+
type InboxReportFeedbackSentiment,
11+
useAnalytics,
12+
} from "@/lib/analytics";
13+
import { useThemeColors } from "@/lib/theme";
14+
15+
/** Bounded to keep the note within the analytics client's per-property limit. */
16+
const FEEDBACK_NOTE_MAX_LENGTH = 4000;
17+
18+
/**
19+
* Thumbs rating at the end of the report body. The rating submits on the first
20+
* tap; only once it's recorded does an optional note appear, so the note can
21+
* never gate the rating. Feedback is analytics-only — it never changes the
22+
* report's state. Mirrors the desktop/cloud feedback footer so ranking
23+
* analysis is comparable across clients.
24+
*/
25+
export function ReportFeedbackFooter({ report }: { report: SignalReport }) {
26+
const analytics = useAnalytics();
27+
const themeColors = useThemeColors();
28+
29+
const [sentiment, setSentiment] =
30+
useState<InboxReportFeedbackSentiment | null>(null);
31+
const [noteOpen, setNoteOpen] = useState(false);
32+
const [noteDraft, setNoteDraft] = useState("");
33+
const [noteSent, setNoteSent] = useState(false);
34+
35+
// Re-tapping the chosen thumb is a no-op rather than a second identical
36+
// feedback event; switching thumbs records the new sentiment.
37+
const chooseSentiment = useCallback(
38+
(next: InboxReportFeedbackSentiment) => {
39+
if (sentiment === next) return;
40+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
41+
setSentiment(next);
42+
analytics.track(ANALYTICS_EVENTS.INBOX_REPORT_FEEDBACK, {
43+
report_id: report.id,
44+
report_age_hours: computeReportAgeHours(report.created_at),
45+
priority: report.priority ?? null,
46+
actionability: report.actionability ?? null,
47+
sentiment: next,
48+
has_pr: !!report.implementation_pr_url,
49+
surface: "detail_footer",
50+
});
51+
},
52+
[sentiment, analytics, report],
53+
);
54+
55+
const submitNote = useCallback(() => {
56+
const trimmed = noteDraft.trim();
57+
if (!trimmed || !sentiment) return;
58+
analytics.track(ANALYTICS_EVENTS.INBOX_REPORT_FEEDBACK_NOTE, {
59+
report_id: report.id,
60+
report_age_hours: computeReportAgeHours(report.created_at),
61+
priority: report.priority ?? null,
62+
actionability: report.actionability ?? null,
63+
sentiment,
64+
has_pr: !!report.implementation_pr_url,
65+
surface: "detail_footer",
66+
note: trimmed.slice(0, FEEDBACK_NOTE_MAX_LENGTH),
67+
});
68+
setNoteDraft("");
69+
setNoteOpen(false);
70+
setNoteSent(true);
71+
}, [noteDraft, sentiment, analytics, report]);
72+
73+
const isPositive = sentiment === "positive";
74+
const isNegative = sentiment === "negative";
75+
76+
return (
77+
<View className="mt-2 border-gray-4 border-t pt-4">
78+
<View className="flex-row flex-wrap items-center gap-2">
79+
<Text className="text-[13px] text-gray-11">
80+
{sentiment ? "Thanks for the feedback" : "Was this report useful?"}
81+
</Text>
82+
<View className="flex-row items-center gap-1.5">
83+
<Pressable
84+
onPress={() => chooseSentiment("positive")}
85+
accessibilityRole="button"
86+
accessibilityLabel="This report was useful"
87+
accessibilityState={{ selected: isPositive }}
88+
hitSlop={6}
89+
className={`rounded-full border px-3 py-2 active:opacity-70 ${
90+
isPositive
91+
? "border-accent-9 bg-accent-9"
92+
: "border-gray-6 bg-background"
93+
}`}
94+
>
95+
<ThumbsUp
96+
size={16}
97+
weight={isPositive ? "fill" : "regular"}
98+
color={isPositive ? "#ffffff" : themeColors.gray[11]}
99+
/>
100+
</Pressable>
101+
<Pressable
102+
onPress={() => chooseSentiment("negative")}
103+
accessibilityRole="button"
104+
accessibilityLabel="This report was not useful"
105+
accessibilityState={{ selected: isNegative }}
106+
hitSlop={6}
107+
className={`rounded-full border px-3 py-2 active:opacity-70 ${
108+
isNegative
109+
? "border-accent-9 bg-accent-9"
110+
: "border-gray-6 bg-background"
111+
}`}
112+
>
113+
<ThumbsDown
114+
size={16}
115+
weight={isNegative ? "fill" : "regular"}
116+
color={isNegative ? "#ffffff" : themeColors.gray[11]}
117+
/>
118+
</Pressable>
119+
</View>
120+
{sentiment && !noteOpen && !noteSent && (
121+
<Pressable
122+
onPress={() => setNoteOpen(true)}
123+
hitSlop={6}
124+
className="rounded-full px-2 py-1 active:opacity-60"
125+
>
126+
<Text className="text-[13px] text-accent-11">Add a note</Text>
127+
</Pressable>
128+
)}
129+
{noteSent && (
130+
<Text className="text-[13px] text-gray-9">Note added</Text>
131+
)}
132+
</View>
133+
{noteOpen && (
134+
<View className="mt-3">
135+
<TextInput
136+
value={noteDraft}
137+
onChangeText={setNoteDraft}
138+
placeholder="What was useful or off?"
139+
placeholderTextColor={themeColors.gray[9]}
140+
multiline
141+
maxLength={FEEDBACK_NOTE_MAX_LENGTH}
142+
autoFocus
143+
className="min-h-[80px] rounded-xl bg-gray-2 px-3 py-3 text-[14px] text-gray-12"
144+
style={{ textAlignVertical: "top" }}
145+
/>
146+
<View className="mt-2 flex-row items-center justify-end">
147+
<Pressable
148+
onPress={submitNote}
149+
disabled={noteDraft.trim().length === 0}
150+
accessibilityRole="button"
151+
accessibilityLabel="Send note"
152+
className={`rounded-full px-4 py-2.5 active:opacity-80 ${
153+
noteDraft.trim().length === 0 ? "bg-gray-4" : "bg-accent-9"
154+
}`}
155+
>
156+
<Text className="font-semibold text-[14px] text-white">Send</Text>
157+
</Pressable>
158+
</View>
159+
</View>
160+
)}
161+
</View>
162+
);
163+
}

‎apps/mobile/src/lib/analytics.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export const ANALYTICS_EVENTS = {
1111
INBOX_REPORT_CLOSED: "Inbox report closed",
1212
INBOX_REPORT_SCROLLED: "Inbox report scrolled",
1313
INBOX_REPORT_ACTION: "Inbox report action",
14+
INBOX_REPORT_FEEDBACK: "Inbox report feedback",
15+
INBOX_REPORT_FEEDBACK_NOTE: "Inbox report feedback note",
1416
SIGN_IN_STARTED: "Sign in started",
1517
SIGN_IN_COMPLETED: "Sign in completed",
1618
SIGN_IN_FAILED: "Sign in failed",
@@ -75,10 +77,14 @@ export type InboxReportActionType =
7577

7678
export type InboxReportActionSurface =
7779
| "detail_pane"
80+
| "detail_footer"
7881
| "toolbar"
7982
| "keyboard"
8083
| "list_row";
8184

85+
/** Sentiment captured by the report usefulness thumbs. */
86+
export type InboxReportFeedbackSentiment = "positive" | "negative";
87+
8288
export interface InboxViewedProperties {
8389
report_count: number;
8490
total_count: number;
@@ -163,6 +169,37 @@ export interface InboxReportActionProperties {
163169
suggested_reviewer_uuid?: string;
164170
}
165171

172+
/**
173+
* Thumbs-up/down verdict on a report's usefulness. Feedback-only: the report
174+
* keeps its state. Mirrors desktop/cloud `Inbox report feedback` so ranking
175+
* analysis is comparable across clients. `note` is optional.
176+
*/
177+
export interface InboxReportFeedbackProperties {
178+
report_id: string;
179+
report_age_hours: number;
180+
priority: string | null;
181+
actionability: string | null;
182+
sentiment: InboxReportFeedbackSentiment;
183+
has_pr: boolean;
184+
surface: InboxReportActionSurface;
185+
note?: string;
186+
}
187+
188+
/**
189+
* Optional free-text note, fired on its own event after a rating so sentiment
190+
* stays exactly one event per rating; join back on `report_id`.
191+
*/
192+
export interface InboxReportFeedbackNoteProperties {
193+
report_id: string;
194+
report_age_hours: number;
195+
priority: string | null;
196+
actionability: string | null;
197+
sentiment: InboxReportFeedbackSentiment;
198+
has_pr: boolean;
199+
surface: InboxReportActionSurface;
200+
note: string;
201+
}
202+
166203
export interface PromptSentProperties {
167204
task_id: string;
168205
is_initial: boolean;
@@ -184,6 +221,8 @@ export type EventPropertyMap = {
184221
[ANALYTICS_EVENTS.INBOX_REPORT_CLOSED]: InboxReportClosedProperties;
185222
[ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED]: InboxReportScrolledProperties;
186223
[ANALYTICS_EVENTS.INBOX_REPORT_ACTION]: InboxReportActionProperties;
224+
[ANALYTICS_EVENTS.INBOX_REPORT_FEEDBACK]: InboxReportFeedbackProperties;
225+
[ANALYTICS_EVENTS.INBOX_REPORT_FEEDBACK_NOTE]: InboxReportFeedbackNoteProperties;
187226
[ANALYTICS_EVENTS.SIGN_IN_STARTED]: SignInStartedProperties;
188227
[ANALYTICS_EVENTS.SIGN_IN_COMPLETED]: SignInCompletedProperties;
189228
[ANALYTICS_EVENTS.SIGN_IN_FAILED]: SignInFailedProperties;
@@ -211,6 +250,8 @@ export const INBOX_ANALYTICS_EVENT_NAMES: ReadonlySet<string> = new Set([
211250
ANALYTICS_EVENTS.INBOX_REPORT_CLOSED,
212251
ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED,
213252
ANALYTICS_EVENTS.INBOX_REPORT_ACTION,
253+
ANALYTICS_EVENTS.INBOX_REPORT_FEEDBACK,
254+
ANALYTICS_EVENTS.INBOX_REPORT_FEEDBACK_NOTE,
214255
]);
215256

216257
export function useAnalytics(): Analytics {

‎packages/shared/src/analytics-events.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,10 +615,14 @@ export type InboxReportActionType =
615615

616616
export type InboxReportActionSurface =
617617
| "detail_pane"
618+
| "detail_footer"
618619
| "toolbar"
619620
| "keyboard"
620621
| "list_row";
621622

623+
/** Sentiment captured by the report usefulness thumbs. */
624+
export type InboxReportFeedbackSentiment = "positive" | "negative";
625+
622626
export interface InboxViewedProperties {
623627
report_count: number;
624628
total_count: number;
@@ -741,6 +745,48 @@ export interface InboxReportActionProperties {
741745
feedback_text?: string;
742746
}
743747

748+
/**
749+
* Thumbs-up/down verdict on a report's usefulness, fired from the feedback
750+
* footer at the end of the report body. Feedback-only: unlike a dismiss, the
751+
* report keeps its state. The sentiment is the label ranking work trains
752+
* against, so it carries the same report classification as the impression and
753+
* open events. Mirrors cloud's `Inbox report feedback`
754+
* (`frontend/src/scenes/inbox/inboxAnalytics.ts`) so the clients are
755+
* comparable in one PostHog project. `note` is optional — the thumbs submit on
756+
* one click, with no note.
757+
*/
758+
export interface InboxReportFeedbackProperties {
759+
report_id: string;
760+
report_age_hours: number;
761+
priority: string | null;
762+
actionability: string | null;
763+
sentiment: InboxReportFeedbackSentiment;
764+
/** Whether the report already has an implementation PR. */
765+
has_pr: boolean;
766+
surface: InboxReportActionSurface;
767+
// Optional free-text note, truncated to keep event payloads bounded. Present
768+
// only on the rare path where the rating and note submit together.
769+
note?: string;
770+
}
771+
772+
/**
773+
* Optional free-text note, offered only once a rating is already recorded. It
774+
* rides on its own event rather than re-firing {@link InboxReportFeedbackProperties}
775+
* so sentiment stays exactly one event per rating; join back to the rating on
776+
* `report_id`. Carries `sentiment` too so a note can be read without that join.
777+
*/
778+
export interface InboxReportFeedbackNoteProperties {
779+
report_id: string;
780+
report_age_hours: number;
781+
priority: string | null;
782+
actionability: string | null;
783+
sentiment: InboxReportFeedbackSentiment;
784+
has_pr: boolean;
785+
surface: InboxReportActionSurface;
786+
/** Truncated to keep event payloads bounded. */
787+
note: string;
788+
}
789+
744790
// Scout events
745791
export type ScoutChatType =
746792
| "fleet_overview"
@@ -1346,6 +1392,8 @@ export const ANALYTICS_EVENTS = {
13461392
INBOX_REPORT_CLOSED: "Inbox report closed",
13471393
INBOX_REPORT_ACTION: "Inbox report action",
13481394
INBOX_REPORT_SCROLLED: "Inbox report scrolled",
1395+
INBOX_REPORT_FEEDBACK: "Inbox report feedback",
1396+
INBOX_REPORT_FEEDBACK_NOTE: "Inbox report feedback note",
13491397
SIGNAL_SOURCE_CONNECTED: "Signal source connected",
13501398

13511399
// Agents page events
@@ -1523,6 +1571,8 @@ export type EventPropertyMap = {
15231571
[ANALYTICS_EVENTS.INBOX_REPORT_CLOSED]: InboxReportClosedProperties;
15241572
[ANALYTICS_EVENTS.INBOX_REPORT_ACTION]: InboxReportActionProperties;
15251573
[ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED]: InboxReportScrolledProperties;
1574+
[ANALYTICS_EVENTS.INBOX_REPORT_FEEDBACK]: InboxReportFeedbackProperties;
1575+
[ANALYTICS_EVENTS.INBOX_REPORT_FEEDBACK_NOTE]: InboxReportFeedbackNoteProperties;
15261576
[ANALYTICS_EVENTS.SIGNAL_SOURCE_CONNECTED]: SignalSourceConnectedProperties;
15271577

15281578
// Agents page events
@@ -1592,6 +1642,8 @@ export const INBOX_ANALYTICS_EVENT_NAMES: ReadonlySet<string> = new Set([
15921642
ANALYTICS_EVENTS.INBOX_REPORT_CLOSED,
15931643
ANALYTICS_EVENTS.INBOX_REPORT_ACTION,
15941644
ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED,
1645+
ANALYTICS_EVENTS.INBOX_REPORT_FEEDBACK,
1646+
ANALYTICS_EVENTS.INBOX_REPORT_FEEDBACK_NOTE,
15951647
ANALYTICS_EVENTS.SIGNAL_SOURCE_CONNECTED,
15961648
]);
15971649

‎packages/ui/src/features/inbox/components/ReportDetail.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
import { Button } from "@posthog/quill";
77
import type { SignalReport } from "@posthog/shared/types";
88
import { ReportActivitySection } from "@posthog/ui/features/inbox/components/detail/ReportActivitySection";
9+
import { ReportFeedbackFooter } from "@posthog/ui/features/inbox/components/detail/ReportFeedbackFooter";
910
import { InboxDetailFrame } from "@posthog/ui/features/inbox/components/InboxDetailFrame";
1011
import { InboxReportDetailGate } from "@posthog/ui/features/inbox/components/InboxReportDetailGate";
1112
import { ReportDetailActions } from "@posthog/ui/features/inbox/components/ReportDetailActions";
@@ -62,6 +63,7 @@ function ReportDetailContent({ report }: { report: SignalReport }) {
6263
<ReportTasksSection report={report} />
6364
<SuggestedReviewersSection report={report} />
6465
<ReportActivitySection reportId={report.id} />
66+
<ReportFeedbackFooter report={report} />
6567
</InboxDetailFrame>
6668
);
6769
}

0 commit comments

Comments
 (0)