@@ -14,19 +14,32 @@ import {
1414} from "phosphor-react-native" ;
1515import { usePostHog } from "posthog-react-native" ;
1616import { useCallback , useEffect , useMemo , useState } from "react" ;
17- import { ActivityIndicator , Pressable , ScrollView , View } from "react-native" ;
17+ import {
18+ ActivityIndicator ,
19+ type NativeScrollEvent ,
20+ type NativeSyntheticEvent ,
21+ Pressable ,
22+ ScrollView ,
23+ View ,
24+ } from "react-native" ;
1825import { useSafeAreaInsets } from "react-native-safe-area-context" ;
1926import { MarkdownText } from "@/features/chat/components/MarkdownText" ;
2027import { getReportRepository } from "@/features/inbox/api" ;
2128import { DiscussReportSheet } from "@/features/inbox/components/DiscussReportSheet" ;
22- import { DismissReportSheet } from "@/features/inbox/components/DismissReportSheet" ;
29+ import {
30+ type DismissReportResult ,
31+ DismissReportSheet ,
32+ } from "@/features/inbox/components/DismissReportSheet" ;
2333import { SignalCard } from "@/features/inbox/components/SignalCard" ;
2434import { SuggestedReviewers } from "@/features/inbox/components/SuggestedReviewers" ;
35+ import { DISMISSAL_REASON_OPTIONS } from "@/features/inbox/constants" ;
36+ import { useInboxEngagementTracker } from "@/features/inbox/hooks/useInboxEngagementTracker" ;
2537import {
2638 useInboxReport ,
2739 useInboxReportArtefacts ,
2840 useInboxReportSignals ,
2941} from "@/features/inbox/hooks/useInboxReports" ;
42+ import { useInboxStore } from "@/features/inbox/stores/inboxStore" ;
3043import type {
3144 ActionabilityJudgmentContent ,
3245 SignalFindingContent ,
@@ -35,6 +48,7 @@ import type {
3548 SuggestedReviewer ,
3649} from "@/features/inbox/types" ;
3750import { inboxStatusLabel } from "@/features/inbox/utils" ;
51+ import { computeReportAgeHours , useAnalytics } from "@/lib/analytics" ;
3852import { useThemeColors } from "@/lib/theme" ;
3953
4054const statusColorMap : Record < string , { bg : string ; text : string } > = {
@@ -128,6 +142,59 @@ export default function ReportDetailScreen() {
128142 const artefactsQuery = useInboxReportArtefacts ( reportId ?? null ) ;
129143 const signalsQuery = useInboxReportSignals ( reportId ?? null ) ;
130144
145+ // ── Engagement analytics ────────────────────────────────────────────────
146+ const analytics = useAnalytics ( ) ;
147+ const lastVisibleReportIds = useInboxStore ( ( s ) => s . lastVisibleReportIds ) ;
148+ const previousOpenedReportId = useInboxStore ( ( s ) => s . previousOpenedReportId ) ;
149+ const setPreviousOpenedReportId = useInboxStore (
150+ ( s ) => s . setPreviousOpenedReportId ,
151+ ) ;
152+ const rank = useMemo ( ( ) => {
153+ if ( ! reportId ) return - 1 ;
154+ const idx = lastVisibleReportIds . indexOf ( reportId ) ;
155+ return idx ;
156+ } , [ reportId , lastVisibleReportIds ] ) ;
157+ const listSize = lastVisibleReportIds . length ;
158+ const tracker = useInboxEngagementTracker ( {
159+ analytics,
160+ report : report ?? null ,
161+ rank,
162+ listSize,
163+ openMethod : "click" ,
164+ previousReportId : previousOpenedReportId ,
165+ } ) ;
166+ // Remember this report as the "previous" once it's been opened so the next
167+ // OPENED event can chain to it.
168+ useEffect ( ( ) => {
169+ if ( ! reportId ) return ;
170+ setPreviousOpenedReportId ( reportId ) ;
171+ } , [ reportId , setPreviousOpenedReportId ] ) ;
172+
173+ const handleScroll = useCallback (
174+ ( _event : NativeSyntheticEvent < NativeScrollEvent > ) => {
175+ tracker . signalScroll ( ) ;
176+ } ,
177+ [ tracker ] ,
178+ ) ;
179+
180+ const handleToggleSignals = useCallback ( ( ) => {
181+ // Fire analytics outside the state updater — Strict Mode double-invokes
182+ // updaters in development, which would double-fire the event.
183+ const next = ! signalsExpanded ;
184+ if ( next && report ) {
185+ tracker . signalAction ( {
186+ report_id : report . id ,
187+ report_title : report . title ?? null ,
188+ report_age_hours : computeReportAgeHours ( report . created_at ) ,
189+ action_type : "expand_signal" ,
190+ surface : "detail_pane" ,
191+ is_bulk : false ,
192+ bulk_size : 1 ,
193+ } ) ;
194+ }
195+ setSignalsExpanded ( next ) ;
196+ } , [ report , tracker , signalsExpanded ] ) ;
197+
131198 useEffect ( ( ) => {
132199 if ( ! reportId ) return ;
133200 let cancelled = false ;
@@ -187,6 +254,15 @@ export default function ReportDetailScreen() {
187254 const handleStartTask = useCallback ( ( ) => {
188255 if ( ! report ) return ;
189256 Haptics . impactAsync ( Haptics . ImpactFeedbackStyle . Medium ) ;
257+ tracker . signalAction ( {
258+ report_id : report . id ,
259+ report_title : report . title ?? null ,
260+ report_age_hours : computeReportAgeHours ( report . created_at ) ,
261+ action_type : "create_pr" ,
262+ surface : "detail_pane" ,
263+ is_bulk : false ,
264+ bulk_size : 1 ,
265+ } ) ;
190266 const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${ report . summary ?? "" } ` ;
191267 router . push ( {
192268 pathname : "/task" ,
@@ -196,12 +272,41 @@ export default function ReportDetailScreen() {
196272 signalReport : report . id ,
197273 } ,
198274 } ) ;
199- } , [ report , router , reportRepo ] ) ;
200-
201- const handleDismissed = useCallback ( ( ) => {
202- setDismissOpen ( false ) ;
203- if ( router . canGoBack ( ) ) router . back ( ) ;
204- } , [ router ] ) ;
275+ } , [ report , router , reportRepo , tracker ] ) ;
276+
277+ const handleDismissed = useCallback (
278+ ( result : DismissReportResult ) => {
279+ setDismissOpen ( false ) ;
280+ if ( report ) {
281+ const reasonOption = DISMISSAL_REASON_OPTIONS . find (
282+ ( o ) => o . value === result . reason ,
283+ ) ;
284+ const isSnooze =
285+ reasonOption !== undefined &&
286+ "snoozesInsteadOfDismiss" in reasonOption &&
287+ reasonOption . snoozesInsteadOfDismiss === true ;
288+ tracker . signalAction ( {
289+ report_id : report . id ,
290+ report_title : report . title ?? null ,
291+ report_age_hours : computeReportAgeHours ( report . created_at ) ,
292+ action_type : isSnooze ? "snooze" : "dismiss" ,
293+ surface : "detail_pane" ,
294+ is_bulk : false ,
295+ bulk_size : 1 ,
296+ ...( isSnooze
297+ ? { }
298+ : {
299+ dismissal_reason : result . reason ,
300+ ...( result . note
301+ ? { dismissal_note : result . note . slice ( 0 , 1000 ) }
302+ : { } ) ,
303+ } ) ,
304+ } ) ;
305+ }
306+ if ( router . canGoBack ( ) ) router . back ( ) ;
307+ } ,
308+ [ router , report , tracker ] ,
309+ ) ;
205310
206311 const handleDiscussSubmit = useCallback (
207312 ( { prompt, question } : { prompt : string ; question : string } ) => {
@@ -293,6 +398,8 @@ export default function ReportDetailScreen() {
293398 paddingTop : 16 ,
294399 paddingBottom : insets . bottom + 100 ,
295400 } }
401+ onScroll = { handleScroll }
402+ scrollEventThrottle = { 250 }
296403 >
297404 { /* Badges row */ }
298405 < View className = "mb-3 flex-row flex-wrap items-center gap-1.5" >
@@ -370,7 +477,7 @@ export default function ReportDetailScreen() {
370477 { signals . length > 0 && (
371478 < View className = "mb-4" >
372479 < Pressable
373- onPress = { ( ) => setSignalsExpanded ( ( v ) => ! v ) }
480+ onPress = { handleToggleSignals }
374481 hitSlop = { 6 }
375482 accessibilityRole = "button"
376483 accessibilityState = { { expanded : signalsExpanded } }
0 commit comments