diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9d533c25a6..5b91cd0bb6 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -59,6 +59,7 @@ "expo-image": "55.0.11", "expo-image-picker": "~55.0.21", "expo-insights": "55.0.18", + "expo-linear-gradient": "~55.0.15", "expo-linking": "55.0.16", "expo-localization": "~55.0.16", "expo-location": "55.1.11", diff --git a/apps/mobile/src/components/agents/older-messages-a11y.test.ts b/apps/mobile/src/components/agents/older-messages-a11y.test.ts new file mode 100644 index 0000000000..3f4bfb2c46 --- /dev/null +++ b/apps/mobile/src/components/agents/older-messages-a11y.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { + OLDER_MESSAGES_ARRIVED_ANNOUNCEMENT, + shouldAnnounceOlderMessagesArrival, +} from '@/components/agents/older-messages-a11y'; + +describe('shouldAnnounceOlderMessagesArrival', () => { + it('does not announce on the initial paint (list first becomes initialized)', () => { + expect( + shouldAnnounceOlderMessagesArrival({ + wasInitialized: false, + previousCount: 0, + nextCount: 20, + previousNewestKey: null, + nextNewestKey: 'newest', + }) + ).toBe(false); + }); + + it('announces when count grows and the newest key stays stable (older page prepend)', () => { + expect( + shouldAnnounceOlderMessagesArrival({ + wasInitialized: true, + previousCount: 20, + nextCount: 40, + previousNewestKey: 'newest', + nextNewestKey: 'newest', + }) + ).toBe(true); + }); + + it('does not announce on append when the newest key changes', () => { + expect( + shouldAnnounceOlderMessagesArrival({ + wasInitialized: true, + previousCount: 20, + nextCount: 21, + previousNewestKey: 'old-newest', + nextNewestKey: 'new-newest', + }) + ).toBe(false); + }); + + it('does not announce when a fetch completes with zero prepended items', () => { + expect( + shouldAnnounceOlderMessagesArrival({ + wasInitialized: true, + previousCount: 20, + nextCount: 20, + previousNewestKey: 'newest', + nextNewestKey: 'newest', + }) + ).toBe(false); + }); + + it('does not announce when count shrinks', () => { + expect( + shouldAnnounceOlderMessagesArrival({ + wasInitialized: true, + previousCount: 20, + nextCount: 10, + previousNewestKey: 'newest', + nextNewestKey: 'newest', + }) + ).toBe(false); + }); + + it('does not announce when newest keys are missing', () => { + expect( + shouldAnnounceOlderMessagesArrival({ + wasInitialized: true, + previousCount: 0, + nextCount: 5, + previousNewestKey: null, + nextNewestKey: 'newest', + }) + ).toBe(false); + }); +}); + +describe('OLDER_MESSAGES_ARRIVED_ANNOUNCEMENT', () => { + it('is stable screen-reader copy for both message lists', () => { + expect(OLDER_MESSAGES_ARRIVED_ANNOUNCEMENT).toBe('Earlier messages loaded'); + }); +}); diff --git a/apps/mobile/src/components/agents/older-messages-a11y.ts b/apps/mobile/src/components/agents/older-messages-a11y.ts new file mode 100644 index 0000000000..79db3c1c1a --- /dev/null +++ b/apps/mobile/src/components/agents/older-messages-a11y.ts @@ -0,0 +1,36 @@ +type ShouldAnnounceOlderMessagesArrivalInputs = { + wasInitialized: boolean; + previousCount: number; + nextCount: number; + previousNewestKey: string | null; + nextNewestKey: string | null; +}; + +/** + * Whether assistive technology should hear that earlier messages arrived. + * + * Announces only on a real prepend after the list has already painted: count + * grows while the newest item identity stays stable. Skips initial load, + * appends (newest key changes), and empty prepends (count unchanged). + */ +export function shouldAnnounceOlderMessagesArrival({ + wasInitialized, + previousCount, + nextCount, + previousNewestKey, + nextNewestKey, +}: ShouldAnnounceOlderMessagesArrivalInputs): boolean { + if (!wasInitialized) { + return false; + } + if (nextCount <= previousCount) { + return false; + } + if (previousNewestKey == null || nextNewestKey == null) { + return false; + } + return previousNewestKey === nextNewestKey; +} + +/** Screen-reader copy when an older page actually prepends items. */ +export const OLDER_MESSAGES_ARRIVED_ANNOUNCEMENT = 'Earlier messages loaded'; diff --git a/apps/mobile/src/components/agents/session-message-list-state.test.ts b/apps/mobile/src/components/agents/session-message-list-state.test.ts index 0c8d8f0773..0e7b8ebaa8 100644 --- a/apps/mobile/src/components/agents/session-message-list-state.test.ts +++ b/apps/mobile/src/components/agents/session-message-list-state.test.ts @@ -81,8 +81,9 @@ describe('selectSessionMessageListHeaderState', () => { }); it('hides omitted noise while a page is loading and the count is non-zero', () => { - // The skeleton replaces the calm informational message; once the page - // resolves, the omitted message returns only if no error overrides it. + // State layer still prioritizes loading over omitted. The render model + // maps loading+omitted>0 back to the omitted banner so it stays stable + // through the load (no skeleton, no hide/show flap). expect( selectSessionMessageListHeaderState({ isLoadingOlderMessages: true, diff --git a/apps/mobile/src/components/agents/session-message-list-state.ts b/apps/mobile/src/components/agents/session-message-list-state.ts index cc1e8e93fe..eb18d81435 100644 --- a/apps/mobile/src/components/agents/session-message-list-state.ts +++ b/apps/mobile/src/components/agents/session-message-list-state.ts @@ -1,16 +1,19 @@ import { type OlderMessagesError } from 'cloud-agent-sdk'; /** - * Pagination header state for `SessionMessageList`. The component renders - * exactly one of these per render: a loading skeleton, a calm inline + * Pagination header state for `SessionMessageList`. The state selector + * still emits exactly one of these per render: loading, a calm inline * message (with or without a Retry CTA), or nothing. * * Priority is enforced by `selectSessionMessageListHeaderState`: * 1. The most recent typed failure wins so the user can always act on it * (or, for non-retryable terminals, sees a stable final message). - * 2. While a page is loading, the skeleton replaces the omitted message - * so the two never collide visually. - * 3. The omitted-item count only surfaces when the load path is healthy. + * 2. While a page is loading, the state layer still prioritizes `loading` + * over `omitted`. The render model maps that loading state to the + * omitted banner when omitted count > 0 (keeps the banner stable + * through the load), otherwise to hidden — no transient skeleton. + * 3. The omitted-item count only surfaces from the state layer when the + * load path is healthy (not loading and no error). */ type SessionMessageListHeaderState = | { kind: 'hidden' } diff --git a/apps/mobile/src/components/agents/session-message-list.tsx b/apps/mobile/src/components/agents/session-message-list.tsx index 83b2522c61..d8d8f8d972 100644 --- a/apps/mobile/src/components/agents/session-message-list.tsx +++ b/apps/mobile/src/components/agents/session-message-list.tsx @@ -2,13 +2,17 @@ import { FlashList, type FlashListRef, type ListRenderItem } from '@shopify/flas import { type OlderMessagesError } from 'cloud-agent-sdk'; import { ChevronDown } from 'lucide-react-native'; import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { Pressable, View, type ViewStyle } from 'react-native'; +import { AccessibilityInfo, Pressable, View, type ViewStyle } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { useSessionListAutoScroll } from '@/components/agents/use-session-list-auto-scroll'; import { SessionPaginationHeader } from '@/components/agents/session-pagination-header'; import { shouldTriggerOlderMessagesLoad } from '@/components/agents/session-message-list-state'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { + OLDER_MESSAGES_ARRIVED_ANNOUNCEMENT, + shouldAnnounceOlderMessagesArrival, +} from '@/components/agents/older-messages-a11y'; const listStyle = { flex: 1 } satisfies ViewStyle; const listContentContainerStyle = { paddingVertical: 8 } satisfies ViewStyle; @@ -17,7 +21,7 @@ const listContentContainerStyle = { paddingVertical: 8 } satisfies ViewStyle; // flight. The manager dedupes too, but the UI guard keeps us from issuing // repeated `onStartReached` callbacks during a single drag, which would // otherwise spam the FlashList event log. -const ON_START_REACHED_THRESHOLD = 0.5; +const ON_START_REACHED_THRESHOLD = 2; type SessionMessageListProps = { sessionId: string; @@ -109,6 +113,36 @@ export function SessionMessageList({ inFlightRef.current = false; }, [sessionId]); + // Non-visual a11y signal for older-page arrival (visual loading skeleton + // was removed). Announce only when items were actually prepended. + const olderArrivalInitializedRef = useRef(false); + const olderArrivalCountRef = useRef(0); + const olderArrivalNewestKeyRef = useRef(null); + useEffect(() => { + olderArrivalInitializedRef.current = false; + olderArrivalCountRef.current = 0; + olderArrivalNewestKeyRef.current = null; + }, [sessionId]); + useEffect(() => { + const newestItem = items.at(-1); + const nextNewestKey = newestItem === undefined ? null : keyExtractor(newestItem); + const nextCount = items.length; + if ( + shouldAnnounceOlderMessagesArrival({ + wasInitialized: olderArrivalInitializedRef.current, + previousCount: olderArrivalCountRef.current, + nextCount, + previousNewestKey: olderArrivalNewestKeyRef.current, + nextNewestKey, + }) + ) { + AccessibilityInfo.announceForAccessibility(OLDER_MESSAGES_ARRIVED_ANNOUNCEMENT); + } + olderArrivalInitializedRef.current = true; + olderArrivalCountRef.current = nextCount; + olderArrivalNewestKeyRef.current = nextNewestKey; + }, [items, keyExtractor]); + // Defensive: the structural list ref is required by the hook but // downstream types may infer it as nullable. const listRefSafe = listRef as unknown as React.RefObject>; diff --git a/apps/mobile/src/components/agents/session-pagination-header-render-model.test.ts b/apps/mobile/src/components/agents/session-pagination-header-render-model.test.ts index 5282f50bc1..f0254b37d3 100644 --- a/apps/mobile/src/components/agents/session-pagination-header-render-model.test.ts +++ b/apps/mobile/src/components/agents/session-pagination-header-render-model.test.ts @@ -21,15 +21,32 @@ describe('selectSessionPaginationHeaderRenderModel', () => { expect(headerModel()).toEqual({ kind: 'hidden' }); }); - it('returns loading with testID and progressbar role', () => { + it('hides the transient loading placeholder when no omitted banner is showing', () => { expect(headerModel({ isLoadingOlderMessages: true })).toEqual({ - kind: 'loading', - testID: 'session-pagination-header-loading', - accessibilityRole: 'progressbar', - text: null, + kind: 'hidden', }); }); + it('keeps the omitted banner stable while a page is loading', () => { + expect(headerModel({ isLoadingOlderMessages: true, olderMessagesOmittedItemCount: 5 })).toEqual( + { + kind: 'omitted', + testID: 'session-pagination-header-omitted', + text: '5 earlier items from this session could not be displayed.', + } + ); + }); + + it('keeps singular omitted text stable while a page is loading', () => { + expect(headerModel({ isLoadingOlderMessages: true, olderMessagesOmittedItemCount: 1 })).toEqual( + { + kind: 'omitted', + testID: 'session-pagination-header-omitted', + text: 'Some earlier items from this session could not be displayed.', + } + ); + }); + it('renders retryable text and a Retry CTA', () => { expect(headerModel({ olderMessagesError: error('retryable') })).toEqual({ kind: 'retryable', @@ -76,14 +93,19 @@ describe('selectSessionPaginationHeaderRenderModel', () => { it('only includes a retry CTA for the retryable state', () => { const hidden = headerModel(); - const loading = headerModel({ isLoadingOlderMessages: true }); + const loadingHidden = headerModel({ isLoadingOlderMessages: true }); + const loadingOmitted = headerModel({ + isLoadingOlderMessages: true, + olderMessagesOmittedItemCount: 3, + }); const invalidData = headerModel({ olderMessagesError: error('invalid_data') }); const tooLarge = headerModel({ olderMessagesError: error('too_large') }); const omitted = headerModel({ olderMessagesOmittedItemCount: 3 }); const retryable = headerModel({ olderMessagesError: error('retryable') }); expect('retry' in hidden).toBe(false); - expect('retry' in loading).toBe(false); + expect('retry' in loadingHidden).toBe(false); + expect('retry' in loadingOmitted).toBe(false); expect('retry' in invalidData).toBe(false); expect('retry' in tooLarge).toBe(false); expect('retry' in omitted).toBe(false); diff --git a/apps/mobile/src/components/agents/session-pagination-header-render-model.ts b/apps/mobile/src/components/agents/session-pagination-header-render-model.ts index eb11df0320..2dae99cf81 100644 --- a/apps/mobile/src/components/agents/session-pagination-header-render-model.ts +++ b/apps/mobile/src/components/agents/session-pagination-header-render-model.ts @@ -15,7 +15,6 @@ function omittedMessage(count: number): string { export type SessionPaginationHeaderRenderModel = | { kind: 'hidden' } - | { kind: 'loading'; testID: string; accessibilityRole: 'progressbar'; text: null } | { kind: 'retryable'; testID: string; @@ -26,6 +25,14 @@ export type SessionPaginationHeaderRenderModel = | { kind: 'too_large'; testID: string; text: string } | { kind: 'omitted'; testID: string; text: string }; +function omittedRenderModel(count: number): SessionPaginationHeaderRenderModel { + return { + kind: 'omitted', + testID: 'session-pagination-header-omitted', + text: omittedMessage(count), + }; +} + export function selectSessionPaginationHeaderRenderModel( inputs: SessionMessageListHeaderStateInputs ): SessionPaginationHeaderRenderModel { @@ -35,13 +42,17 @@ export function selectSessionPaginationHeaderRenderModel( return { kind: 'hidden' }; } + // Suppress the transient loading placeholder so FlashList mVCP is not + // disturbed by a header height collapse when the older page arrives. + // When an omitted banner is already visible (count > 0), keep it stable + // through the load instead of hiding it — a hide/show flap would reintroduce + // the same jump. The state layer still prioritizes `loading` over `omitted`; + // this mapping is render-model only. if (state.kind === 'loading') { - return { - kind: 'loading', - testID: 'session-pagination-header-loading', - accessibilityRole: 'progressbar', - text: null, - }; + if (inputs.olderMessagesOmittedItemCount > 0) { + return omittedRenderModel(inputs.olderMessagesOmittedItemCount); + } + return { kind: 'hidden' }; } if (state.kind === 'retryable') { @@ -69,9 +80,5 @@ export function selectSessionPaginationHeaderRenderModel( }; } - return { - kind: 'omitted', - testID: 'session-pagination-header-omitted', - text: omittedMessage(state.count), - }; + return omittedRenderModel(state.count); } diff --git a/apps/mobile/src/components/agents/session-pagination-header.tsx b/apps/mobile/src/components/agents/session-pagination-header.tsx index 010e864df7..6a2916764b 100644 --- a/apps/mobile/src/components/agents/session-pagination-header.tsx +++ b/apps/mobile/src/components/agents/session-pagination-header.tsx @@ -1,7 +1,6 @@ import { View } from 'react-native'; import { Button } from '@/components/ui/button'; -import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { selectSessionPaginationHeaderRenderModel, @@ -33,18 +32,6 @@ export function SessionPaginationHeader({ return null; } - if (model.kind === 'loading') { - return ( - - - - ); - } - if (model.kind === 'retryable') { return ( void; - isFetchingOlder: boolean; pendingAction: PendingAction | null; scrollToNewestRequest: number; onExecuteAction: (message: Message, groupId: string, value: ExecApprovalDecision) => void; @@ -50,7 +53,6 @@ export function MessageList({ members, botName, fetchOlder, - isFetchingOlder, pendingAction, scrollToNewestRequest, onExecuteAction, @@ -164,6 +166,35 @@ export function MessageList({ scrollToNewest(); }, [scrollToNewest, scrollToNewestRequest]); + // Non-visual a11y signal for older-page arrival (no visual loading header). + // Announce only when items were actually prepended after the list painted. + const olderArrivalInitializedRef = useRef(false); + const olderArrivalCountRef = useRef(0); + const olderArrivalNewestKeyRef = useRef(null); + useEffect(() => { + olderArrivalInitializedRef.current = false; + olderArrivalCountRef.current = 0; + olderArrivalNewestKeyRef.current = null; + }, [conversationId]); + useEffect(() => { + const nextNewestKey = messageListNewestScrollKey(newestMessage); + const nextCount = chronological.length; + if ( + shouldAnnounceOlderMessagesArrival({ + wasInitialized: olderArrivalInitializedRef.current, + previousCount: olderArrivalCountRef.current, + nextCount, + previousNewestKey: olderArrivalNewestKeyRef.current, + nextNewestKey, + }) + ) { + AccessibilityInfo.announceForAccessibility(OLDER_MESSAGES_ARRIVED_ANNOUNCEMENT); + } + olderArrivalInitializedRef.current = true; + olderArrivalCountRef.current = nextCount; + olderArrivalNewestKeyRef.current = nextNewestKey; + }, [chronological, newestMessage]); + return ( - - - ) : null - } /> ); diff --git a/apps/mobile/src/components/notifications-master-gate.test.ts b/apps/mobile/src/components/notifications-master-gate.test.ts new file mode 100644 index 0000000000..8b238f148f --- /dev/null +++ b/apps/mobile/src/components/notifications-master-gate.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveMasterGateLeadingPresentation } from './notifications-master-gate'; + +describe('deriveMasterGateLeadingPresentation (unsettled gate must not assert off)', () => { + it('is neutral while permission is loading even if notificationsEnabled is false', () => { + expect( + deriveMasterGateLeadingPresentation({ + permissionLoading: true, + permissionError: false, + gateSettled: false, + notificationsEnabled: false, + }) + ).toBe('neutral'); + }); + + it('is neutral while token queries settle after permission loaded without error', () => { + expect( + deriveMasterGateLeadingPresentation({ + permissionLoading: false, + permissionError: false, + gateSettled: false, + notificationsEnabled: false, + }) + ).toBe('neutral'); + }); + + it('is on when the gate is settled and notifications are fully enabled', () => { + expect( + deriveMasterGateLeadingPresentation({ + permissionLoading: false, + permissionError: false, + gateSettled: true, + notificationsEnabled: true, + }) + ).toBe('on'); + }); + + it('is off when the gate is settled and notifications are disabled', () => { + expect( + deriveMasterGateLeadingPresentation({ + permissionLoading: false, + permissionError: false, + gateSettled: true, + notificationsEnabled: false, + }) + ).toBe('off'); + }); + + it('keeps the off presentation on permission error (gate settled via denied short-circuit)', () => { + expect( + deriveMasterGateLeadingPresentation({ + permissionLoading: false, + permissionError: true, + gateSettled: true, + notificationsEnabled: false, + }) + ).toBe('off'); + }); +}); diff --git a/apps/mobile/src/components/notifications-master-gate.ts b/apps/mobile/src/components/notifications-master-gate.ts new file mode 100644 index 0000000000..54d0f0d89d --- /dev/null +++ b/apps/mobile/src/components/notifications-master-gate.ts @@ -0,0 +1,27 @@ +type MasterGateLeadingPresentationArgs = Readonly<{ + permissionLoading: boolean; + permissionError: boolean; + gateSettled: boolean; + notificationsEnabled: boolean; +}>; + +/** + * Leading content for the master notifications row (icon, dimming, subtitle). + * + * While the gate is unsettled (permission still loading, or permission OK but + * token queries not settled), do not assert the "off" state — that is the same + * transient-wrong-state class the trailing Switch/CTA already avoid. Permission + * error keeps the settled "off" presentation (gateSettled is true when granted + * is falsy). + */ +export function deriveMasterGateLeadingPresentation({ + permissionLoading, + permissionError, + gateSettled, + notificationsEnabled, +}: MasterGateLeadingPresentationArgs): 'neutral' | 'on' | 'off' { + if (permissionLoading || (!permissionError && !gateSettled)) { + return 'neutral'; + } + return notificationsEnabled ? 'on' : 'off'; +} diff --git a/apps/mobile/src/components/notifications-screen.tsx b/apps/mobile/src/components/notifications-screen.tsx index 2bbc290b8f..b54b5d9ccd 100644 --- a/apps/mobile/src/components/notifications-screen.tsx +++ b/apps/mobile/src/components/notifications-screen.tsx @@ -20,6 +20,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Alert, Linking, Pressable, Switch, View } from 'react-native'; import { toast } from 'sonner-native'; +import { deriveMasterGateLeadingPresentation } from '@/components/notifications-master-gate'; import { ScreenHeader } from '@/components/screen-header'; import { TabScreenScrollView } from '@/components/tab-screen'; import { Skeleton } from '@/components/ui/skeleton'; @@ -28,6 +29,7 @@ import { useAuth } from '@/lib/auth/auth-context'; import { applyAgentPushOptimistic, deriveAgentPushEditable, + deriveGateSettled, deriveShowEnableCta, NOTIFICATION_CATEGORY_KEYS, type NotificationCategoryKey, @@ -197,6 +199,7 @@ export function NotificationsScreen() { data: permissionGranted = false, isLoading: permissionLoading, isError: permissionError, + isFetched: permissionFetched, refetch: refetchPermission, } = useQuery({ queryKey: permissionQueryKey, @@ -209,6 +212,7 @@ export function NotificationsScreen() { const { data: deviceToken, isError: deviceTokenError, + isFetched: deviceTokenFetched, refetch: refetchDeviceToken, } = useQuery({ queryKey: deviceTokenQueryKey, @@ -219,6 +223,7 @@ export function NotificationsScreen() { const { data: pushTokens, isError: pushTokensError, + isFetched: pushTokensFetched, refetch: refetchPushTokens, } = useQuery({ ...trpc.user.getMyPushTokens.queryOptions(), @@ -228,6 +233,16 @@ export function NotificationsScreen() { const serverRegistered = deviceToken != null && (pushTokens ?? []).some(t => t.token === deviceToken); + // Each *Settled is isFetched || isError for the enabled query. Do not invent + // a "disabled → settled" mapping for deviceToken — deriveGateSettled + // short-circuits when permission is denied so disabled flags are never read. + const gateSettled = deriveGateSettled({ + permissionSettled: permissionFetched || permissionError, + permissionGranted, + pushTokensSettled: pushTokensFetched || pushTokensError, + deviceTokenSettled: deviceTokenFetched || deviceTokenError, + }); + const { data: preferences, isLoading: preferencesLoading, @@ -399,6 +414,12 @@ export function NotificationsScreen() { }, []); const isMasterBusy = isTogglingPermission || isRegisteringToken; + const masterLeading = deriveMasterGateLeadingPresentation({ + permissionLoading, + permissionError, + gateSettled, + notificationsEnabled, + }); return ( @@ -414,22 +435,31 @@ export function NotificationsScreen() { Push - {notificationsEnabled ? ( - - ) : ( - - )} + {masterLeading === 'neutral' && } + {masterLeading === 'on' && } + {masterLeading === 'off' && } Notifications enabled - - {notificationsEnabled - ? 'Push notifications are on for this device.' - : 'Permission or device registration is off.'} - + {masterLeading === 'neutral' && } + {masterLeading === 'on' && ( + + Push notifications are on for this device. + + )} + {masterLeading === 'off' && ( + + Permission or device registration is off. + + )} - {permissionLoading && } + {/* Master trailing slot — first match wins: + 1. permissionLoading → skeleton + 2. permissionError → InlineRetry + 3. !gateSettled → skeleton (token queries still settling) + 4. else → real Switch (+ optional isMasterBusy spinner) */} + {permissionLoading && } {!permissionLoading && permissionError && ( void refetchPermission()} /> )} - {!permissionLoading && !permissionError && ( + {!permissionLoading && !permissionError && !gateSettled && ( + + )} + {!permissionLoading && !permissionError && gateSettled && ( <> {isMasterBusy && } - {/* Empty-state CTA: only shown when the master gate is closed. The - retryable unhappy path (a category mutation rejection) is handled - by the toggle itself — there is no terminal failure mode for - these preferences, so a non-retryable CTA is structurally absent. */} - {showEnableCta && !permissionLoading && !permissionError && ( + {/* Empty-state CTA: only shown when the master gate is closed and the + gate has settled (avoids a transient flash while token queries + resolve for an already-registered user). The retryable unhappy + path (a category mutation rejection) is handled by the toggle + itself — there is no terminal failure mode for these preferences, + so a non-retryable CTA is structurally absent. */} + {!permissionLoading && !permissionError && gateSettled && showEnableCta && ( @@ -497,11 +532,19 @@ export function NotificationsScreen() { {preferencesLoading && ( <> - - - - - + {CATEGORY_META.map(meta => ( + + + + + + + + + ))} )} {preferencesError && ( diff --git a/apps/mobile/src/components/ui/skeleton.tsx b/apps/mobile/src/components/ui/skeleton.tsx index adb93f8dd3..3864f52a01 100644 --- a/apps/mobile/src/components/ui/skeleton.tsx +++ b/apps/mobile/src/components/ui/skeleton.tsx @@ -1,6 +1,10 @@ import { useEffect } from 'react'; +import { LinearGradient } from 'expo-linear-gradient'; +import { type LayoutChangeEvent, useColorScheme, View } from 'react-native'; import Animated, { cancelAnimation, + Easing, + makeMutable, useAnimatedStyle, useReducedMotion, useSharedValue, @@ -14,24 +18,121 @@ type SkeletonProps = { className?: string; }; +/** Soft horizontal highlight over `bg-muted` — concrete rgba, not className. */ +const LIGHT_SHIMMER = [ + 'rgba(255, 255, 255, 0)', + 'rgba(255, 255, 255, 0.45)', + 'rgba(255, 255, 255, 0)', +] as const; +const DARK_SHIMMER = [ + 'rgba(255, 255, 255, 0)', + 'rgba(255, 255, 255, 0.1)', + 'rgba(255, 255, 255, 0)', +] as const; + +const SHIMMER_DURATION_MS = 1800; +/** Half-cycle for opacity breathe (0.4→1.0). Old value was 1000 ms; full cycle is 1700 ms. */ +const PULSE_HALF_CYCLE_MS = 850; + +/** LinearGradient is not NativeWind-mapped; fill the absolute overlay. */ +const GRADIENT_FILL = { flex: 1 } as const; + +/** + * One module-level Reanimated clock shared by every animating Skeleton. + * Refcounted: 0→1 starts withRepeat; 1→0 cancels. Reduced-motion instances + * never touch this clock. + */ +const shimmerProgress = makeMutable(0); +let shimmerRefCount = 0; + +function retainShimmerClock(): void { + shimmerRefCount += 1; + if (shimmerRefCount === 1) { + shimmerProgress.value = 0; + shimmerProgress.value = withRepeat( + withTiming(1, { + duration: SHIMMER_DURATION_MS, + easing: Easing.inOut(Easing.ease), + }), + -1, + false + ); + } +} + +function releaseShimmerClock(): void { + if (shimmerRefCount <= 0) { + return; + } + shimmerRefCount -= 1; + if (shimmerRefCount === 0) { + cancelAnimation(shimmerProgress); + shimmerProgress.value = 0; + } +} + export function Skeleton({ className }: Readonly) { const reducedMotion = useReducedMotion(); - const opacity = useSharedValue(0.4); + const colorScheme = useColorScheme(); + const layoutWidth = useSharedValue(0); + const pulse = useSharedValue(0.4); useEffect(() => { - if (!reducedMotion) { - opacity.value = withRepeat(withTiming(1, { duration: 1000 }), -1, true); + if (reducedMotion) { + return undefined; } - + retainShimmerClock(); + pulse.value = withRepeat(withTiming(1, { duration: PULSE_HALF_CYCLE_MS }), -1, true); return () => { - cancelAnimation(opacity); + cancelAnimation(pulse); + releaseShimmerClock(); + }; + }, [pulse, reducedMotion]); + + const shimmerStyle = useAnimatedStyle(() => { + const width = layoutWidth.value; + // Before first onLayout, keep the gradient invisible / off-canvas. + if (width <= 0) { + return { + opacity: 0, + transform: [{ translateX: 0 }], + }; + } + // progress 0→1 maps translateX from -width (fully left) to +width (fully right). + const translateX = -width + shimmerProgress.value * width * 2; + return { + opacity: 1, + transform: [{ translateX }], }; - }, [opacity, reducedMotion]); + }); + + const pulseStyle = useAnimatedStyle(() => ({ opacity: pulse.value })); + + const onLayout = (event: LayoutChangeEvent) => { + layoutWidth.value = event.nativeEvent.layout.width; + }; + + // Reduced motion: static muted block at opacity 0.7 — no gradient, no clock. + if (reducedMotion) { + return ; + } - const animatedStyle = useAnimatedStyle(() => ({ - // Static muted block when reduced motion is on — no shimmer loop. - opacity: reducedMotion ? 0.7 : opacity.value, - })); + const shimmerColors = colorScheme === 'dark' ? DARK_SHIMMER : LIGHT_SHIMMER; - return ; + return ( + + + + + + ); } diff --git a/apps/mobile/src/lib/hooks/agent-push-preference.test.ts b/apps/mobile/src/lib/hooks/agent-push-preference.test.ts index 762bd73907..2ed4f1e815 100644 --- a/apps/mobile/src/lib/hooks/agent-push-preference.test.ts +++ b/apps/mobile/src/lib/hooks/agent-push-preference.test.ts @@ -5,6 +5,7 @@ import { applyAgentPushOptimistic, DEFAULT_NOTIFICATION_PREFERENCE, deriveAgentPushEditable, + deriveGateSettled, deriveShowEnableCta, NOTIFICATION_CATEGORY_KEYS, type NotificationCategoryKey, @@ -85,6 +86,42 @@ describe('deriveShowEnableCta (empty-state CTA presence)', () => { }); }); +describe('deriveGateSettled (master gate settle flap)', () => { + // Truth table: permissionSettled, granted, pushTokensSettled, deviceTokenSettled → result + const cases: [boolean, boolean, boolean, boolean, boolean, string][] = [ + [false, false, false, false, false, 'permission loading'], + [false, true, true, true, false, 'permission loading ignores settled tokens'], + // Denied / permission-error (granted falsy): short-circuit; token flags irrelevant + [true, false, false, false, true, 'denied short-circuits unsettled tokens'], + [true, false, true, true, true, 'denied with settled tokens still true'], + // Granted: both token queries must settle (isFetched || isError each) + [true, true, false, true, false, 'granted, pushTokens in flight'], + [true, true, true, false, false, 'granted, deviceToken in flight'], + [true, true, false, false, false, 'granted, both tokens in flight'], + [true, true, true, true, true, 'granted, both tokens settled'], + ]; + + for (const [ + permissionSettled, + permissionGranted, + pushTokensSettled, + deviceTokenSettled, + expected, + label, + ] of cases) { + it(label, () => { + expect( + deriveGateSettled({ + permissionSettled, + permissionGranted, + pushTokensSettled, + deviceTokenSettled, + }) + ).toBe(expected); + }); + } +}); + describe('readAgentPushPreference', () => { it('returns the default for the requested category when the cache has no snapshot', () => { const qc = makeQueryClient(); diff --git a/apps/mobile/src/lib/hooks/agent-push-preference.ts b/apps/mobile/src/lib/hooks/agent-push-preference.ts index 00fe8e71ab..3458073949 100644 --- a/apps/mobile/src/lib/hooks/agent-push-preference.ts +++ b/apps/mobile/src/lib/hooks/agent-push-preference.ts @@ -63,6 +63,36 @@ export function deriveShowEnableCta(notificationsEnabled: boolean): boolean { return !notificationsEnabled; } +type GateSettledArgs = Readonly<{ + permissionSettled: boolean; + permissionGranted: boolean; + pushTokensSettled: boolean; + deviceTokenSettled: boolean; +}>; + +/** + * Whether the master push gate has enough settled inputs to render the real + * Switch / Enable CTA without a transient wrong value or layout flap. + * + * Short-circuits when permission is denied (or errored → granted is falsy): + * token queries are irrelevant to a closed gate, and `deviceToken` is + * `enabled: permissionGranted` so it would never settle while denied. + */ +export function deriveGateSettled({ + permissionSettled, + permissionGranted, + pushTokensSettled, + deviceTokenSettled, +}: GateSettledArgs): boolean { + if (!permissionSettled) { + return false; + } + if (!permissionGranted) { + return true; + } + return pushTokensSettled && deviceTokenSettled; +} + /** Map the legacy single-key cache shape to the new per-category shape. */ function readFromSnapshot(snapshot: NotificationPreferencesSnapshot): NotificationPreferences { if (!snapshot) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 590007df0b..9487df4fba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -387,6 +387,9 @@ importers: expo-insights: specifier: 55.0.18 version: 55.0.18(expo@55.0.27) + expo-linear-gradient: + specifier: ~55.0.15 + version: 55.0.16(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) expo-linking: specifier: 55.0.16 version: 55.0.16(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) @@ -12199,6 +12202,13 @@ packages: expo: '*' react: '*' + expo-linear-gradient@55.0.16: + resolution: {integrity: sha512-zzNB2Hdv+i3zIO8GPN1oU6BeqRJXQSwKgmdJXNf735Fsplxk1bjmqdYNXHamge7uHTb6M+X6aY9Mtjbrf/4S2g==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-linking@55.0.16: resolution: {integrity: sha512-O+Idexholc5rlr6Z4W/+TewTLQVnbEXztoLgLEbJwh5/A1SsJ+eq9yGGPwd4rcQ+fEBDiExr6qZxbUArnUGHfw==} peerDependencies: @@ -28894,6 +28904,12 @@ snapshots: expo: 55.0.27(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(bufferutil@4.1.0)(expo-router@55.0.16)(react-dom@19.2.6(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@6.0.6) react: 19.2.0 + expo-linear-gradient@55.0.16(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0): + dependencies: + expo: 55.0.27(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(bufferutil@4.1.0)(expo-router@55.0.16)(react-dom@19.2.6(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6) + expo-linking@55.0.16(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0): dependencies: expo-constants: 55.0.16(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))