From 43f31a2654defb1f43d94fc86729978780bf9385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:46:32 +0000 Subject: [PATCH 1/7] feat(mobile): add the notification history screen A signed-in user could not review the notifications an account received: the app had one notification settings screen and no list of past notifications. Add a Notification history row under Notifications in the Preferences hub and a screen that lists the recorded notifications newest first. Each row is rendered on the device from the stored catalog key and its params in the app language, falls back to the stored copy when that key is gone, and opens the same destination a push tap opens, including the organization switch. The list filters by the recorded categories, pages with a retention bound, pulls to refresh, and covers loading, empty with the retention window, error and retry states. A device whose pushes are off still lists what arrived and offers a link to the notification settings. The rows are typed from `user.getNotificationHistory`, and that output type only resolves once the tRPC bundle can resolve the notifications package it names: without it in the bundle's dependencies every mobile consumer saw the item type as an untyped index signature. --- .../(3_profile)/notification-history.tsx | 5 + ...tification-history-screen.mounted.test.tsx | 297 ++++++++++++++++ .../notification-history-screen.tsx | 316 ++++++++++++++++++ .../preferences-screen.mounted.test.tsx | 12 +- .../src/components/preferences-screen.tsx | 20 +- apps/mobile/src/i18n/locales/af.json | 10 + apps/mobile/src/i18n/locales/am.json | 10 + apps/mobile/src/i18n/locales/ar.json | 10 + apps/mobile/src/i18n/locales/az.json | 10 + apps/mobile/src/i18n/locales/be.json | 10 + apps/mobile/src/i18n/locales/bg.json | 10 + apps/mobile/src/i18n/locales/bn.json | 10 + apps/mobile/src/i18n/locales/bs.json | 10 + apps/mobile/src/i18n/locales/ca.json | 10 + apps/mobile/src/i18n/locales/ckb.json | 10 + apps/mobile/src/i18n/locales/cs.json | 10 + apps/mobile/src/i18n/locales/cy.json | 10 + apps/mobile/src/i18n/locales/da.json | 10 + apps/mobile/src/i18n/locales/de.json | 10 + apps/mobile/src/i18n/locales/el.json | 10 + apps/mobile/src/i18n/locales/en.json | 10 + apps/mobile/src/i18n/locales/es.json | 10 + apps/mobile/src/i18n/locales/et.json | 10 + apps/mobile/src/i18n/locales/eu.json | 10 + apps/mobile/src/i18n/locales/fa.json | 10 + apps/mobile/src/i18n/locales/fi.json | 10 + apps/mobile/src/i18n/locales/fil.json | 10 + apps/mobile/src/i18n/locales/fr.json | 10 + apps/mobile/src/i18n/locales/ga.json | 10 + apps/mobile/src/i18n/locales/gl.json | 10 + apps/mobile/src/i18n/locales/gu.json | 10 + apps/mobile/src/i18n/locales/ha.json | 10 + apps/mobile/src/i18n/locales/he.json | 10 + apps/mobile/src/i18n/locales/hi.json | 10 + apps/mobile/src/i18n/locales/hr.json | 10 + apps/mobile/src/i18n/locales/ht.json | 10 + apps/mobile/src/i18n/locales/hu.json | 10 + apps/mobile/src/i18n/locales/hy.json | 10 + apps/mobile/src/i18n/locales/id.json | 10 + apps/mobile/src/i18n/locales/ig.json | 10 + apps/mobile/src/i18n/locales/is.json | 10 + apps/mobile/src/i18n/locales/it.json | 10 + apps/mobile/src/i18n/locales/ja.json | 10 + apps/mobile/src/i18n/locales/ka.json | 10 + apps/mobile/src/i18n/locales/kk.json | 10 + apps/mobile/src/i18n/locales/km.json | 10 + apps/mobile/src/i18n/locales/kn.json | 10 + apps/mobile/src/i18n/locales/ko.json | 10 + apps/mobile/src/i18n/locales/lo.json | 10 + apps/mobile/src/i18n/locales/lt.json | 10 + apps/mobile/src/i18n/locales/lv.json | 10 + apps/mobile/src/i18n/locales/mg.json | 10 + apps/mobile/src/i18n/locales/mi.json | 10 + apps/mobile/src/i18n/locales/mk.json | 10 + apps/mobile/src/i18n/locales/ml.json | 10 + apps/mobile/src/i18n/locales/mn.json | 10 + apps/mobile/src/i18n/locales/mr.json | 10 + apps/mobile/src/i18n/locales/ms.json | 10 + apps/mobile/src/i18n/locales/mt.json | 10 + apps/mobile/src/i18n/locales/my.json | 10 + apps/mobile/src/i18n/locales/nb.json | 10 + apps/mobile/src/i18n/locales/ne.json | 10 + apps/mobile/src/i18n/locales/nl.json | 10 + apps/mobile/src/i18n/locales/om.json | 10 + apps/mobile/src/i18n/locales/or.json | 10 + apps/mobile/src/i18n/locales/pa.json | 10 + apps/mobile/src/i18n/locales/pl.json | 10 + apps/mobile/src/i18n/locales/ps.json | 10 + apps/mobile/src/i18n/locales/pt-BR.json | 10 + apps/mobile/src/i18n/locales/pt.json | 10 + apps/mobile/src/i18n/locales/ro.json | 10 + apps/mobile/src/i18n/locales/ru.json | 10 + apps/mobile/src/i18n/locales/si.json | 10 + apps/mobile/src/i18n/locales/sk.json | 10 + apps/mobile/src/i18n/locales/sl.json | 10 + apps/mobile/src/i18n/locales/so.json | 10 + apps/mobile/src/i18n/locales/sq.json | 10 + apps/mobile/src/i18n/locales/sr.json | 10 + apps/mobile/src/i18n/locales/sv.json | 10 + apps/mobile/src/i18n/locales/sw.json | 10 + apps/mobile/src/i18n/locales/ta.json | 10 + apps/mobile/src/i18n/locales/te.json | 10 + apps/mobile/src/i18n/locales/th.json | 10 + apps/mobile/src/i18n/locales/tr.json | 10 + apps/mobile/src/i18n/locales/uk.json | 10 + apps/mobile/src/i18n/locales/ur.json | 10 + apps/mobile/src/i18n/locales/uz.json | 10 + apps/mobile/src/i18n/locales/vi.json | 10 + apps/mobile/src/i18n/locales/yo.json | 10 + apps/mobile/src/i18n/locales/zh-Hans.json | 10 + apps/mobile/src/i18n/locales/zh-Hant.json | 10 + apps/mobile/src/i18n/locales/zu.json | 10 + .../lib/hooks/use-notification-master-gate.ts | 74 ++++ .../src/lib/notification-history.test.ts | 157 +++++++++ apps/mobile/src/lib/notification-history.ts | 143 ++++++++ packages/trpc/package.json | 1 + pnpm-lock.yaml | 3 + 97 files changed, 1896 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/app/(app)/(tabs)/(3_profile)/notification-history.tsx create mode 100644 apps/mobile/src/components/notification-history-screen.mounted.test.tsx create mode 100644 apps/mobile/src/components/notification-history-screen.tsx create mode 100644 apps/mobile/src/lib/hooks/use-notification-master-gate.ts create mode 100644 apps/mobile/src/lib/notification-history.test.ts create mode 100644 apps/mobile/src/lib/notification-history.ts diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/notification-history.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/notification-history.tsx new file mode 100644 index 00000000000..a7a5f7ebbdc --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/notification-history.tsx @@ -0,0 +1,5 @@ +import { NotificationHistoryScreen } from '@/components/notification-history-screen'; + +export default function NotificationHistoryRoute() { + return ; +} diff --git a/apps/mobile/src/components/notification-history-screen.mounted.test.tsx b/apps/mobile/src/components/notification-history-screen.mounted.test.tsx new file mode 100644 index 00000000000..4df949231b0 --- /dev/null +++ b/apps/mobile/src/components/notification-history-screen.mounted.test.tsx @@ -0,0 +1,297 @@ +import { createElement, type ReactElement } from 'react'; +import { act, type ReactTestInstance } from '@/test/renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { NotificationHistoryScreen } from '@/components/notification-history-screen'; +import { renderWithProviders, waitFor } from '@/test/render-with-providers'; + +type Row = { + id: string; + category: string | null; + pushType: string; + payload: Record; + i18nKey: string | null; + i18nParams: Record | null; + title: string; + body: string; + createdAt: string; +}; + +const NOW = Date.parse('2026-10-24T10:00:00.000Z'); + +type PreferencesFixture = { capabilities: Record }; +type GateFixture = { + loading: boolean; + settled: boolean; + permissionGranted: boolean; + pushEnabled: boolean; +}; + +const state = vi.hoisted(() => { + const preferences: PreferencesFixture | undefined = { capabilities: {} }; + const gate: GateFixture = { + loading: false, + settled: true, + permissionGranted: true, + pushEnabled: true, + }; + return { + rows: [] as Row[], + historyError: undefined as Error | undefined, + preferences, + gate, + /** The inputs `infiniteQueryOptions` was built with, newest last. */ + historyInputs: [] as { category?: string }[], + }; +}); + +const mocks = vi.hoisted(() => ({ + push: vi.fn(), + setPendingDeepLink: vi.fn(), + toastError: vi.fn(), +})); + +vi.mock('@shopify/flash-list', () => ({ + FlashList: ({ + data, + renderItem, + }: { + data: Row[]; + renderItem: (info: { item: Row }) => ReactElement; + }) => + createElement( + 'FlashList', + null, + data.map(item => createElement('Cell', { key: item.id }, renderItem({ item }))) + ), +})); + +vi.mock('react-native', () => ({ + View: 'View', + Pressable: 'Pressable', + ScrollView: 'ScrollView', +})); + +vi.mock('expo-router', () => ({ useRouter: () => ({ push: mocks.push }) })); +vi.mock('sonner-native', () => ({ toast: { error: mocks.toastError } })); +vi.mock('@/components/ui/icons', () => ({ BellOff: 'BellOff' })); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/refresh-control', () => ({ RefreshControl: 'RefreshControl' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/tab-screen', () => ({ useTabBarBottomPadding: () => 0 })); +vi.mock('@/lib/auth/auth-context', () => ({ useAuth: () => ({ token: 'jwt' }) })); +vi.mock('@/lib/hooks/use-now-ticker', () => ({ useNowTicker: () => NOW })); +vi.mock('@/lib/hooks/use-notification-master-gate', () => ({ + useNotificationMasterGate: () => state.gate, +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#71717a' }), +})); +vi.mock('@/lib/deep-link-launch', () => ({ + setPendingDeepLink: mocks.setPendingDeepLink, +})); +vi.mock('@/lib/notification-actions', () => ({ + pendingDeepLinkOptionsForData: () => ({ sessionBound: false }), +})); +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + user: { + getNotificationHistory: { + infiniteQueryOptions: (input: { category?: string }, options: object) => { + state.historyInputs.push(input); + return { + queryKey: ['user', 'getNotificationHistory', input], + initialPageParam: undefined, + queryFn: () => { + if (state.historyError) { + throw state.historyError; + } + const items = state.rows.filter( + entry => input.category === undefined || entry.category === input.category + ); + return { items, nextCursor: null }; + }, + ...options, + }; + }, + }, + getNotificationPreferences: { + queryOptions: () => ({ + queryKey: ['user', 'getNotificationPreferences'], + queryFn: () => state.preferences, + }), + }, + }, + }), +})); + +function row(overrides: Partial = {}): Row { + return { + id: 'row-1', + category: 'balanceAlerts', + pushType: 'low_balance', + payload: { type: 'low_balance', organizationId: 'org-acme' }, + i18nKey: 'internal.lowBalance', + i18nParams: { organizationName: 'Acme', minimumBalanceUsd: '5' }, + title: 'stored title', + body: 'stored body', + createdAt: new Date(NOW - 5 * 60_000).toISOString(), + ...overrides, + }; +} + +let view: Awaited> | undefined = undefined; + +async function mount(): Promise>> { + view = await renderWithProviders(); + await waitFor(() => view?.renderer.root.findAllByType('Skeleton').length === 0); + return view; +} + +function byType(type: string) { + const root = view?.renderer.root; + if (!root) { + throw new Error('screen not mounted'); + } + return root.findAll(node => node.type === type); +} + +/** Press a control; a missing control fails the test instead of a no-op. */ +function press(node: ReactTestInstance | undefined) { + if (!node) { + throw new Error('control not mounted'); + } + act(() => { + (node.props.onPress as () => void)(); + }); +} + +/** The node whose `Text` child holds `value`. */ +function withText(nodes: ReactTestInstance[], value: string) { + return nodes.find( + node => + node.findAll( + child => + typeof child.type === 'string' && + (child.type as string) === 'Text' && + child.props.children === value + ).length > 0 + ); +} + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('__DEV__', false); + state.rows = []; + state.historyError = undefined; + state.preferences = { capabilities: {} }; + state.gate = { loading: false, settled: true, permissionGranted: true, pushEnabled: true }; + state.historyInputs = []; + vi.clearAllMocks(); +}); + +afterEach(() => { + view?.unmount(); + view = undefined; + vi.unstubAllGlobals(); +}); + +describe('NotificationHistoryScreen', () => { + it('renders each row from the stored catalog key in the app language', async () => { + state.rows = [row()]; + await mount(); + + const texts = byType('Text').map(node => node.props.children); + expect(texts).toContain('Low balance alert'); + expect(texts).toContain('Acme balance fell below $5'); + expect(texts).toContain('5 minutes ago'); + expect(texts).not.toContain('stored title'); + }); + + it('filters the list by the tapped chip', async () => { + state.rows = [ + row(), + row({ id: 'row-2', category: 'chatMessages', pushType: 'chat.message' }), + ]; + await mount(); + expect(byType('Cell')).toHaveLength(2); + + const chatChip = withText(byType('Button'), 'Chat messages'); + press(chatChip); + + await waitFor(() => byType('Cell').length === 1); + expect(state.historyInputs.at(-1)).toMatchObject({ category: 'chatMessages' }); + }); + + it('opens a row through the pending deep link slot, not the router', async () => { + state.rows = [row()]; + await mount(); + + const pressable = byType('Pressable')[0]; + press(pressable); + + expect(mocks.setPendingDeepLink).toHaveBeenCalledWith( + '/(app)/(tabs)/(3_profile)/organization/credit-activity?org=org-acme', + 'notification', + { sessionBound: false } + ); + expect(mocks.push).not.toHaveBeenCalled(); + }); + + it('keeps the list and reports a row whose stored payload no longer resolves', async () => { + state.rows = [row({ payload: { type: 'retired_kind' } })]; + await mount(); + + const pressable = byType('Pressable')[0]; + press(pressable); + + expect(mocks.toastError).toHaveBeenCalledWith('This notification no longer opens anywhere.'); + expect(mocks.setPendingDeepLink).not.toHaveBeenCalled(); + expect(byType('Cell')).toHaveLength(1); + }); + + it('states the retention window when the account has no rows', async () => { + await mount(); + + const empty = byType('EmptyState')[0]; + expect(empty?.props).toMatchObject({ + title: 'No notifications yet', + description: + 'Notifications this account receives appear here. They are kept for 90 days.', + }); + }); + + it('offers a retry when the first page fails', async () => { + state.historyError = new Error('offline'); + await mount(); + + const failure = byType('QueryError')[0]; + expect(failure?.props).toMatchObject({ message: 'Could not load notification history.' }); + expect(typeof failure?.props.onRetry).toBe('function'); + }); + + it('shows the settings banner while the push gate is closed and still loads the list', async () => { + state.gate = { loading: false, settled: true, permissionGranted: false, pushEnabled: false }; + state.rows = [row()]; + await mount(); + + const banner = withText(byType('Button'), 'Notification settings'); + expect(banner).toBeDefined(); + expect(byType('Cell')).toHaveLength(1); + + press(banner); + expect(mocks.push).toHaveBeenCalledWith('/(app)/notifications'); + }); + + it('shows no banner while push reaches the device', async () => { + state.rows = [row()]; + await mount(); + + const texts = byType('Text').map(node => node.props.children); + expect(texts).not.toContain('Notification settings'); + }); +}); diff --git a/apps/mobile/src/components/notification-history-screen.tsx b/apps/mobile/src/components/notification-history-screen.tsx new file mode 100644 index 00000000000..12c6798ed17 --- /dev/null +++ b/apps/mobile/src/components/notification-history-screen.tsx @@ -0,0 +1,316 @@ +import { FlashList } from '@shopify/flash-list'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { type Href, useRouter } from 'expo-router'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, View, type ViewStyle } from 'react-native'; +import { toast } from 'sonner-native'; + +import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; +import { NOTIFICATION_HISTORY_RETENTION_DAYS } from '@kilocode/notifications'; + +import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; +import { ScreenHeader } from '@/components/screen-header'; +import { RefreshControl } from '@/components/ui/refresh-control'; +import { BellOff } from '@/components/ui/icons'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { useAuth } from '@/lib/auth/auth-context'; +import { setPendingDeepLink } from '@/lib/deep-link-launch'; +import { pendingDeepLinkOptionsForData } from '@/lib/notification-actions'; +import { + NOTIFICATION_HISTORY_FILTERS, + notificationHistoryCopy, + type NotificationHistoryFilter, + notificationHistoryFilters, + notificationHistoryTarget, +} from '@/lib/notification-history'; +import { useNotificationMasterGate } from '@/lib/hooks/use-notification-master-gate'; +import { useNowTicker } from '@/lib/hooks/use-now-ticker'; +import { useTabBarBottomPadding } from '@/components/tab-screen'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { hasRetentionBudget, withInfiniteRetention } from '@/lib/query/infinite-retention'; +import { useTRPC } from '@/lib/trpc'; +import { parseTimestamp, timeAgo } from '@/lib/utils'; + +/** Rows requested per page; the server defaults to the same 30. */ +const PAGE_SIZE = 30; +/** How often a row's relative time is re-read while the screen stays open. */ +const RELATIVE_TIME_TICK_MS = 60_000; + +const SETTINGS_ROUTE = '/(app)/notifications' as Href; + +const listStyle = { flex: 1 } satisfies ViewStyle; +const listContentContainerStyle = { paddingHorizontal: 16 } satisfies ViewStyle; + +type HistoryPage = inferRouterOutputs['user']['getNotificationHistory']; +type HistoryRow = HistoryPage['items'][number]; + +type HistoryRowProps = Readonly<{ + row: HistoryRow; + now: number; + onOpen: (payload: Record) => void; +}>; + +function HistoryRowView({ row, now, onOpen }: HistoryRowProps) { + const { i18n } = useTranslation(); + const copy = notificationHistoryCopy(row, i18n.language); + return ( + { + onOpen(row.payload); + }} + > + + + {copy.title} + + + {timeAgo(parseTimestamp(row.createdAt), undefined, now)} + + + + {copy.body} + + + ); +} + +/** One filter chip. Single-select, so the active one carries `selected`. */ +function FilterChip({ + label, + active, + onPress, +}: Readonly<{ label: string; active: boolean; onPress: () => void }>) { + return ( + + ); +} + +/** + * The pushes this account received, newest first: one row per recorded + * notification, rendered on the device from the catalog key and params the push + * carried, filtered by the same categories the settings screen toggles, and + * opening the destination a push tap opens. + * + * The list is independent of the push gate: a user whose pushes are off sees + * what arrived before they were, with a banner that leads to the switch. + */ +export function NotificationHistoryScreen() { + const trpc = useTRPC(); + const router = useRouter(); + const colors = useThemeColors(); + const { t } = useTranslation(); + const { token: authToken } = useAuth(); + const gate = useNotificationMasterGate(); + const now = useNowTicker(RELATIVE_TIME_TICK_MS); + const paddingBottom = useTabBarBottomPadding(); + const [category, setCategory] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + const preferences = useQuery({ + ...trpc.user.getNotificationPreferences.queryOptions(), + enabled: authToken != null, + }); + + const query = useInfiniteQuery( + withInfiniteRetention( + trpc.user.getNotificationHistory.infiniteQueryOptions( + { category: category ?? undefined, limit: PAGE_SIZE }, + { + enabled: authToken != null, + // A newest-first list reaches its retention bound by refusing the + // next page: `maxPages` alone would drop index 0, which is the newest + // page. + getNextPageParam: (lastPage, pages) => + hasRetentionBudget(pages.length) ? (lastPage.nextCursor ?? undefined) : undefined, + } + ) + ) + ); + + const items = useMemo( + () => query.data?.pages.flatMap(page => page.items) ?? [], + [query.data?.pages] + ); + const hasListContent = items.length > 0 || query.isFetchNextPageError; + // A query that is disabled never leaves `pending`; only a signed-in read can. + const loading = authToken != null && query.isPending && !query.isError; + const failure = !loading && query.isError && !hasListContent; + + const openRow = useCallback( + (payload: Record) => { + const target = notificationHistoryTarget(payload); + if (target === null) { + toast.error(t('notifications.history.openFailed')); + return; + } + // The same slot a notification response fills, so the root layout owns + // the organization switch and the navigation. + setPendingDeepLink(target.href, 'notification', pendingDeepLinkOptionsForData(target.data)); + }, + [t] + ); + + const renderItem = useCallback( + ({ item }: { item: HistoryRow }) => , + [now, openRow] + ); + + const handleRefresh = () => { + void (async () => { + setRefreshing(true); + try { + const result = await query.refetch(); + if (result.isError) { + toast.error(t('common.couldNotRefresh')); + } + } finally { + setRefreshing(false); + } + })(); + }; + const refreshControl = ; + + const filters = preferences.data + ? notificationHistoryFilters(preferences.data.capabilities) + : NOTIFICATION_HISTORY_FILTERS; + + let footer = ; + if (query.isFetchNextPageError) { + footer = ( + + + + ); + } else if (query.isFetchingNextPage) { + footer = ; + } + + return ( + + + + {gate.settled && !gate.pushEnabled ? ( + + + + {t('notifications.history.bannerBody')} + + + + ) : null} + + {preferences.isPending && authToken != null ? ( + + + + + + ) : ( + + {filters.map(filter => ( + { + setCategory(filter.category); + }} + /> + ))} + + )} + + + {loading ? ( + + + + + + ) : null} + + {failure ? ( + { + void query.refetch(); + }} + isRetrying={query.isFetching} + refreshControl={refreshControl} + /> + ) : null} + + {!loading && !failure && !hasListContent ? ( + + ) : null} + + {!loading && !failure && hasListContent ? ( + + style={listStyle} + data={items} + keyExtractor={item => item.id} + renderItem={renderItem} + contentContainerStyle={listContentContainerStyle} + refreshControl={refreshControl} + onEndReached={() => { + // Once the next page has failed, auto-loading re-fires on every + // content-size change, so recovery waits for the footer's tap. + if ( + query.hasNextPage && + !query.isFetchingNextPage && + !query.isFetchNextPageError + ) { + void query.fetchNextPage(); + } + }} + onEndReachedThreshold={0.5} + ListFooterComponent={footer} + /> + ) : null} + + + + ); +} diff --git a/apps/mobile/src/components/preferences-screen.mounted.test.tsx b/apps/mobile/src/components/preferences-screen.mounted.test.tsx index 1e384568e0f..7f23e0fc381 100644 --- a/apps/mobile/src/components/preferences-screen.mounted.test.tsx +++ b/apps/mobile/src/components/preferences-screen.mounted.test.tsx @@ -18,6 +18,7 @@ vi.mock('expo-router', () => ({ vi.mock('@/components/ui/icons', () => ({ Bell: 'Bell', Globe: 'Globe', + History: 'History', Mic: 'Mic', SlidersHorizontal: 'SlidersHorizontal', Wallet: 'Wallet', @@ -68,7 +69,7 @@ describe('PreferencesScreen hub', () => { it('renders one navigation row per settings group with its title and subtitle', async () => { const renderer = await mountPreferences(); - expect(hubRows(renderer)).toHaveLength(6); + expect(hubRows(renderer)).toHaveLength(7); expect(row(renderer, 'General').props).toMatchObject({ icon: 'SlidersHorizontal', last: true }); expect(row(renderer, 'Voice input').props).toMatchObject({ icon: 'Mic', @@ -92,6 +93,11 @@ describe('PreferencesScreen hub', () => { subtitle: 'Push preferences', last: true, }); + expect(row(renderer, i18n.t('notifications.history.title')).props).toMatchObject({ + icon: 'History', + subtitle: 'Notifications this account received', + last: true, + }); expect(row(renderer, i18n.t('notifications.channel.spend')).props).toMatchObject({ icon: 'Wallet', last: true, @@ -104,6 +110,10 @@ describe('PreferencesScreen hub', () => { ['Translate tool summaries', '/(app)/(tabs)/(3_profile)/tool-summary-translation'], ['Account', '/(app)/(tabs)/(3_profile)/account'], ['Notifications', '/(app)/notifications'], + [ + i18n.t('notifications.history.title'), + '/(app)/(tabs)/(3_profile)/notification-history', + ], [i18n.t('notifications.channel.spend'), '/(app)/(tabs)/(3_profile)/spend-alerts'], ])('pushes the %s subpage from its row', async (title, route) => { const renderer = await mountPreferences(); diff --git a/apps/mobile/src/components/preferences-screen.tsx b/apps/mobile/src/components/preferences-screen.tsx index 5a3dc5196f5..ce8316fde31 100644 --- a/apps/mobile/src/components/preferences-screen.tsx +++ b/apps/mobile/src/components/preferences-screen.tsx @@ -1,5 +1,13 @@ import { type Href, useRouter } from 'expo-router'; -import { Bell, Globe, Mic, SlidersHorizontal, Wallet, WandSparkles } from '@/components/ui/icons'; +import { + Bell, + Globe, + History, + Mic, + SlidersHorizontal, + Wallet, + WandSparkles, +} from '@/components/ui/icons'; import { View } from 'react-native'; import { useTranslation } from 'react-i18next'; @@ -99,6 +107,16 @@ export function PreferencesScreen() { router.push('/(app)/notifications' as Href); }} /> + { + router.push('/(app)/(tabs)/(3_profile)/notification-history' as Href); + }} + /> ; + +/** + * Whether pushes can reach this device: the OS permission plus a token the + * account holds. The settings screen's master switch is the same pair, so a + * screen that says "push is off" and the switch that turns it back on cannot + * disagree. + * + * A permission read that fails counts as not granted, which is what + * `deriveGateSettled` short-circuits on: the token queries are irrelevant to a + * closed gate, and the device-token query is disabled while it is closed. + */ +export function useNotificationMasterGate(): NotificationMasterGate { + const trpc = useTRPC(); + const { token: authToken } = useAuth(); + + const permission = useQuery({ + queryKey: permissionQueryKey, + queryFn: async () => (await getNotificationPermissionStatus()) === 'granted', + }); + const permissionGranted = permission.data === true; + + const deviceToken = useQuery({ + queryKey: deviceTokenQueryKey, + queryFn: getDevicePushToken, + enabled: permissionGranted, + }); + const pushTokens = useQuery({ + ...trpc.user.getMyPushTokens.queryOptions(), + enabled: authToken != null, + }); + + const settled = deriveGateSettled({ + permissionSettled: permission.isFetched || permission.isError, + permissionGranted, + pushTokensSettled: pushTokens.isFetched || pushTokens.isError, + deviceTokenSettled: deviceToken.isFetched || deviceToken.isError, + }); + const registeredToken = deviceToken.data ?? null; + const serverRegistered = + registeredToken !== null && + (pushTokens.data ?? []).some(entry => entry.token === registeredToken); + + return { + loading: !settled, + settled, + permissionGranted, + pushEnabled: permissionGranted && serverRegistered, + }; +} diff --git a/apps/mobile/src/lib/notification-history.test.ts b/apps/mobile/src/lib/notification-history.test.ts new file mode 100644 index 00000000000..8f992de4f27 --- /dev/null +++ b/apps/mobile/src/lib/notification-history.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; + +import { + NOTIFICATION_HISTORY_FILTERS, + notificationHistoryCopy, + notificationHistoryFilters, + notificationHistoryTarget, +} from '@/lib/notification-history'; + +const LOW_BALANCE_PARAMS = { organizationName: 'Acme', minimumBalanceUsd: '5' }; + +describe('notificationHistoryCopy', () => { + it('renders the stored catalog key and its params in the requested language', () => { + const copy = notificationHistoryCopy( + { + i18nKey: 'internal.lowBalance', + i18nParams: LOW_BALANCE_PARAMS, + title: 'stored title', + body: 'stored body', + }, + 'de' + ); + + expect(copy.title).toBe('Warnung bei niedrigem Guthaben'); + expect(copy.body).toBe('Das Guthaben von Acme fiel unter $5'); + }); + + it('renders English copy for a locale the push catalog does not carry', () => { + const copy = notificationHistoryCopy( + { i18nKey: 'internal.lowBalance', i18nParams: LOW_BALANCE_PARAMS, title: '', body: '' }, + 'not-a-locale' + ); + + expect(copy).toEqual({ + title: 'Low balance alert', + body: 'Acme balance fell below $5', + }); + }); + + it('falls back to the stored copy when the catalog key no longer exists', () => { + const copy = notificationHistoryCopy( + { + i18nKey: 'internal.retiredKind', + i18nParams: { anything: 'x' }, + title: 'Stored title', + body: 'Stored body', + }, + 'de' + ); + + expect(copy).toEqual({ title: 'Stored title', body: 'Stored body' }); + }); + + it('uses the stored copy for a push that carried no catalog key', () => { + const copy = notificationHistoryCopy( + { i18nKey: null, i18nParams: null, title: 'Raw title', body: 'Raw body' }, + 'de' + ); + + expect(copy).toEqual({ title: 'Raw title', body: 'Raw body' }); + }); +}); + +describe('notificationHistoryTarget', () => { + it.each([ + [ + { type: 'cloud_agent_session', cliSessionId: 'ses_1', organizationId: 'org-2' }, + '/(app)/agent-chat/ses_1', + ], + [ + { type: 'agent_briefing', url: '/agent-briefing' }, + '/(app)/agent-briefing', + ], + ])('opens the push destination of a %o row without the push marker', (payload, href) => { + expect(notificationHistoryTarget(payload)).toMatchObject({ href }); + }); + + it('keeps the other query params and drops only the push marker', () => { + expect( + notificationHistoryTarget({ type: 'low_balance', organizationId: 'org-acme' }) + ).toMatchObject({ + href: '/(app)/(tabs)/(3_profile)/organization/credit-activity?org=org-acme', + }); + expect( + notificationHistoryTarget({ + type: 'spend_alert', + scope: 'organization', + organizationId: 'org-acme', + }) + ).toMatchObject({ + href: '/(app)/(tabs)/(3_profile)/spend-alerts?org=org-acme', + }); + }); + + it('routes a security finding row to its scope', () => { + expect( + notificationHistoryTarget({ + type: 'security_finding', + findingId: 'f-1', + scope: 'org-9', + }) + ).toMatchObject({ href: '/(app)/security-agent/org-9/findings/f-1' }); + }); + + it('carries the payload the row was recorded from', () => { + const target = notificationHistoryTarget({ + type: 'cloud_agent_session', + cliSessionId: 'ses_1', + organizationId: 'org-2', + }); + + expect(target?.data).toEqual({ + type: 'cloud_agent_session', + cliSessionId: 'ses_1', + organizationId: 'org-2', + }); + }); + + it('reports no destination for a payload this app cannot parse', () => { + expect(notificationHistoryTarget({ type: 'retired_kind' })).toBeNull(); + expect(notificationHistoryTarget({})).toBeNull(); + }); +}); + +describe('notificationHistoryFilters', () => { + it('offers every recorded category behind an "All" chip', () => { + expect(NOTIFICATION_HISTORY_FILTERS.map(filter => filter.category)).toEqual([ + null, + 'chatMessages', + 'agentAttention', + 'sessionStatus', + 'balanceAlerts', + 'spendAlerts', + 'securityFindings', + ]); + }); + + it('drops a category the account cannot receive', () => { + const filters = notificationHistoryFilters({ + balanceAlerts: { available: false }, + securityFindings: { available: false }, + }); + + expect(filters.map(filter => filter.category)).toEqual([ + null, + 'chatMessages', + 'agentAttention', + 'sessionStatus', + 'spendAlerts', + ]); + }); + + it('keeps every chip while availability is unknown or unread', () => { + expect(notificationHistoryFilters(undefined)).toEqual(NOTIFICATION_HISTORY_FILTERS); + expect(notificationHistoryFilters({})).toEqual(NOTIFICATION_HISTORY_FILTERS); + }); +}); diff --git a/apps/mobile/src/lib/notification-history.ts b/apps/mobile/src/lib/notification-history.ts new file mode 100644 index 00000000000..89f7c23012a --- /dev/null +++ b/apps/mobile/src/lib/notification-history.ts @@ -0,0 +1,143 @@ +import { + NOTIFICATION_HISTORY_CATEGORIES, + type NotificationHistoryCategory, + type PushData, + pushDataSchema, + translatePush, +} from '@kilocode/notifications'; + +import { notificationPathForData } from './notification-path'; + +/** + * Pure logic for the Notification history screen: how a recorded row is read + * back, where a row's tap goes, and which filter chips the list offers. + * + * The row is rendered on the device from the catalog key and params the push + * carried, in the app's current language, so a row reads like the notification + * did. The worker's own renderer does the same with its per-token locale + * (`NotificationChannelDO.dispatchPush`); the stored fallback copy is what a + * removed key falls back to. + */ + +/** The stored fields a row's copy is rendered from. */ +export type NotificationHistoryCopyFields = Readonly<{ + /** Catalog key without a `.title`/`.body` suffix, or null for a raw-form push. */ + i18nKey: string | null; + i18nParams: Record | null; + /** The copy the sender stored, used verbatim when no key resolves. */ + title: string; + body: string; +}>; + +/** The rendered title and body of one row. */ +export type NotificationHistoryCopy = Readonly<{ title: string; body: string }>; + +/** + * The row's title and body in `locale`. A row with no catalog key, or whose key + * is no longer in the push catalog, keeps the stored fallback copy — never the + * content-free preview copy, which is a per-device send-time setting and is + * never what was recorded. + */ +export function notificationHistoryCopy( + row: NotificationHistoryCopyFields, + locale: string +): NotificationHistoryCopy { + if (row.i18nKey === null) { + return { title: row.title, body: row.body }; + } + const params = row.i18nParams ?? undefined; + return { + title: translatePush(locale, `${row.i18nKey}.title`, params, row.title), + body: translatePush(locale, `${row.i18nKey}.body`, params, row.body), + }; +} + +export type NotificationHistoryTarget = Readonly<{ + data: PushData; + href: string; +}>; + +/** + * Drop the `via=push` marker from a destination. The marker tags the landing + * screen's analytics event as push-originated, and opening a row from the list + * is not a push tap. + */ +function withoutPushOrigin(href: string): string { + const queryStart = href.indexOf('?'); + if (queryStart === -1) { + return href; + } + const kept = href + .slice(queryStart + 1) + .split('&') + .filter(part => part !== 'via=push'); + if (kept.length === 0) { + return href.slice(0, queryStart); + } + return `${href.slice(0, queryStart)}?${kept.join('&')}`; +} + +/** + * Where a row's tap goes, or null when the stored payload no longer resolves to + * a destination (a producer shape this app cannot parse). A null target is the + * one dead destination the list can detect without a request: every recorded + * kind maps to a route. + */ +export function notificationHistoryTarget( + payload: Record +): NotificationHistoryTarget | null { + const parsed = pushDataSchema.safeParse(payload); + if (!parsed.success) { + return null; + } + return { data: parsed.data, href: withoutPushOrigin(notificationPathForData(parsed.data)) }; +} + +export type NotificationHistoryFilter = Readonly<{ + /** null is the unfiltered "All" chip. */ + category: NotificationHistoryCategory | null; + labelKey: string; +}>; + +/** + * Chip label per recorded category. The settings screen's own wording, so a + * chip and the switch that governs it read the same. Keys are literal so the + * catalog check can see them. + */ +const CATEGORY_LABEL_KEYS = { + chatMessages: 'notifications.channel.chat', + agentAttention: 'notifications.category.agentAttentionTitle', + sessionStatus: 'notifications.category.sessionStatusTitle', + balanceAlerts: 'notifications.channel.balance', + spendAlerts: 'notifications.channel.spend', + securityFindings: 'notifications.channel.security', +} satisfies Record; + +/** + * Every chip in the order the settings screen lists the categories: "All" + * first, then one per category the recorder can produce. A category the + * recorder never writes (there is no recorded push kind for it) has no chip, + * so no chip can only ever answer an empty page. + */ +export const NOTIFICATION_HISTORY_FILTERS: readonly NotificationHistoryFilter[] = [ + { category: null, labelKey: 'common.all' }, + ...NOTIFICATION_HISTORY_CATEGORIES.map(category => ({ + category, + labelKey: CATEGORY_LABEL_KEYS[category], + })), +]; + +/** + * The chips an account can use: a category the server marks unavailable (no + * organization, no security agent) is dropped rather than offered as a control + * that can only return an empty page. Availability the client has not read yet + * — or could not read — keeps the chip, so a failed preferences query never + * hides a filter the account does have. + */ +export function notificationHistoryFilters( + capabilities: Readonly> | undefined +): readonly NotificationHistoryFilter[] { + return NOTIFICATION_HISTORY_FILTERS.filter( + filter => filter.category === null || capabilities?.[filter.category]?.available !== false + ); +} diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 8ebdefb6380..31b882c3d81 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -20,6 +20,7 @@ "@kilocode/kilo-chat": "workspace:*", "@kilocode/kiloclaw-instance-tiers": "workspace:*", "@kilocode/kiloclaw-secret-catalog": "workspace:*", + "@kilocode/notifications": "workspace:*", "@kilocode/worker-utils": "workspace:*", "@trpc/server": "catalog:", "@workos-inc/node": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad870d29f74..ff6e930b1c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1910,6 +1910,9 @@ importers: '@kilocode/kiloclaw-secret-catalog': specifier: workspace:* version: link:../kiloclaw-secret-catalog + '@kilocode/notifications': + specifier: workspace:* + version: link:../notifications '@kilocode/worker-utils': specifier: workspace:* version: link:../worker-utils From 015b3f842be392779c92f65a251bf1d8dbf50087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:37:29 +0000 Subject: [PATCH 2/7] chore(mobile): format the history screen and cover it in the settings a11y gallery The Preferences hub gained a row, so the gallery that mounts that screen needs the row's icon in its icon mock. --- .../notification-history-screen.mounted.test.tsx | 8 ++------ .../mobile/src/components/notification-history-screen.tsx | 6 +----- .../src/components/preferences-screen.mounted.test.tsx | 5 +---- .../src/components/settings-screens-a11y.mounted.test.tsx | 1 + apps/mobile/src/lib/notification-history.test.ts | 5 +---- 5 files changed, 6 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/components/notification-history-screen.mounted.test.tsx b/apps/mobile/src/components/notification-history-screen.mounted.test.tsx index 4df949231b0..14d50338446 100644 --- a/apps/mobile/src/components/notification-history-screen.mounted.test.tsx +++ b/apps/mobile/src/components/notification-history-screen.mounted.test.tsx @@ -213,10 +213,7 @@ describe('NotificationHistoryScreen', () => { }); it('filters the list by the tapped chip', async () => { - state.rows = [ - row(), - row({ id: 'row-2', category: 'chatMessages', pushType: 'chat.message' }), - ]; + state.rows = [row(), row({ id: 'row-2', category: 'chatMessages', pushType: 'chat.message' })]; await mount(); expect(byType('Cell')).toHaveLength(2); @@ -260,8 +257,7 @@ describe('NotificationHistoryScreen', () => { const empty = byType('EmptyState')[0]; expect(empty?.props).toMatchObject({ title: 'No notifications yet', - description: - 'Notifications this account receives appear here. They are kept for 90 days.', + description: 'Notifications this account receives appear here. They are kept for 90 days.', }); }); diff --git a/apps/mobile/src/components/notification-history-screen.tsx b/apps/mobile/src/components/notification-history-screen.tsx index 12c6798ed17..2b7fbb870b2 100644 --- a/apps/mobile/src/components/notification-history-screen.tsx +++ b/apps/mobile/src/components/notification-history-screen.tsx @@ -297,11 +297,7 @@ export function NotificationHistoryScreen() { onEndReached={() => { // Once the next page has failed, auto-loading re-fires on every // content-size change, so recovery waits for the footer's tap. - if ( - query.hasNextPage && - !query.isFetchingNextPage && - !query.isFetchNextPageError - ) { + if (query.hasNextPage && !query.isFetchingNextPage && !query.isFetchNextPageError) { void query.fetchNextPage(); } }} diff --git a/apps/mobile/src/components/preferences-screen.mounted.test.tsx b/apps/mobile/src/components/preferences-screen.mounted.test.tsx index 7f23e0fc381..87cf66a1ccd 100644 --- a/apps/mobile/src/components/preferences-screen.mounted.test.tsx +++ b/apps/mobile/src/components/preferences-screen.mounted.test.tsx @@ -110,10 +110,7 @@ describe('PreferencesScreen hub', () => { ['Translate tool summaries', '/(app)/(tabs)/(3_profile)/tool-summary-translation'], ['Account', '/(app)/(tabs)/(3_profile)/account'], ['Notifications', '/(app)/notifications'], - [ - i18n.t('notifications.history.title'), - '/(app)/(tabs)/(3_profile)/notification-history', - ], + [i18n.t('notifications.history.title'), '/(app)/(tabs)/(3_profile)/notification-history'], [i18n.t('notifications.channel.spend'), '/(app)/(tabs)/(3_profile)/spend-alerts'], ])('pushes the %s subpage from its row', async (title, route) => { const renderer = await mountPreferences(); diff --git a/apps/mobile/src/components/settings-screens-a11y.mounted.test.tsx b/apps/mobile/src/components/settings-screens-a11y.mounted.test.tsx index 74ad2735dcc..903ec8d4c64 100644 --- a/apps/mobile/src/components/settings-screens-a11y.mounted.test.tsx +++ b/apps/mobile/src/components/settings-screens-a11y.mounted.test.tsx @@ -70,6 +70,7 @@ vi.mock('react-native-safe-area-context', () => ({ vi.mock('@/components/ui/icons', () => ({ Bell: 'Bell', Globe: 'Globe', + History: 'History', KeyRound: 'KeyRound', Mic: 'Mic', Shield: 'Shield', diff --git a/apps/mobile/src/lib/notification-history.test.ts b/apps/mobile/src/lib/notification-history.test.ts index 8f992de4f27..b8955ffada5 100644 --- a/apps/mobile/src/lib/notification-history.test.ts +++ b/apps/mobile/src/lib/notification-history.test.ts @@ -67,10 +67,7 @@ describe('notificationHistoryTarget', () => { { type: 'cloud_agent_session', cliSessionId: 'ses_1', organizationId: 'org-2' }, '/(app)/agent-chat/ses_1', ], - [ - { type: 'agent_briefing', url: '/agent-briefing' }, - '/(app)/agent-briefing', - ], + [{ type: 'agent_briefing', url: '/agent-briefing' }, '/(app)/agent-briefing'], ])('opens the push destination of a %o row without the push marker', (payload, href) => { expect(notificationHistoryTarget(payload)).toMatchObject({ href }); }); From ac8b67b2b30ad50dc5b8ac82e038518825eb12fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:51:17 +0000 Subject: [PATCH 3/7] fix(notifications): drop a stale-window parameter nothing reads The extension typecheck reports the second parameter of `sessionCardStaleAtSeconds` as unused, because the stale moment is derived from the card's own last activity and the caller's default window. A change to the tRPC package's dependencies now marks the extension affected, so that pre-existing error blocks the typecheck group; remove the parameter and its argument. Behaviour is identical: the quiet window still counts from the last activity the server saw. --- packages/notifications/src/session-live-activity.ts | 1 - services/notifications/src/lib/session-card-delivery.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/notifications/src/session-live-activity.ts b/packages/notifications/src/session-live-activity.ts index b668b8648c5..7dc40bcda6a 100644 --- a/packages/notifications/src/session-live-activity.ts +++ b/packages/notifications/src/session-live-activity.ts @@ -149,7 +149,6 @@ export function buildSessionLiveActivityContentState( */ export function sessionCardStaleAtSeconds( content: Pick, - nowSeconds: number, defaultStaleAtSeconds: number ): number { if (content.statusKind !== 'running') return defaultStaleAtSeconds; diff --git a/services/notifications/src/lib/session-card-delivery.ts b/services/notifications/src/lib/session-card-delivery.ts index 022ad19c09a..9242134ef9b 100644 --- a/services/notifications/src/lib/session-card-delivery.ts +++ b/services/notifications/src/lib/session-card-delivery.ts @@ -278,7 +278,6 @@ export function planSessionCardSend(params: { // The quiet window for a working card, the aggregate window otherwise. staleAtSeconds: sessionCardStaleAtSeconds( content, - nowSeconds, nowSeconds + SESSION_CARD_AGGREGATE_STALE_MS / 1000 ), }, From e258d8e13cf34655a2a3ee19014e1d4306a7d452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:46:00 +0000 Subject: [PATCH 4/7] fix(mobile): keep history rows correct across refresh, paging and an empty filter Review follow-ups on the notification history screen: - A refresh now collapses the retained pages and reloads from the newest row. Each retained page is a keyset window that only stays contiguous with the page above it while page one's newest row is unchanged; re-requesting the old windows would skip every row between the old and the new page-one tail. The rows the refresh started from stay on screen until page one delivers. - The page bound says so in the footer instead of ending the list silently, and rises to 10 pages. - A first load while offline settles into the error state and its retry instead of pausing in `pending` and holding the skeleton. - The chip carries `role="tab"`, which `Button` keeps (`role` outranks `accessibilityRole`). - The next-page retry footer keeps the tab-bar clearance. - An empty category says so, and the retention window stays on the unfiltered empty state it describes. Two new catalog keys, translated into every catalog. --- .../components/notification-history-row.tsx | 45 ++++++ ...tification-history-screen.mounted.test.tsx | 136 ++++++++++++++++-- .../notification-history-screen.tsx | 132 +++++++++-------- apps/mobile/src/i18n/locales/af.json | 2 + apps/mobile/src/i18n/locales/am.json | 2 + apps/mobile/src/i18n/locales/ar.json | 2 + apps/mobile/src/i18n/locales/az.json | 2 + apps/mobile/src/i18n/locales/be.json | 2 + apps/mobile/src/i18n/locales/bg.json | 2 + apps/mobile/src/i18n/locales/bn.json | 2 + apps/mobile/src/i18n/locales/bs.json | 2 + apps/mobile/src/i18n/locales/ca.json | 2 + apps/mobile/src/i18n/locales/ckb.json | 2 + apps/mobile/src/i18n/locales/cs.json | 2 + apps/mobile/src/i18n/locales/cy.json | 2 + apps/mobile/src/i18n/locales/da.json | 2 + apps/mobile/src/i18n/locales/de.json | 2 + apps/mobile/src/i18n/locales/el.json | 2 + apps/mobile/src/i18n/locales/en.json | 2 + apps/mobile/src/i18n/locales/es.json | 2 + apps/mobile/src/i18n/locales/et.json | 2 + apps/mobile/src/i18n/locales/eu.json | 2 + apps/mobile/src/i18n/locales/fa.json | 2 + apps/mobile/src/i18n/locales/fi.json | 2 + apps/mobile/src/i18n/locales/fil.json | 2 + apps/mobile/src/i18n/locales/fr.json | 2 + apps/mobile/src/i18n/locales/ga.json | 2 + apps/mobile/src/i18n/locales/gl.json | 2 + apps/mobile/src/i18n/locales/gu.json | 2 + apps/mobile/src/i18n/locales/ha.json | 2 + apps/mobile/src/i18n/locales/he.json | 2 + apps/mobile/src/i18n/locales/hi.json | 2 + apps/mobile/src/i18n/locales/hr.json | 2 + apps/mobile/src/i18n/locales/ht.json | 2 + apps/mobile/src/i18n/locales/hu.json | 2 + apps/mobile/src/i18n/locales/hy.json | 2 + apps/mobile/src/i18n/locales/id.json | 2 + apps/mobile/src/i18n/locales/ig.json | 2 + apps/mobile/src/i18n/locales/is.json | 2 + apps/mobile/src/i18n/locales/it.json | 2 + apps/mobile/src/i18n/locales/ja.json | 2 + apps/mobile/src/i18n/locales/ka.json | 2 + apps/mobile/src/i18n/locales/kk.json | 2 + apps/mobile/src/i18n/locales/km.json | 2 + apps/mobile/src/i18n/locales/kn.json | 2 + apps/mobile/src/i18n/locales/ko.json | 2 + apps/mobile/src/i18n/locales/lo.json | 2 + apps/mobile/src/i18n/locales/lt.json | 2 + apps/mobile/src/i18n/locales/lv.json | 2 + apps/mobile/src/i18n/locales/mg.json | 2 + apps/mobile/src/i18n/locales/mi.json | 2 + apps/mobile/src/i18n/locales/mk.json | 2 + apps/mobile/src/i18n/locales/ml.json | 2 + apps/mobile/src/i18n/locales/mn.json | 2 + apps/mobile/src/i18n/locales/mr.json | 2 + apps/mobile/src/i18n/locales/ms.json | 2 + apps/mobile/src/i18n/locales/mt.json | 2 + apps/mobile/src/i18n/locales/my.json | 2 + apps/mobile/src/i18n/locales/nb.json | 2 + apps/mobile/src/i18n/locales/ne.json | 2 + apps/mobile/src/i18n/locales/nl.json | 2 + apps/mobile/src/i18n/locales/om.json | 2 + apps/mobile/src/i18n/locales/or.json | 2 + apps/mobile/src/i18n/locales/pa.json | 2 + apps/mobile/src/i18n/locales/pl.json | 2 + apps/mobile/src/i18n/locales/ps.json | 2 + apps/mobile/src/i18n/locales/pt-BR.json | 2 + apps/mobile/src/i18n/locales/pt.json | 2 + apps/mobile/src/i18n/locales/ro.json | 2 + apps/mobile/src/i18n/locales/ru.json | 2 + apps/mobile/src/i18n/locales/si.json | 2 + apps/mobile/src/i18n/locales/sk.json | 2 + apps/mobile/src/i18n/locales/sl.json | 2 + apps/mobile/src/i18n/locales/so.json | 2 + apps/mobile/src/i18n/locales/sq.json | 2 + apps/mobile/src/i18n/locales/sr.json | 2 + apps/mobile/src/i18n/locales/sv.json | 2 + apps/mobile/src/i18n/locales/sw.json | 2 + apps/mobile/src/i18n/locales/ta.json | 2 + apps/mobile/src/i18n/locales/te.json | 2 + apps/mobile/src/i18n/locales/th.json | 2 + apps/mobile/src/i18n/locales/tr.json | 2 + apps/mobile/src/i18n/locales/uk.json | 2 + apps/mobile/src/i18n/locales/ur.json | 2 + apps/mobile/src/i18n/locales/uz.json | 2 + apps/mobile/src/i18n/locales/vi.json | 2 + apps/mobile/src/i18n/locales/yo.json | 2 + apps/mobile/src/i18n/locales/zh-Hans.json | 2 + apps/mobile/src/i18n/locales/zh-Hant.json | 2 + apps/mobile/src/i18n/locales/zu.json | 2 + 90 files changed, 422 insertions(+), 65 deletions(-) create mode 100644 apps/mobile/src/components/notification-history-row.tsx diff --git a/apps/mobile/src/components/notification-history-row.tsx b/apps/mobile/src/components/notification-history-row.tsx new file mode 100644 index 00000000000..b51d68de76b --- /dev/null +++ b/apps/mobile/src/components/notification-history-row.tsx @@ -0,0 +1,45 @@ +import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; +import { useTranslation } from 'react-i18next'; +import { Pressable, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { notificationHistoryCopy } from '@/lib/notification-history'; +import { parseTimestamp, timeAgo } from '@/lib/utils'; + +type HistoryPage = inferRouterOutputs['user']['getNotificationHistory']; +export type HistoryRow = HistoryPage['items'][number]; + +/** One recorded notification: the copy a push showed, and how long ago. */ +export function HistoryRowView({ + row, + now, + onOpen, +}: Readonly<{ + row: HistoryRow; + now: number; + onOpen: (payload: Record) => void; +}>) { + const { i18n } = useTranslation(); + const copy = notificationHistoryCopy(row, i18n.language); + return ( + { + onOpen(row.payload); + }} + > + + + {copy.title} + + + {timeAgo(parseTimestamp(row.createdAt), undefined, now)} + + + + {copy.body} + + + ); +} diff --git a/apps/mobile/src/components/notification-history-screen.mounted.test.tsx b/apps/mobile/src/components/notification-history-screen.mounted.test.tsx index 14d50338446..33717bf875b 100644 --- a/apps/mobile/src/components/notification-history-screen.mounted.test.tsx +++ b/apps/mobile/src/components/notification-history-screen.mounted.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- one mounted harness: the paged list, its states, the chip filter, the row tap and the push banner share one fake tRPC surface */ import { createElement, type ReactElement } from 'react'; import { act, type ReactTestInstance } from '@/test/renderer'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -42,6 +43,14 @@ const state = vi.hoisted(() => { gate, /** The inputs `infiniteQueryOptions` was built with, newest last. */ historyInputs: [] as { category?: string }[], + /** The option objects the screen built, newest last. */ + historyOptions: [] as Record[], + /** Rows on page one while a fixture wants a second page. */ + pageSize: undefined as number | undefined, + /** A failure for a non-first page only. */ + pageError: undefined as Error | undefined, + /** The rendered list's paging hook, so a test can drive a next page. */ + listProps: undefined as { onEndReached?: () => void; onRefresh?: () => void } | undefined, }; }); @@ -55,15 +64,24 @@ vi.mock('@shopify/flash-list', () => ({ FlashList: ({ data, renderItem, + onEndReached, + refreshControl, + ListFooterComponent, }: { data: Row[]; renderItem: (info: { item: Row }) => ReactElement; - }) => - createElement( + onEndReached?: () => void; + refreshControl?: { props: { onRefresh?: () => void } }; + ListFooterComponent?: ReactElement; + }) => { + state.listProps = { onEndReached, onRefresh: refreshControl?.props.onRefresh }; + return createElement( 'FlashList', null, - data.map(item => createElement('Cell', { key: item.id }, renderItem({ item }))) - ), + data.map(item => createElement('Cell', { key: item.id }, renderItem({ item }))), + ListFooterComponent ?? null + ); + }, })); vi.mock('react-native', () => ({ @@ -82,7 +100,7 @@ vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); vi.mock('@/components/ui/refresh-control', () => ({ RefreshControl: 'RefreshControl' })); vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); -vi.mock('@/components/tab-screen', () => ({ useTabBarBottomPadding: () => 0 })); +vi.mock('@/components/tab-screen', () => ({ useTabBarBottomPadding: () => 24 })); vi.mock('@/lib/auth/auth-context', () => ({ useAuth: () => ({ token: 'jwt' }) })); vi.mock('@/lib/hooks/use-now-ticker', () => ({ useNowTicker: () => NOW })); vi.mock('@/lib/hooks/use-notification-master-gate', () => ({ @@ -101,19 +119,33 @@ vi.mock('@/lib/trpc', () => ({ useTRPC: () => ({ user: { getNotificationHistory: { - infiniteQueryOptions: (input: { category?: string }, options: object) => { + queryKey: () => ['user', 'getNotificationHistory'], + infiniteQueryOptions: (input: { category?: string }, options: Record) => { state.historyInputs.push(input); + state.historyOptions.push(options); return { queryKey: ['user', 'getNotificationHistory', input], initialPageParam: undefined, - queryFn: () => { + queryFn: ({ pageParam }: { pageParam: string | undefined }) => { + if (pageParam !== undefined && state.pageError) { + throw state.pageError; + } if (state.historyError) { throw state.historyError; } const items = state.rows.filter( entry => input.category === undefined || entry.category === input.category ); - return { items, nextCursor: null }; + if (state.pageSize === undefined) { + return { items, nextCursor: null }; + } + if (pageParam === undefined) { + return { + items: items.slice(0, state.pageSize), + nextCursor: items.length > state.pageSize ? 'page-2' : null, + }; + } + return { items: items.slice(state.pageSize), nextCursor: null }; }, ...options, }; @@ -170,6 +202,18 @@ function press(node: ReactTestInstance | undefined) { }); } +/** The screen's button props, read once so the chip assertions stay typed. */ +type ChipProps = { role?: string; accessibilityState?: { selected?: boolean } }; + +function chipProps(node: ReactTestInstance | undefined): ChipProps | undefined { + return node?.props as ChipProps | undefined; +} + +/** The footer's bottom padding, read once so the clearance assertion stays typed. */ +function footerPadding(node: ReactTestInstance): number | undefined { + return (node.props as { style?: { paddingBottom?: number } }).style?.paddingBottom; +} + /** The node whose `Text` child holds `value`. */ function withText(nodes: ReactTestInstance[], value: string) { return nodes.find( @@ -191,6 +235,10 @@ beforeEach(() => { state.preferences = { capabilities: {} }; state.gate = { loading: false, settled: true, permissionGranted: true, pushEnabled: true }; state.historyInputs = []; + state.historyOptions = []; + state.pageSize = undefined; + state.pageError = undefined; + state.listProps = undefined; vi.clearAllMocks(); }); @@ -290,4 +338,76 @@ describe('NotificationHistoryScreen', () => { const texts = byType('Text').map(node => node.props.children); expect(texts).not.toContain('Notification settings'); }); + + it('announces the chip row as a tab list with the selected chip marked', async () => { + state.rows = [row({ category: 'chatMessages', pushType: 'chat.message' })]; + await mount(); + + const chip = withText(byType('Button'), 'Chat messages'); + expect(chipProps(chip)).toMatchObject({ + role: 'tab', + accessibilityState: { selected: false }, + }); + + press(chip); + await waitFor( + () => + chipProps(withText(byType('Button'), 'Chat messages'))?.accessibilityState?.selected === + true + ); + }); + + it('says a category has nothing rather than that the account has nothing', async () => { + state.rows = [row({ category: 'balanceAlerts' })]; + await mount(); + press(withText(byType('Button'), 'Chat messages')); + await waitFor(() => byType('EmptyState').length === 1); + + const empty = byType('EmptyState')[0]; + expect(empty?.props).toMatchObject({ title: 'No notifications in this category' }); + // The retention window describes the whole list, not one empty category. + expect(empty?.props.description).toBeUndefined(); + }); + + it('collapses retained pages on a refresh instead of re-reading their cursors', async () => { + state.pageSize = 3; + state.rows = [1, 2, 3, 4, 5].map(index => + row({ id: `row-${index}`, title: `stored ${index}` }) + ); + await mount(); + expect(byType('Cell')).toHaveLength(3); + + act(() => { + state.listProps?.onEndReached?.(); + }); + await waitFor(() => byType('Cell').length === 5); + + act(() => { + state.listProps?.onRefresh?.(); + }); + await waitFor(() => byType('Cell').length === 3); + }); + + it('keeps the tab-bar clearance under the next-page retry', async () => { + state.pageSize = 1; + state.rows = [row({ id: 'row-1' }), row({ id: 'row-2' })]; + await mount(); + + state.pageError = new Error('paging failed'); + act(() => { + state.listProps?.onEndReached?.(); + }); + await waitFor(() => withText(byType('Button'), 'Retry loading more') !== undefined); + + // 24dp of tab bar plus the footer's own 32dp. + const footer = byType('View').find(node => footerPadding(node) === 56); + expect(footer).toBeDefined(); + }); + + it('turns off the network gate so an offline first load can fail into its retry', async () => { + state.rows = [row()]; + await mount(); + + expect(state.historyOptions.at(-1)).toMatchObject({ networkMode: 'always' }); + }); }); diff --git a/apps/mobile/src/components/notification-history-screen.tsx b/apps/mobile/src/components/notification-history-screen.tsx index 2b7fbb870b2..9eaade183f9 100644 --- a/apps/mobile/src/components/notification-history-screen.tsx +++ b/apps/mobile/src/components/notification-history-screen.tsx @@ -1,12 +1,11 @@ import { FlashList } from '@shopify/flash-list'; -import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; import { type Href, useRouter } from 'expo-router'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Pressable, ScrollView, View, type ViewStyle } from 'react-native'; +import { ScrollView, View, type ViewStyle } from 'react-native'; import { toast } from 'sonner-native'; -import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; import { NOTIFICATION_HISTORY_RETENTION_DAYS } from '@kilocode/notifications'; import { EmptyState } from '@/components/empty-state'; @@ -22,64 +21,33 @@ import { setPendingDeepLink } from '@/lib/deep-link-launch'; import { pendingDeepLinkOptionsForData } from '@/lib/notification-actions'; import { NOTIFICATION_HISTORY_FILTERS, - notificationHistoryCopy, type NotificationHistoryFilter, notificationHistoryFilters, notificationHistoryTarget, } from '@/lib/notification-history'; import { useNotificationMasterGate } from '@/lib/hooks/use-notification-master-gate'; +import { type HistoryRow, HistoryRowView } from '@/components/notification-history-row'; import { useNowTicker } from '@/lib/hooks/use-now-ticker'; import { useTabBarBottomPadding } from '@/components/tab-screen'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { hasRetentionBudget, withInfiniteRetention } from '@/lib/query/infinite-retention'; +import { + hasRetentionBudget, + reconcileFirstPage, + withInfiniteRetention, +} from '@/lib/query/infinite-retention'; import { useTRPC } from '@/lib/trpc'; -import { parseTimestamp, timeAgo } from '@/lib/utils'; /** Rows requested per page; the server defaults to the same 30. */ const PAGE_SIZE = 30; /** How often a row's relative time is re-read while the screen stays open. */ const RELATIVE_TIME_TICK_MS = 60_000; -const SETTINGS_ROUTE = '/(app)/notifications' as Href; +/** Retained pages; the bound refuses the next page rather than dropping the newest. */ +const MAX_PAGES = 10; const listStyle = { flex: 1 } satisfies ViewStyle; const listContentContainerStyle = { paddingHorizontal: 16 } satisfies ViewStyle; -type HistoryPage = inferRouterOutputs['user']['getNotificationHistory']; -type HistoryRow = HistoryPage['items'][number]; - -type HistoryRowProps = Readonly<{ - row: HistoryRow; - now: number; - onOpen: (payload: Record) => void; -}>; - -function HistoryRowView({ row, now, onOpen }: HistoryRowProps) { - const { i18n } = useTranslation(); - const copy = notificationHistoryCopy(row, i18n.language); - return ( - { - onOpen(row.payload); - }} - > - - - {copy.title} - - - {timeAgo(parseTimestamp(row.createdAt), undefined, now)} - - - - {copy.body} - - - ); -} - /** One filter chip. Single-select, so the active one carries `selected`. */ function FilterChip({ label, @@ -90,7 +58,9 @@ function FilterChip({