From 72aa7639087b3a01a3b1d08bcf58cf355a0ba01c Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Fri, 19 Jun 2026 11:53:21 +0100 Subject: [PATCH 1/2] feat(mobile): add inbox Archive tab with restore (port #2704) Ports the desktop "Archive" (dismissed reports) inbox feature to the mobile app. - New Archive segment in the floating inbox view toggle, listing reports the user has dismissed (status `suppressed`), newest-dismissed first. - Dedicated `useArchivedReports` query filtered to suppressed reports, ordered by `updated_at` desc. - Per-row Restore action: revalidates the report against the server first and no-ops if it's no longer suppressed, otherwise issues the `potential` transition, then invalidates the report caches so it moves back into the inbox. Success/failure surfaced via an inline banner + haptics. - Each archived row shows its dismissal reason via a local `dismissalReasonLabel` helper (mirrors the shared helper; Metro only resolves the `@posthog/shared` root barrel, which the mobile app already mirrors for dismissal reasons). - Tests for the suppressed-membership predicate and reason-label fallback. Generated-By: PostHog Code Task-Id: 3819b98a-bc60-4793-bd00-2cbf14ddc84a --- apps/mobile/src/app/(tabs)/inbox.tsx | 23 +- apps/mobile/src/features/inbox/api.ts | 27 +++ .../inbox/components/ArchivedReportList.tsx | 219 ++++++++++++++++++ .../inbox/components/InboxViewToggle.tsx | 65 +++--- .../inbox/components/ReportListRow.tsx | 9 +- apps/mobile/src/features/inbox/constants.ts | 3 + .../features/inbox/hooks/useInboxReports.ts | 54 ++++- apps/mobile/src/features/inbox/types.ts | 4 + apps/mobile/src/features/inbox/utils.test.ts | 23 ++ apps/mobile/src/features/inbox/utils.ts | 23 ++ 10 files changed, 402 insertions(+), 48 deletions(-) create mode 100644 apps/mobile/src/features/inbox/components/ArchivedReportList.tsx diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index 3e33c53586..4013102faa 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -2,13 +2,20 @@ import { useFocusEffect, useRouter } from "expo-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ArchivedReportList } from "@/features/inbox/components/ArchivedReportList"; import { FilterSheet } from "@/features/inbox/components/FilterSheet"; import { FloatingInboxHeader } from "@/features/inbox/components/FloatingInboxHeader"; -import { InboxViewToggle } from "@/features/inbox/components/InboxViewToggle"; +import { + type InboxViewMode, + InboxViewToggle, +} from "@/features/inbox/components/InboxViewToggle"; import { ReportList } from "@/features/inbox/components/ReportList"; import { ReviewerFilterSheet } from "@/features/inbox/components/ReviewerFilterSheet"; import { TinderView } from "@/features/inbox/components/TinderView"; -import { useInboxReports } from "@/features/inbox/hooks/useInboxReports"; +import { + useArchivedReports, + useInboxReports, +} from "@/features/inbox/hooks/useInboxReports"; import { decidedIds, useDismissedReportsStore, @@ -23,8 +30,6 @@ import { buildInboxViewedProperties } from "@/features/inbox/utils"; import { useIntegrations } from "@/features/tasks/hooks/useIntegrations"; import { ANALYTICS_EVENTS, useAnalytics } from "@/lib/analytics"; -type InboxViewMode = "list" | "tinder"; - export default function InboxScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); @@ -33,6 +38,7 @@ export default function InboxScreen() { const [filterOpen, setFilterOpen] = useState(false); const [reviewerOpen, setReviewerOpen] = useState(false); const [viewMode, setViewMode] = useState("list"); + const archived = useArchivedReports({ enabled: viewMode === "archive" }); const reviewerFilterCount = useInboxFilterStore( (s) => s.suggestedReviewerFilter.length, ); @@ -134,6 +140,11 @@ export default function InboxScreen() { onReportPress={handleReportPress} contentInsetTop={headerHeight} /> + ) : viewMode === "archive" ? ( + ) : ( setReviewerOpen(true)} diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts index db72f0d163..8299bf800a 100644 --- a/apps/mobile/src/features/inbox/api.ts +++ b/apps/mobile/src/features/inbox/api.ts @@ -310,3 +310,30 @@ export async function dismissSignalReport( return await response.json(); } + +/** Re-queue a dismissed report into the inbox via the `potential` transition. */ +export async function restoreSignalReport( + reportId: string, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + + const response = await authedFetch( + `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/state/`, + { + method: "POST", + body: JSON.stringify({ state: "potential" }), + }, + ); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + throw new HttpError( + response.status, + response.statusText, + errorText || "Failed to restore signal report", + ); + } + + return await response.json(); +} diff --git a/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx b/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx new file mode 100644 index 0000000000..42ffe856c4 --- /dev/null +++ b/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx @@ -0,0 +1,219 @@ +import { Text } from "@components/text"; +import * as Haptics from "expo-haptics"; +import { ArrowCounterClockwise, Tray } from "phosphor-react-native"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + FlatList, + Pressable, + RefreshControl, + View, +} from "react-native"; +import { useThemeColors } from "@/lib/theme"; +import { useArchivedReports, useRestoreReport } from "../hooks/useInboxReports"; +import type { SignalReport } from "../types"; +import { dismissalReasonLabel, formatReportTimestamp } from "../utils"; + +interface ArchivedReportListProps { + onReportPress?: (report: SignalReport) => void; + contentInsetTop?: number; +} + +type Feedback = { kind: "success" | "info" | "error"; text: string }; + +interface ArchivedRowProps { + report: SignalReport; + onPress: (report: SignalReport) => void; + onRestore: (reportId: string) => void; + restoring: boolean; +} + +const ArchivedRow = memo(function ArchivedRow({ + report, + onPress, + onRestore, + restoring, +}: ArchivedRowProps) { + const themeColors = useThemeColors(); + const when = formatReportTimestamp(new Date(report.updated_at)); + const reasonLabel = report.dismissal_reason + ? dismissalReasonLabel(report.dismissal_reason) + : null; + + return ( + onPress(report)} + className="flex-row items-start gap-2.5 border-gray-6 border-b px-3 py-2.5 active:bg-gray-3" + > + + + {report.title ?? "Untitled signal"} + + + + Archived {when} + {reasonLabel ? ( + + {reasonLabel} + + ) : null} + + + + onRestore(report.id)} + disabled={restoring} + hitSlop={8} + accessibilityLabel="Restore report to inbox" + accessibilityRole="button" + className="flex-row items-center gap-1.5 self-center rounded-full border border-gray-6 px-3 py-1.5 active:bg-gray-3" + > + {restoring ? ( + + ) : ( + <> + + + Restore + + + )} + + + ); +}); + +export function ArchivedReportList({ + onReportPress, + contentInsetTop = 0, +}: ArchivedReportListProps) { + const { reports, isLoading, error, refetch } = useArchivedReports(); + const themeColors = useThemeColors(); + const restore = useRestoreReport(); + const [feedback, setFeedback] = useState(null); + const feedbackTimer = useRef | null>(null); + + const showFeedback = useCallback((next: Feedback) => { + setFeedback(next); + if (feedbackTimer.current) clearTimeout(feedbackTimer.current); + feedbackTimer.current = setTimeout(() => setFeedback(null), 4000); + }, []); + + useEffect(() => { + return () => { + if (feedbackTimer.current) clearTimeout(feedbackTimer.current); + }; + }, []); + + const restoreMutate = restore.mutate; + const handleRestore = useCallback( + (reportId: string) => { + restoreMutate(reportId, { + onSuccess: (restored) => { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + showFeedback( + restored + ? { kind: "success", text: "Restored to inbox" } + : { kind: "info", text: "Already back in your inbox" }, + ); + }, + onError: (err) => { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + showFeedback({ + kind: "error", + text: err.message || "Failed to restore report", + }); + }, + }); + }, + [restoreMutate, showFeedback], + ); + + if (error) { + return ( + + {error} + refetch()} + className="rounded-lg bg-gray-3 px-4 py-2" + > + Retry + + + ); + } + + if (isLoading && reports.length === 0) { + return ( + + + Loading archive... + + ); + } + + if (reports.length === 0) { + return ( + + + + + + Archive is empty + + + Reports you dismiss show up here. + + + ); + } + + return ( + + item.id} + renderItem={({ item }) => ( + onReportPress?.(report)} + onRestore={handleRestore} + restoring={restore.isPending && restore.variables === item.id} + /> + )} + refreshControl={ + refetch()} + tintColor={themeColors.accent[9]} + progressViewOffset={contentInsetTop} + /> + } + contentContainerStyle={{ + paddingTop: contentInsetTop, + paddingBottom: 100, + }} + /> + + {feedback ? ( + + + {feedback.text} + + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/inbox/components/InboxViewToggle.tsx b/apps/mobile/src/features/inbox/components/InboxViewToggle.tsx index 9ffbad6bc3..2c8665f077 100644 --- a/apps/mobile/src/features/inbox/components/InboxViewToggle.tsx +++ b/apps/mobile/src/features/inbox/components/InboxViewToggle.tsx @@ -1,10 +1,16 @@ import * as Haptics from "expo-haptics"; -import { Cards, ListBullets } from "phosphor-react-native"; +import { Archive, Cards, type Icon, ListBullets } from "phosphor-react-native"; import { Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColors } from "@/lib/theme"; -type InboxViewMode = "list" | "tinder"; +export type InboxViewMode = "list" | "tinder" | "archive"; + +const VIEW_MODES: { mode: InboxViewMode; icon: Icon; label: string }[] = [ + { mode: "list", icon: ListBullets, label: "List view" }, + { mode: "tinder", icon: Cards, label: "Card view" }, + { mode: "archive", icon: Archive, label: "Archive" }, +]; interface InboxViewToggleProps { mode: InboxViewMode; @@ -12,8 +18,8 @@ interface InboxViewToggleProps { } /** - * Floating pill toggle at the bottom of the inbox screen. Two icons — list - * view and tinder/card view — with the active one highlighted. + * Floating pill toggle at the bottom of the inbox screen, with the active + * segment highlighted. */ export function InboxViewToggle({ mode, onModeChange }: InboxViewToggleProps) { const insets = useSafeAreaInsets(); @@ -32,36 +38,27 @@ export function InboxViewToggle({ mode, onModeChange }: InboxViewToggleProps) { pointerEvents="box-none" > - handlePress("list")} - hitSlop={4} - className={`items-center justify-center rounded-full px-5 py-3 ${mode === "list" ? "bg-accent-9" : "active:bg-gray-3"}`} - > - - - handlePress("tinder")} - hitSlop={4} - className={`items-center justify-center rounded-full px-5 py-3 ${mode === "tinder" ? "bg-accent-9" : "active:bg-gray-3"}`} - > - - + {VIEW_MODES.map(({ mode: m, icon: IconCmp, label }) => { + const active = mode === m; + return ( + handlePress(m)} + hitSlop={4} + accessibilityLabel={label} + accessibilityRole="button" + className={`items-center justify-center rounded-full px-5 py-3 ${active ? "bg-accent-9" : "active:bg-gray-3"}`} + > + + + ); + })} ); diff --git a/apps/mobile/src/features/inbox/components/ReportListRow.tsx b/apps/mobile/src/features/inbox/components/ReportListRow.tsx index d3cdced3c6..3d721e6cda 100644 --- a/apps/mobile/src/features/inbox/components/ReportListRow.tsx +++ b/apps/mobile/src/features/inbox/components/ReportListRow.tsx @@ -1,10 +1,10 @@ import { Text } from "@components/text"; -import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import { memo } from "react"; import { Pressable, View } from "react-native"; import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge"; import { useThemeColors } from "@/lib/theme"; import type { SignalReport } from "../types"; +import { formatReportTimestamp } from "../utils"; interface ReportListRowProps { report: SignalReport; @@ -34,12 +34,7 @@ const priorityColorMap: Record = { function ReportListRowComponent({ report, onPress }: ReportListRowProps) { const themeColors = useThemeColors(); - const updatedAt = new Date(report.updated_at); - const hoursSince = differenceInHours(new Date(), updatedAt); - const timeDisplay = - hoursSince < 24 - ? formatDistanceToNow(updatedAt, { addSuffix: true }) - : format(updatedAt, "MMM d"); + const timeDisplay = formatReportTimestamp(new Date(report.updated_at)); const dotKind = statusDotMap[report.status] ?? "muted"; const dotColor = diff --git a/apps/mobile/src/features/inbox/constants.ts b/apps/mobile/src/features/inbox/constants.ts index ee5861623c..76315826f3 100644 --- a/apps/mobile/src/features/inbox/constants.ts +++ b/apps/mobile/src/features/inbox/constants.ts @@ -2,6 +2,9 @@ export const INBOX_PIPELINE_STATUS_FILTER = "potential,candidate,in_progress,ready,pending_input"; +/** Status filter for the Archive view — reports the user has dismissed. */ +export const INBOX_DISMISSED_STATUS_FILTER = "suppressed"; + /** Polling interval for inbox queries (ms). */ export const INBOX_REFETCH_INTERVAL_MS = 5_000; diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts index 785d112d6e..b6b144dd80 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts @@ -9,9 +9,13 @@ import { getSignalReportArtefacts, getSignalReportSignals, getSignalReports, + restoreSignalReport, updateSignalReportArtefact, } from "../api"; -import { INBOX_REFETCH_INTERVAL_MS } from "../constants"; +import { + INBOX_DISMISSED_STATUS_FILTER, + INBOX_REFETCH_INTERVAL_MS, +} from "../constants"; import { useInboxFilterStore } from "../stores/inboxFilterStore"; import type { AvailableSuggestedReviewersResponse, @@ -29,12 +33,15 @@ import { buildSignalReportListOrdering, buildStatusFilterParam, buildSuggestedReviewerFilterParam, + isArchivedReport, } from "../utils"; export const inboxKeys = { all: ["inbox", "signal-reports"] as const, list: (params?: SignalReportsQueryParams) => [...inboxKeys.all, "list", params ?? {}] as const, + archived: (params?: SignalReportsQueryParams) => + [...inboxKeys.all, "archived", params ?? {}] as const, detail: (reportId: string) => [...inboxKeys.all, reportId, "detail"] as const, artefacts: (reportId: string) => [...inboxKeys.all, reportId, "artefacts"] as const, @@ -85,6 +92,30 @@ export function useInboxReports(options?: { enabled?: boolean }) { }; } +export function useArchivedReports(options?: { enabled?: boolean }) { + const { projectId, oauthAccessToken } = useAuthStore(); + + const params: SignalReportsQueryParams = { + status: INBOX_DISMISSED_STATUS_FILTER, + ordering: buildSignalReportListOrdering("updated_at", "desc"), + }; + + const query = useQuery({ + queryKey: inboxKeys.archived(params), + queryFn: () => getSignalReports(params), + enabled: !!projectId && !!oauthAccessToken && (options?.enabled ?? true), + }); + + return { + reports: query.data?.results ?? [], + totalCount: query.data?.count ?? 0, + isLoading: query.isLoading, + isFetching: query.isFetching, + error: query.error?.message ?? null, + refetch: query.refetch, + }; +} + export function useInboxReport(reportId: string | null) { const { projectId, oauthAccessToken } = useAuthStore(); @@ -206,3 +237,24 @@ export function useDismissReport(reportId: string) { }, }); } + +export function useRestoreReport() { + const queryClient = useQueryClient(); + + // Resolves to whether the report was actually re-queued. Revalidate against + // the server first so a stale row can't silently reopen an already-active + // report. + return useMutation({ + mutationFn: async (reportId) => { + const current = await getSignalReport(reportId); + if (current && !isArchivedReport(current)) { + return false; + } + await restoreSignalReport(reportId); + return true; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: inboxKeys.all }); + }, + }); +} diff --git a/apps/mobile/src/features/inbox/types.ts b/apps/mobile/src/features/inbox/types.ts index 04afb0bff6..00aa875ba3 100644 --- a/apps/mobile/src/features/inbox/types.ts +++ b/apps/mobile/src/features/inbox/types.ts @@ -1,3 +1,5 @@ +import type { DismissalReasonOptionValue } from "./constants"; + export type SignalReportStatus = | "potential" | "candidate" @@ -29,6 +31,8 @@ export interface SignalReport { priority?: SignalReportPriority | null; actionability?: SignalReportActionability | null; already_addressed?: boolean | null; + dismissal_reason?: DismissalReasonOptionValue | null; + dismissal_note?: string | null; is_suggested_reviewer?: boolean; source_products?: string[]; implementation_pr_url?: string | null; diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts index 491223f889..644f019395 100644 --- a/apps/mobile/src/features/inbox/utils.test.ts +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -9,7 +9,9 @@ import { buildInboxViewedProperties, buildPriorityFilterParam, buildReviewerOptions, + dismissalReasonLabel, formatSignalReportSummaryMarkdown, + isArchivedReport, orderSuggestedReviewers, reviewerMatchesAvailable, toSuggestedReviewerWriteContent, @@ -360,6 +362,27 @@ describe("buildPriorityFilterParam", () => { }); }); +describe("isArchivedReport", () => { + it.each([ + { status: "suppressed" as SignalReportStatus, expected: true }, + { status: "ready" as SignalReportStatus, expected: false }, + { status: "potential" as SignalReportStatus, expected: false }, + { status: "deleted" as SignalReportStatus, expected: false }, + ])("is $expected for $status", ({ status, expected }) => { + expect(isArchivedReport({ status })).toBe(expected); + }); +}); + +describe("dismissalReasonLabel", () => { + it.each([ + { value: "analysis_wrong", expected: "Agent's analysis is wrong" }, + { value: "other", expected: "Something else…" }, + { value: "totally_unknown_code", expected: "totally_unknown_code" }, + ])("maps $value", ({ value, expected }) => { + expect(dismissalReasonLabel(value)).toBe(expected); + }); +}); + describe("buildReviewerOptions", () => { it("dedupes by uuid and pins the current user first", () => { const options = buildReviewerOptions( diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts index 13dc99eef5..cb331ba8ad 100644 --- a/apps/mobile/src/features/inbox/utils.ts +++ b/apps/mobile/src/features/inbox/utils.ts @@ -1,4 +1,6 @@ +import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import type { InboxViewedProperties } from "@/lib/analytics"; +import { DISMISSAL_REASON_OPTIONS } from "./constants"; import type { AvailableSuggestedReviewer, SignalReport, @@ -35,6 +37,27 @@ export function formatSignalReportSummaryMarkdown(content: string): string { return result; } +/** Relative time for the last day, absolute "MMM d" beyond it. */ +export function formatReportTimestamp(date: Date): string { + return differenceInHours(new Date(), date) < 24 + ? formatDistanceToNow(date, { addSuffix: true }) + : format(date, "MMM d"); +} + +/** A report is archived when it has been dismissed (suppressed). */ +export function isArchivedReport( + report: Pick, +): boolean { + return report.status === "suppressed"; +} + +/** Human label for a persisted dismissal reason, falling back to the raw code. */ +export function dismissalReasonLabel(value: string): string { + return ( + DISMISSAL_REASON_OPTIONS.find((o) => o.value === value)?.label ?? value + ); +} + export function inboxStatusLabel(status: SignalReportStatus): string { switch (status) { case "ready": From 6e1096dcfb566259122cfc90b723aa4e7b34dee4 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Fri, 19 Jun 2026 12:11:07 +0100 Subject: [PATCH 2/2] refactor(mobile): stabilize archived row press handler Hoist the inline onPress arrow into a useCallback so it no longer defeats the memo() on ArchivedRow, completing the row memoization (onRestore was already stabilized). Generated-By: PostHog Code Task-Id: 3819b98a-bc60-4793-bd00-2cbf14ddc84a --- .../src/features/inbox/components/ArchivedReportList.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx b/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx index 42ffe856c4..73af5ff02a 100644 --- a/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx +++ b/apps/mobile/src/features/inbox/components/ArchivedReportList.tsx @@ -109,6 +109,11 @@ export function ArchivedReportList({ }; }, []); + const handlePress = useCallback( + (report: SignalReport) => onReportPress?.(report), + [onReportPress], + ); + const restoreMutate = restore.mutate; const handleRestore = useCallback( (reportId: string) => { @@ -180,7 +185,7 @@ export function ArchivedReportList({ renderItem={({ item }) => ( onReportPress?.(report)} + onPress={handlePress} onRestore={handleRestore} restoring={restore.isPending && restore.variables === item.id} />