diff --git a/.changeset/ype-104-ui-consumer-api.md b/.changeset/ype-104-ui-consumer-api.md new file mode 100644 index 0000000..232839c --- /dev/null +++ b/.changeset/ype-104-ui-consumer-api.md @@ -0,0 +1,16 @@ +--- +'@youversion/platform-react-native-expo-ui': minor +--- + +YPE-104 UI deltas on the highlights stack. + +## BibleReader + +- **`refreshHighlights()` ref handle** — call `reader.current?.refreshHighlights()` to re-fetch highlights for the reader's current scope (for example after a screen refocus). +- **`onHighlightError(error)`** — optional callback for offline or queued highlight writes. Fires for `{ status: 'queued' }` and `{ status: 'error', reason: 'transient' }` only; auth, invalid, ok, and noop outcomes stay silent. The `HighlightWriteError` type is exported from the UI package. + +## Sign-out guard + +- **`BibleReader`** and **`YouVersionAuthButton`** now ask before signing out, matching the Swift SDK. When the highlight write queue still holds unsent work, the copy escalates to "Save your highlights?"; confirming calls `signOut()` only — core clears the queue and cache on sign-out. +- **Web bypass** — on `Platform.OS === 'web'`, both surfaces call `signOut()` directly because React Native Web's `Alert.alert` is a no-op. +- **`useSignOutGuard`** is exported for apps that need the same confirmation on their own sign-out UI. diff --git a/AGENTS.md b/AGENTS.md index f2e87f1..52d6866 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ Keep `GestureHandlerRootView` outside `YouVersionProvider`; bottom-sheet gesture `BibleCard` and `BibleReader` are stateful — they own `versionId` (via `useControllableState`) and coordinate picker sheets. When `showVersionPicker` is enabled and `onVersionPickerPress` is omitted, they open a built-in `BibleVersionPickerSheet`; when a handler is provided, the consumer handles the press and no sheet renders. On `BibleCard`, `showVersionPicker` defaults to `false` (matching the Web SDK), so consumers must opt in before either path applies. -`BibleReader` also intercepts the Web SDK user menu's sign-out, matching Swift: `onSignOutPress` raises a native `Alert` rather than calling `signOut()`. Two variants, chosen by `hasQueuedHighlightWrites(userInfo?.id)` — a plain confirmation, or an escalated "Save your highlights?" when the write queue still holds unsent work that sign-out would purge. It is an `Alert`, not a `NativeSheet`: it matches Swift's `.alert`, it needs `style: 'destructive'` (which the `prompt-sheet` family cannot express), and there is nothing to lay out. **Web bypasses it entirely** (`Platform.OS === 'web'` passes `signOut` straight through) because `react-native-web`'s `Alert.alert` is a silent no-op, which would leave the menu item doing nothing forever. The interception is reader-scoped by design — `YouVersionAuthButton` and `useYVAuth().signOut()` still sign out immediately, as Swift's `SignInWithYouVersionButton` does. +`BibleReader` and `YouVersionAuthButton` route sign-out through `useSignOutGuard`, matching Swift: a native `Alert` before `signOut()` runs. Two variants, chosen by `hasQueuedHighlightWrites(userInfo?.id)` — a plain confirmation, or an escalated "Save your highlights?" when the write queue still holds unsent work that sign-out would purge. It is an `Alert`, not a `NativeSheet`: it matches Swift's `.alert`, it needs `style: 'destructive'` (which the `prompt-sheet` family cannot express), and there is nothing to lay out. **Web bypasses it entirely** (`Platform.OS === 'web'` calls `signOut()` directly) because `react-native-web`'s `Alert.alert` is a silent no-op, which would leave the button doing nothing forever. The guard returns `undefined` when auth is unconfigured or the user is already signed out, so callers skip the prompt. `useYVAuth().signOut()` still signs out immediately when invoked directly — only SDK-owned surfaces (`BibleReader`'s user menu, `YouVersionAuthButton`) go through the guard. ### Verse Action Sheet @@ -168,7 +168,7 @@ Keep `apps/example/metro.config.js` minimal — just `getDefaultConfig(__dirname ## Exports -**UI** (`@youversion/platform-react-native-expo-ui`): `YouVersionProvider`, `BibleCard`, `BibleChapterPickerSheet`, `BibleReader`, `BibleReaderSettingsSheet`, `BibleTextView`, `BibleVersionPickerSheet`, `VerseOfTheDay`, and `YouVersionAuthButton`, plus the verse-selection payload types re-exported from the Web SDK (`BibleReaderVerseSelection`, `BibleReaderShareData`) so an `onVerseSelect` handler can be typed without depending on `@youversion/platform-react-ui` +**UI** (`@youversion/platform-react-native-expo-ui`): `YouVersionProvider`, `BibleCard`, `BibleChapterPickerSheet`, `BibleReader`, `BibleReaderSettingsSheet`, `BibleTextView`, `BibleVersionPickerSheet`, `VerseOfTheDay`, and `YouVersionAuthButton`, plus `useSignOutGuard`, and types `BibleReaderHandle`, `HighlightWriteError`, `SignOutGuardAuth`, plus the verse-selection payload types re-exported from the Web SDK (`BibleReaderVerseSelection`, `BibleReaderShareData`) so an `onVerseSelect` handler can be typed without depending on `@youversion/platform-react-ui` **Core** (`@youversion/platform-react-native-expo-core`): `YouVersionProvider` (installation id + optional auth), `useYouVersion`, `useYVAuth` (its value carries `requestedPermissions` / `grantedPermissions` / `hasPermission` / `invalidatePermissions` / `requestPermissions` / `ensureFreshToken` / `getAccessToken` alongside the sign-in surface), `useHighlights`, `useHighlightPermissionFlow`, `deriveServerColors`, `hasQueuedHighlightWrites`, `HIGHLIGHT_COLORS` / `isHighlightColor` / `isValidHighlightHex`, `mmkvStorage`, auth types (`AccessTokenResult`, `AuthConfig`, `AuthPermission`, `KnownAuthPermission`, `AuthScope`, `DataExchangeOutcome`, `DataExchangeFailureReason`, `YVUserInfo`), and highlights types (`Highlight`, `HighlightColor`, `HighlightScope`, `ServerColors`, `HighlightWriteOutcome` (`ok` / `queued` / `noop` / `error`), `HighlightWriteReason`, `HighlightsFetchError`, `UseHighlightsOptions`, `UseHighlightsResult`, `UseHighlightPermissionFlowResult`, `PermissionFlowError`, `PermissionFlowErrorReason`) diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index f003325..a7559ef 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -12,14 +12,18 @@ export { export type { BibleCardProps, BibleChapterPickerSheetProps, + BibleReaderHandle, BibleReaderProps, BibleReaderSettingsSheetProps, BibleReaderShareData, BibleReaderVerseSelection, BibleTextViewProps, BibleVersionPickerSheetProps, + HighlightWriteError, VerseOfTheDayProps, YouVersionAuthButtonProps, YouVersionProviderProps, YouVersionTheme, } from './native' +export { useSignOutGuard } from './native' +export type { SignOutGuardAuth } from './native' diff --git a/packages/ui/src/lib/__tests__/report-highlight-write-error.test.ts b/packages/ui/src/lib/__tests__/report-highlight-write-error.test.ts new file mode 100644 index 0000000..7f76964 --- /dev/null +++ b/packages/ui/src/lib/__tests__/report-highlight-write-error.test.ts @@ -0,0 +1,133 @@ +import type { HighlightWriteOutcome } from '@youversion/platform-react-native-expo-core' + +import { + reportHighlightWriteError, + type HighlightWriteError, +} from '../report-highlight-write-error' + +type AssertQueuedHasNoReason = Extract< + HighlightWriteError, + { status: 'queued' } +> extends { reason?: unknown } + ? never + : true + +const assertQueuedHasNoReason: AssertQueuedHasNoReason = true +void assertQueuedHasNoReason + +describe('reportHighlightWriteError', () => { + it('fires for queued outcomes', () => { + const onHighlightError = jest.fn() + + reportHighlightWriteError({ status: 'queued', verses: [1, 2] }, onHighlightError) + + expect(onHighlightError).toHaveBeenCalledWith({ status: 'queued', verses: [1, 2] }) + }) + + it('fires for transient error outcomes', () => { + const onHighlightError = jest.fn() + + reportHighlightWriteError( + { + status: 'error', + reason: 'transient', + message: 'Network request failed', + failedVerses: [1, 2], + succeededVerses: [], + }, + onHighlightError, + ) + + expect(onHighlightError).toHaveBeenCalledWith({ + status: 'error', + reason: 'transient', + verses: [1, 2], + message: 'Network request failed', + }) + }) + + it('does nothing when no handler is passed', () => { + expect(() => + reportHighlightWriteError({ status: 'queued', verses: [1, 2] }), + ).not.toThrow() + }) + + it('swallows a throwing onHighlightError callback', () => { + const onHighlightError = jest.fn(() => { + throw new Error('consumer blew up') + }) + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) + + expect(() => + reportHighlightWriteError({ status: 'queued', verses: [1, 2] }, onHighlightError), + ).not.toThrow() + expect(onHighlightError).toHaveBeenCalledTimes(1) + expect(consoleError).toHaveBeenCalledWith('onHighlightError failed:', expect.any(Error)) + + consoleError.mockRestore() + }) + + it('swallows a rejected async onHighlightError callback', async () => { + const onHighlightError = jest.fn(async () => { + throw new Error('async consumer blew up') + }) + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) + + expect(() => + reportHighlightWriteError({ status: 'queued', verses: [1, 2] }, onHighlightError), + ).not.toThrow() + expect(onHighlightError).toHaveBeenCalledTimes(1) + + await Promise.resolve() + expect(consoleError).toHaveBeenCalledWith('onHighlightError failed:', expect.any(Error)) + + consoleError.mockRestore() + }) + + it.each([ + ['ok', { status: 'ok', verses: [1, 2] } satisfies HighlightWriteOutcome], + ['noop', { status: 'noop' } satisfies HighlightWriteOutcome], + [ + 'invalid', + { + status: 'error', + reason: 'invalid', + message: 'Unsupported highlight color.', + failedVerses: [1, 2], + succeededVerses: [], + } satisfies HighlightWriteOutcome, + ], + [ + 'auth', + { + status: 'error', + reason: 'auth', + message: 'Request failed with status 403', + failedVerses: [1, 2], + succeededVerses: [], + } satisfies HighlightWriteOutcome, + ], + [ + 'not-signed-in', + { + status: 'error', + reason: 'not-signed-in', + message: 'Not signed in', + failedVerses: [1, 2], + succeededVerses: [], + } satisfies HighlightWriteOutcome, + ], + ] as const)('does not fire for %s outcomes', (_label, outcome) => { + const onHighlightError = jest.fn() + + reportHighlightWriteError(outcome, onHighlightError) + + expect(onHighlightError).not.toHaveBeenCalled() + }) + + it('queued member has no reason field at the type level', () => { + // @ts-expect-error — queued outcomes never carry reason + const illegal: HighlightWriteError = { status: 'queued', reason: 'transient', verses: [1] } + void illegal + }) +}) diff --git a/packages/ui/src/lib/report-highlight-write-error.ts b/packages/ui/src/lib/report-highlight-write-error.ts new file mode 100644 index 0000000..e2a4537 --- /dev/null +++ b/packages/ui/src/lib/report-highlight-write-error.ts @@ -0,0 +1,43 @@ +import type { HighlightWriteOutcome } from '@youversion/platform-react-native-expo-core' + +/** + * Consumer-facing slice of a highlight write outcome. Fired only for offline or + * queued writes — not auth, invalid, ok, or noop. + */ +export type HighlightWriteError = + | { status: 'queued'; verses: number[] } + | { status: 'error'; reason: 'transient'; verses: number[]; message?: string } + +function invokeHighlightErrorHandler( + onHighlightError: (error: HighlightWriteError) => void, + error: HighlightWriteError, +): void { + try { + void Promise.resolve(onHighlightError(error)).catch((err) => { + console.error('onHighlightError failed:', err) + }) + } catch (err) { + console.error('onHighlightError failed:', err) + } +} + +export function reportHighlightWriteError( + outcome: HighlightWriteOutcome, + onHighlightError?: (error: HighlightWriteError) => void, +): void { + if (onHighlightError === undefined) { + return + } + if (outcome.status === 'queued') { + invokeHighlightErrorHandler(onHighlightError, { status: 'queued', verses: outcome.verses }) + return + } + if (outcome.status === 'error' && outcome.reason === 'transient') { + invokeHighlightErrorHandler(onHighlightError, { + status: 'error', + reason: 'transient', + verses: outcome.failedVerses, + message: outcome.message, + }) + } +} diff --git a/packages/ui/src/native/__tests__/bible-reader-consumer-api.test.tsx b/packages/ui/src/native/__tests__/bible-reader-consumer-api.test.tsx new file mode 100644 index 0000000..abda88e --- /dev/null +++ b/packages/ui/src/native/__tests__/bible-reader-consumer-api.test.tsx @@ -0,0 +1,278 @@ +/** + * Consumer-facing BibleReader seams: refreshHighlights ref handle and onHighlightError. + */ +import { act, fireEvent, render } from '@testing-library/react-native' +import type { HighlightWriteOutcome } from '@youversion/platform-react-native-expo-core' +import * as core from '@youversion/platform-react-native-expo-core' +import type { BibleReaderVerseSelection } from '@youversion/platform-react-ui' +import { createRef, type ReactNode } from 'react' + +import { BibleReader, type BibleReaderHandle } from '../bible-reader' +import { YouVersionProvider } from '../youversion-provider' + +const VERSION_ID = 111 + +const SELECTION: BibleReaderVerseSelection = { + versionId: VERSION_ID, + book: 'JHN', + chapter: '1', + verses: [1, 2], + passageIds: ['JHN.1.1', 'JHN.1.2'], + reference: 'John 1:1-2', + shareData: null, +} + +const highlightPermissionFlowApply = jest.fn< + Promise, + [string, number[]] +>(async () => ({ status: 'ok', verses: [1, 2] })) +const rawRemove = jest.fn, [string, number[]]>( + async () => ({ status: 'ok', verses: [1, 2] }), +) +const refreshHighlights = jest.fn(async () => undefined) + +function stubHighlightPermissionFlow() { + jest + .spyOn(core, 'useHighlightPermissionFlow') + .mockImplementation(({ versionId, book, chapter }) => ({ + highlights: { + highlights: [], + scope: { versionId, book, chapter }, + isRefreshing: false, + error: null, + refresh: refreshHighlights, + apply: jest.fn(), + remove: rawRemove, + }, + isConfirming: false, + apply: highlightPermissionFlowApply, + confirm: jest.fn(), + decline: jest.fn(), + flowError: null, + })) +} + +jest.mock('../../dom/bible-reader', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View, Text, Pressable } = require('react-native') + return { + __esModule: true, + default: function MockDOM(props: { + onVerseSelect?: (verseSelection: BibleReaderVerseSelection) => Promise + }) { + return ( + + void props.onVerseSelect?.(SELECTION)} + > + Select + + + ) + }, + } +}) + +jest.mock('../../dom/footnote-content', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View } = require('react-native') + return { __esModule: true, default: () => } +}) + +jest.mock('../bible-chapter-picker-sheet', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View } = require('react-native') + return { + __esModule: true, + BibleChapterPickerSheet: () => , + } +}) + +jest.mock('../bible-version-picker-sheet', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View } = require('react-native') + return { + __esModule: true, + BibleVersionPickerSheet: () => , + } +}) + +jest.mock('../bible-reader-settings-sheet', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View } = require('react-native') + return { __esModule: true, BibleReaderSettingsSheet: () => } +}) + +jest.mock('../native-sheet', () => { + const actual = jest.requireActual('../native-sheet') + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View } = require('react-native') + return { + ...actual, + NativeSheet: ({ isOpen, children }: { isOpen: boolean; children: ReactNode }) => + isOpen ? {children} : null, + } +}) + +jest.mock('../bible-verse-action-sheet', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View, Pressable, Text } = require('react-native') + return { + __esModule: true, + BibleVerseActionSheet: (props: { + isOpen: boolean + onSwatchPress: (swatch: { color: string; state: 'apply' | 'remove' }) => void + }) => + props.isOpen ? ( + + props.onSwatchPress({ color: 'fffe00', state: 'apply' })} + > + Apply + + props.onSwatchPress({ color: 'fffe00', state: 'remove' })} + > + Remove + + + ) : null, + } +}) + +jest.mock('../sign-in-with-youversion-sheet', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View } = require('react-native') + return { __esModule: true, SignInWithYouVersionSheet: () => } +}) + +jest.mock('../highlight-consent-sheet', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View } = require('react-native') + return { __esModule: true, HighlightConsentSheet: () => } +}) + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +) + +beforeEach(() => { + highlightPermissionFlowApply.mockClear() + rawRemove.mockClear() + refreshHighlights.mockClear() + stubHighlightPermissionFlow() + jest.spyOn(core, 'useYVAuthOptional').mockReturnValue({ + isAuthenticated: true, + accessToken: 'token', + userInfo: { id: 'user-1' }, + error: null, + signIn: jest.fn(async () => undefined), + signOut: jest.fn(async () => undefined), + refreshNow: jest.fn(async () => undefined), + ensureFreshToken: jest.fn(async () => undefined), + getAccessToken: jest.fn( + async () => ({ status: 'ok', token: 'token', userId: 'user-1' }) as const, + ), + isLoading: false, + requestedPermissions: ['highlights'], + grantedPermissions: ['highlights'], + hasPermission: () => true, + invalidatePermissions: jest.fn(), + requestPermissions: jest.fn(async () => ({ status: 'cancel' }) as const), + }) +}) + +afterEach(() => { + jest.restoreAllMocks() +}) + +async function selectVerses(getByTestId: (id: string) => Parameters[0]) { + await act(async () => { + fireEvent.press(getByTestId('trigger-verse-select')) + }) +} + +describe('BibleReader consumer API', () => { + it('refreshHighlights calls through to highlights.refresh', async () => { + const reader = createRef() + + render(, { wrapper }) + + expect(reader.current).not.toBeNull() + expect(refreshHighlights).not.toHaveBeenCalled() + + await act(async () => { + await reader.current?.refreshHighlights() + }) + + expect(refreshHighlights).toHaveBeenCalledTimes(1) + }) + + it('exposes nothing beyond refreshHighlights on the ref handle', () => { + const reader = createRef() + + render(, { wrapper }) + + expect(Object.keys(reader.current ?? {})).toEqual(['refreshHighlights']) + }) + + it('onHighlightError fires for queued apply outcomes', async () => { + highlightPermissionFlowApply.mockResolvedValueOnce({ status: 'queued', verses: [1, 2] }) + const onHighlightError = jest.fn() + + const { getByTestId } = render( + , + { wrapper }, + ) + + await selectVerses(getByTestId) + await act(async () => { + fireEvent.press(getByTestId('trigger-apply-swatch')) + }) + + expect(onHighlightError).toHaveBeenCalledWith({ status: 'queued', verses: [1, 2] }) + }) + + it('onHighlightError fires for transient error outcomes on remove', async () => { + rawRemove.mockResolvedValueOnce({ + status: 'error', + reason: 'transient', + message: 'Network request failed', + failedVerses: [1, 2], + succeededVerses: [], + }) + const onHighlightError = jest.fn() + + const { getByTestId } = render( + , + { wrapper }, + ) + + await selectVerses(getByTestId) + await act(async () => { + fireEvent.press(getByTestId('trigger-remove-swatch')) + }) + + expect(onHighlightError).toHaveBeenCalledWith({ + status: 'error', + reason: 'transient', + verses: [1, 2], + message: 'Network request failed', + }) + }) +}) diff --git a/packages/ui/src/native/__tests__/use-sign-out-guard.test.tsx b/packages/ui/src/native/__tests__/use-sign-out-guard.test.tsx new file mode 100644 index 0000000..68109fe --- /dev/null +++ b/packages/ui/src/native/__tests__/use-sign-out-guard.test.tsx @@ -0,0 +1,228 @@ +import { act, renderHook } from '@testing-library/react-native' +import * as core from '@youversion/platform-react-native-expo-core' +import type { ReactNode } from 'react' +import { Alert, Platform } from 'react-native' + +import en from '../../i18n/locales/en.json' +import { useSignOutGuard } from '../use-sign-out-guard' +import { YouVersionProvider } from '../youversion-provider' + +const signOut = jest.fn(async () => undefined) +const USER_ID = 'user-1' + +const signedInAuth = { signOut, isAuthenticated: true as const, userInfo: { id: USER_ID } } + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +) + +type AlertButton = { text?: string; style?: string; onPress?: () => void } + +function alertCall() { + const call = (Alert.alert as jest.Mock).mock.calls.at(-1) + expect(call).toBeTruthy() + return { + title: call?.[0] as string, + message: call?.[1] as string, + buttons: call?.[2] as AlertButton[], + } +} + +function pressAlertButton(text: string) { + const button = alertCall().buttons.find((candidate) => candidate.text === text) + expect(button).toBeTruthy() + button?.onPress?.() +} + +beforeEach(() => { + signOut.mockClear() + jest.spyOn(Alert, 'alert').mockImplementation(() => undefined) + jest.spyOn(core, 'hasQueuedHighlightWrites').mockReturnValue(false) +}) + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe('useSignOutGuard', () => { + it('returns undefined when auth is null', () => { + const { result } = renderHook(() => useSignOutGuard(null), { wrapper }) + expect(result.current).toBeUndefined() + }) + + it('signs out without Alert when the user is signed out', async () => { + const { result } = renderHook( + () => useSignOutGuard({ signOut, isAuthenticated: false, userInfo: null }), + { wrapper }, + ) + + expect(result.current).toBeDefined() + + await act(async () => { + await result.current?.() + }) + + expect(Alert.alert).not.toHaveBeenCalled() + expect(signOut).toHaveBeenCalledTimes(1) + }) + + it('shows the normal sign-out alert when nothing is queued', async () => { + const { result } = renderHook(() => useSignOutGuard(signedInAuth), { wrapper }) + + await act(async () => { + await result.current?.() + }) + + const { title, message, buttons } = alertCall() + expect(title).toBe(en.signOutQuestion) + expect(message).toBe(en.signOutExplanation) + expect(buttons.map((button) => button.text)).toEqual([en.cancel, en.signOut]) + expect(signOut).not.toHaveBeenCalled() + }) + + it('signs out once the user confirms the normal variant', async () => { + const { result } = renderHook(() => useSignOutGuard(signedInAuth), { wrapper }) + + await act(async () => { + await result.current?.() + }) + pressAlertButton(en.signOut) + + expect(signOut).toHaveBeenCalledTimes(1) + }) + + it('keeps the user signed in when they cancel', async () => { + const { result } = renderHook(() => useSignOutGuard(signedInAuth), { wrapper }) + + await act(async () => { + await result.current?.() + }) + pressAlertButton(en.cancel) + + expect(signOut).not.toHaveBeenCalled() + }) + + it('escalates when queued writes exist and signs out on confirm without discarding', async () => { + jest.spyOn(core, 'hasQueuedHighlightWrites').mockReturnValue(true) + const { result } = renderHook(() => useSignOutGuard(signedInAuth), { wrapper }) + + await act(async () => { + await result.current?.() + }) + + const { title, message, buttons } = alertCall() + expect(title).toBe(en.signOutPendingHighlightsQuestion) + expect(message).toBe(en.signOutPendingHighlightsExplanation) + expect(buttons.map((button) => button.text)).toEqual([ + en.cancel, + en.signOutPendingHighlightsConfirm, + ]) + expect(signOut).not.toHaveBeenCalled() + + pressAlertButton(en.signOutPendingHighlightsConfirm) + + expect(signOut).toHaveBeenCalledTimes(1) + expect(core.hasQueuedHighlightWrites).toHaveBeenCalledWith(USER_ID) + }) + + it('logs rejecting signOut from the native confirm button without throwing', async () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) + const rejectingSignOut = jest.fn(async () => { + throw new Error('sign-out failed') + }) + const { result } = renderHook( + () => + useSignOutGuard({ + signOut: rejectingSignOut, + isAuthenticated: true, + userInfo: { id: USER_ID }, + }), + { wrapper }, + ) + + await act(async () => { + await result.current?.() + }) + pressAlertButton(en.signOut) + + await act(async () => { + await Promise.resolve() + }) + + expect(rejectingSignOut).toHaveBeenCalledTimes(1) + expect(consoleError).toHaveBeenCalledWith(expect.any(Error)) + + consoleError.mockRestore() + }) + + it('asks the queue about the signed-in user id', async () => { + const hasQueued = jest.spyOn(core, 'hasQueuedHighlightWrites').mockReturnValue(false) + const { result } = renderHook(() => useSignOutGuard(signedInAuth), { wrapper }) + + await act(async () => { + await result.current?.() + }) + + expect(hasQueued).toHaveBeenCalledWith(USER_ID) + }) + + describe('web', () => { + const originalOs = Platform.OS + + afterEach(() => { + Object.defineProperty(Platform, 'OS', { + configurable: true, + enumerable: true, + value: originalOs, + }) + }) + + it('signs out immediately without raising Alert', async () => { + Object.defineProperty(Platform, 'OS', { + configurable: true, + enumerable: true, + value: 'web', + }) + const { result } = renderHook(() => useSignOutGuard(signedInAuth), { wrapper }) + + await act(async () => { + await result.current?.() + }) + + expect(Alert.alert).not.toHaveBeenCalled() + expect(signOut).toHaveBeenCalledTimes(1) + }) + + it('logs rejecting signOut without throwing', async () => { + Object.defineProperty(Platform, 'OS', { + configurable: true, + enumerable: true, + value: 'web', + }) + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) + const rejectingSignOut = jest.fn(async () => { + throw new Error('sign-out failed') + }) + const { result } = renderHook( + () => + useSignOutGuard({ + signOut: rejectingSignOut, + isAuthenticated: true, + userInfo: { id: USER_ID }, + }), + { wrapper }, + ) + + await act(async () => { + await expect(result.current?.()).resolves.toBeUndefined() + }) + + expect(rejectingSignOut).toHaveBeenCalledTimes(1) + expect(consoleError).toHaveBeenCalledWith(expect.any(Error)) + + consoleError.mockRestore() + }) + }) +}) diff --git a/packages/ui/src/native/__tests__/youversion-auth-button.test.tsx b/packages/ui/src/native/__tests__/youversion-auth-button.test.tsx index 2e22e30..0acdfa3 100644 --- a/packages/ui/src/native/__tests__/youversion-auth-button.test.tsx +++ b/packages/ui/src/native/__tests__/youversion-auth-button.test.tsx @@ -1,23 +1,16 @@ -import type { ComponentProps, ReactNode } from 'react' +import type { ComponentProps } from 'react' import { render, screen, userEvent } from '@testing-library/react-native' +import * as core from '@youversion/platform-react-native-expo-core' +import { Alert, Platform } from 'react-native' +import en from '../../i18n/locales/en.json' import { YouVersionAuthButton } from '../youversion-auth-button' import { YouVersionProvider } from '../youversion-provider' -const mockSignIn = jest.fn() -const mockSignOut = jest.fn() +const mockSignIn = jest.fn(async () => undefined) +const mockSignOut = jest.fn(async () => undefined) let mockIsAuthenticated = false -jest.mock('@youversion/platform-react-native-expo-core', () => ({ - YouVersionProvider: ({ children }: { children: ReactNode }) => children, - useYVAuth: () => ({ - isAuthenticated: mockIsAuthenticated, - signIn: mockSignIn, - signOut: mockSignOut, - }), -})) - -// The SVG logo pulls in react-native-svg; stub it to a plain view. jest.mock('../bible-app-logo', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { View } = require('react-native') @@ -38,8 +31,45 @@ beforeEach(() => { mockSignIn.mockClear() mockSignOut.mockClear() mockIsAuthenticated = false + jest.spyOn(Alert, 'alert').mockImplementation(() => undefined) + jest.spyOn(core, 'hasQueuedHighlightWrites').mockReturnValue(false) + jest.spyOn(core, 'useYVAuth').mockImplementation(() => ({ + isAuthenticated: mockIsAuthenticated, + signIn: mockSignIn, + signOut: mockSignOut, + userInfo: mockIsAuthenticated ? { id: 'user-1' } : null, + accessToken: mockIsAuthenticated ? 'token' : null, + error: null, + refreshNow: jest.fn(async () => undefined), + ensureFreshToken: jest.fn(async () => undefined), + getAccessToken: jest.fn(async () => + mockIsAuthenticated + ? ({ status: 'ok', token: 'token', userId: 'user-1' } as const) + : ({ status: 'unavailable', reason: 'signed-out' } as const), + ), + isLoading: false, + requestedPermissions: [], + grantedPermissions: null, + hasPermission: () => false, + invalidatePermissions: jest.fn(), + requestPermissions: jest.fn(async () => ({ status: 'cancel' }) as const), + })) +}) + +afterEach(() => { + jest.restoreAllMocks() }) +type AlertButton = { text?: string; onPress?: () => void } + +function pressAlertButton(text: string) { + const call = (Alert.alert as jest.Mock).mock.calls.at(-1) + const buttons = call?.[2] as AlertButton[] | undefined + const button = buttons?.find((candidate) => candidate.text === text) + expect(button).toBeTruthy() + button?.onPress?.() +} + describe('YouVersionAuthButton labels', () => { it('shows "Sign in with YouVersion" when unauthenticated (mode=auto)', () => { renderAuthButton() @@ -112,6 +142,16 @@ describe('YouVersionAuthButton labels', () => { }) describe('YouVersionAuthButton press behavior', () => { + const originalOs = Platform.OS + + afterEach(() => { + Object.defineProperty(Platform, 'OS', { + configurable: true, + enumerable: true, + value: originalOs, + }) + }) + it('calls signIn when pressed unauthenticated (mode=auto)', async () => { const user = userEvent.setup() renderAuthButton() @@ -120,17 +160,46 @@ describe('YouVersionAuthButton press behavior', () => { expect(mockSignIn).toHaveBeenCalledTimes(1) expect(mockSignOut).not.toHaveBeenCalled() + expect(Alert.alert).not.toHaveBeenCalled() + }) + + it('asks before signing out when authenticated (mode=auto)', async () => { + mockIsAuthenticated = true + const user = userEvent.setup() + renderAuthButton() + + await user.press(screen.getByText(/sign out of/i)) + + expect(Alert.alert).toHaveBeenCalledTimes(1) + expect(mockSignOut).not.toHaveBeenCalled() }) - it('calls signOut when pressed authenticated (mode=auto)', async () => { + it('signs out once the user confirms the guarded alert', async () => { mockIsAuthenticated = true const user = userEvent.setup() renderAuthButton() await user.press(screen.getByText(/sign out of/i)) + pressAlertButton(en.signOut) + + expect(mockSignOut).toHaveBeenCalledTimes(1) + }) + + it('escalates the alert when queued writes exist and signs out on confirm only', async () => { + mockIsAuthenticated = true + jest.spyOn(core, 'hasQueuedHighlightWrites').mockReturnValue(true) + const user = userEvent.setup() + renderAuthButton() + + await user.press(screen.getByText(/sign out of/i)) + + const call = (Alert.alert as jest.Mock).mock.calls[0] + expect(call?.[0]).toBe(en.signOutPendingHighlightsQuestion) + expect(mockSignOut).not.toHaveBeenCalled() + + pressAlertButton(en.signOutPendingHighlightsConfirm) expect(mockSignOut).toHaveBeenCalledTimes(1) - expect(mockSignIn).not.toHaveBeenCalled() }) it('calls signIn when mode="signIn" and unauthenticated', async () => { @@ -154,14 +223,30 @@ describe('YouVersionAuthButton press behavior', () => { expect(mockSignOut).not.toHaveBeenCalled() }) - it('calls signOut when mode="signOut" and unauthenticated', async () => { + it('calls signOut without Alert when mode="signOut" and unauthenticated', async () => { const user = userEvent.setup() renderAuthButton({ mode: 'signOut' }) await user.press(screen.getByText(/sign out of/i)) + expect(Alert.alert).not.toHaveBeenCalled() + expect(mockSignOut).toHaveBeenCalledTimes(1) + }) + + it('signs out immediately on web without raising Alert', async () => { + Object.defineProperty(Platform, 'OS', { + configurable: true, + enumerable: true, + value: 'web', + }) + mockIsAuthenticated = true + const user = userEvent.setup() + renderAuthButton() + + await user.press(screen.getByText(/sign out of/i)) + + expect(Alert.alert).not.toHaveBeenCalled() expect(mockSignOut).toHaveBeenCalledTimes(1) - expect(mockSignIn).not.toHaveBeenCalled() }) it('calls signOut when mode="signOut" and authenticated', async () => { @@ -170,6 +255,7 @@ describe('YouVersionAuthButton press behavior', () => { renderAuthButton({ mode: 'signOut' }) await user.press(screen.getByText(/sign out of/i)) + pressAlertButton(en.signOut) expect(mockSignOut).toHaveBeenCalledTimes(1) expect(mockSignIn).not.toHaveBeenCalled() diff --git a/packages/ui/src/native/bible-reader.tsx b/packages/ui/src/native/bible-reader.tsx index 12ac881..e70dff1 100644 --- a/packages/ui/src/native/bible-reader.tsx +++ b/packages/ui/src/native/bible-reader.tsx @@ -1,7 +1,6 @@ import { useControllableState } from '@radix-ui/react-use-controllable-state' import { deriveServerColors, - hasQueuedHighlightWrites, useHighlightPermissionFlow, useYouVersion, useYVAuthOptional, @@ -17,19 +16,23 @@ import type { } from '@youversion/platform-react-ui' import * as Clipboard from 'expo-clipboard' import * as WebBrowser from 'expo-web-browser' -import { useCallback, useMemo, useRef, useState } from 'react' -import { Alert, Platform, Share, StyleSheet, View } from 'react-native' +import type { Ref } from 'react' +import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { Platform, Share, StyleSheet, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useShallow } from 'zustand/react/shallow' import type { BibleReaderProps as DomBibleReaderProps } from '../dom/bible-reader' import BibleReaderDOM from '../dom/bible-reader' import FootnoteContent from '../dom/footnote-content' import { useTheme } from '../hooks/use-theme' -import { useSdkTranslation } from '../i18n/use-sdk-translation' import { DEFAULT_BIBLE_VERSION_ID } from '../lib/constants' import { withSheetDomDefaults } from '../lib/embed-dom-props' import { encodeFontFamilyForDom } from '../lib/reader-fonts' import { computeReaderBottomScrollPadding } from '../lib/reader-bottom-scroll-padding' +import { + reportHighlightWriteError, + type HighlightWriteError, +} from '../lib/report-highlight-write-error' import { resolveVerseActions } from '../lib/resolve-verse-actions' import { buildVerseActionSwatches, type VerseActionSwatch } from '../lib/verse-action-swatches' import { useReaderLocationStore } from '../stores/reader-location-store' @@ -41,6 +44,7 @@ import { BibleVersionPickerSheet } from './bible-version-picker-sheet' import { HighlightConsentSheet } from './highlight-consent-sheet' import { NativeSheet } from './native-sheet' import { SignInWithYouVersionSheet } from './sign-in-with-youversion-sheet' +import { useSignOutGuard } from './use-sign-out-guard' const EMPTY_FOOTNOTE: FootnoteData = { verseNum: '', @@ -86,6 +90,27 @@ function sameScope(a: HighlightScope, b: HighlightScope): boolean { * `@youversion/platform-react-ui` directly. */ export type { BibleReaderShareData, BibleReaderVerseSelection } from '@youversion/platform-react-ui' +export type { HighlightWriteError } from '../lib/report-highlight-write-error' + +/** + * The imperative surface of `BibleReader`, reached through a `ref`. + * + * ```tsx + * const reader = useRef(null) + * useFocusEffect(useCallback(() => { void reader.current?.refreshHighlights() }, [])) + * + * ``` + */ +export type BibleReaderHandle = { + /** + * Re-fetch the highlights for the chapter on screen, picking up anything + * created on another device or in the YouVersion app. + * + * Safe to call at any time: it de-dupes against a fetch already in flight, + * no-ops when signed out, and never clears what is already painted. + */ + refreshHighlights: () => Promise +} export type BibleReaderProps = Omit< DomBibleReaderProps, @@ -134,6 +159,17 @@ export type BibleReaderProps = Omit< onCopy?: (data: BibleReaderShareData) => void | Promise /** Share's counterpart to {@link BibleReaderProps.onCopy}. Falls back to RN's `Share.share`. */ onShare?: (data: BibleReaderShareData) => void | Promise + /** + * A highlight has not reached the server yet — queued and retrying, or a + * transient failure the write queue will keep retrying. The paint stays on + * screen; render an offline or pending hint rather than an error toast. + */ + onHighlightError?: (error: HighlightWriteError) => void + /** + * Imperative handle — see {@link BibleReaderHandle}. React 19 passes `ref` + * as an ordinary prop, so there is no `forwardRef` here. + */ + ref?: Ref } export function BibleReader({ @@ -161,18 +197,19 @@ export function BibleReader({ clearSelectionSignal = 0, onCopy: consumerOnCopy, onShare: consumerOnShare, + onHighlightError, backgroundColor, foregroundColor, dom, + ref, }: BibleReaderProps) { const context = useYouVersion() const auth = useYVAuthOptional() const accessToken = auth?.accessToken ?? null const userInfo = auth?.userInfo ?? null const signIn = auth?.signIn - const signOut = auth?.signOut + const guardedSignOut = useSignOutGuard(auth) const resolvedTheme = useTheme(theme) - const { t } = useSdkTranslation() const { setFontFamily, setFontSize, setLineSpacing, fontSize, fontFamily, lineSpacing } = useReaderSettingsStore() @@ -225,8 +262,11 @@ export function BibleReader({ highlights, scope: highlightScope, remove: removeHighlight, + refresh: refreshHighlights, } = highlightPermissionFlow.highlights + useImperativeHandle(ref, () => ({ refreshHighlights }), [refreshHighlights]) + const [footnoteData, setFootnoteData] = useState(null) // footnoteData can remain non-null across repeated taps, so track each tap as an open event. const [footnoteOpenKey, setFootnoteOpenKey] = useState(0) @@ -309,7 +349,9 @@ export function BibleReader({ // `remove` goes straight to the unguarded write: a user looking at a // highlight already has the permissions it needs (ADR 0016). if (swatch.state === 'remove') { - void removeHighlight(swatch.color, verses) + void removeHighlight(swatch.color, verses).then((outcome) => + reportHighlightWriteError(outcome, onHighlightError), + ) return } if (needsSignIn) { @@ -325,7 +367,9 @@ export function BibleReader({ } // Fire-and-forget: the paint is optimistic inside `useHighlights`, so the // verse changes color on this frame instead of after the round-trip. - void applyHighlight(swatch.color, verses) + void applyHighlight(swatch.color, verses).then((outcome) => + reportHighlightWriteError(outcome, onHighlightError), + ) }, [ verseSelection, @@ -333,6 +377,7 @@ export function BibleReader({ removeHighlight, applyHighlight, needsSignIn, + onHighlightError, versionId, book, chapter, @@ -350,8 +395,10 @@ export function BibleReader({ // controlled location change must not hand verse numbers to the current // location-scoped flow. if (!sameScope(pending.scope, { versionId, book, chapter })) return - void applyHighlight(pending.color, pending.verses) - }, [applyHighlight, versionId, book, chapter]) + void applyHighlight(pending.color, pending.verses).then((outcome) => + reportHighlightWriteError(outcome, onHighlightError), + ) + }, [applyHighlight, onHighlightError, versionId, book, chapter]) // "No Thanks", a swipe-down, a backdrop tap, and displacement all land here. // Every one discards the intent, and nothing is written. @@ -467,29 +514,6 @@ export function BibleReader({ if (data) void handleShare(data) }, [verseSelection, handleShare, closeVerseActions]) - // `async` with no `await` on purpose: the DOM wrapper types `onSignOutPress` - // as `() => Promise`, so a plain `() => void` handler fails typecheck. - const handleSignOutPress = useCallback(async () => { - if (!signOut) return - - const hasUnsentHighlights = hasQueuedHighlightWrites(userInfo?.id ?? null) - - Alert.alert( - hasUnsentHighlights ? t('signOutPendingHighlightsQuestion') : t('signOutQuestion'), - hasUnsentHighlights ? t('signOutPendingHighlightsExplanation') : t('signOutExplanation'), - [ - { text: t('cancel'), style: 'cancel' }, - { - text: hasUnsentHighlights ? t('signOutPendingHighlightsConfirm') : t('signOut'), - style: 'destructive', - onPress: () => { - void signOut() - }, - }, - ], - ) - }, [signOut, userInfo?.id, t]) - const onExternalLinkPress = useCallback(async (url: string) => { try { await WebBrowser.openBrowserAsync(url, { @@ -540,8 +564,7 @@ export function BibleReader({ onVerseSelect={handleVerseSelect} clearSelectionSignal={clearSelectionSignal + internalClearCount} onSignInPress={signIn} - // `Alert.alert` is a no-op on react-native-web, so web signs out unprompted. - onSignOutPress={Platform.OS === 'web' || !signOut ? signOut : handleSignOutPress} + onSignOutPress={guardedSignOut} userInfo={userInfo} theme={resolvedTheme} book={book} diff --git a/packages/ui/src/native/index.ts b/packages/ui/src/native/index.ts index 30283b3..0e87349 100644 --- a/packages/ui/src/native/index.ts +++ b/packages/ui/src/native/index.ts @@ -4,9 +4,11 @@ export { BibleChapterPickerSheet } from './bible-chapter-picker-sheet' export type { BibleChapterPickerSheetProps } from './bible-chapter-picker-sheet' export { BibleReader } from './bible-reader' export type { + BibleReaderHandle, BibleReaderProps, BibleReaderShareData, BibleReaderVerseSelection, + HighlightWriteError, } from './bible-reader' export { BibleReaderSettingsSheet } from './bible-reader-settings-sheet' export type { BibleReaderSettingsSheetProps } from './bible-reader-settings-sheet' @@ -20,3 +22,5 @@ export { YouVersionAuthButton } from './youversion-auth-button' export type { YouVersionAuthButtonProps } from './youversion-auth-button' export { YouVersionProvider } from './youversion-provider' export type { YouVersionProviderProps, YouVersionTheme } from './youversion-provider' +export { useSignOutGuard } from './use-sign-out-guard' +export type { SignOutGuardAuth } from './use-sign-out-guard' diff --git a/packages/ui/src/native/use-sign-out-guard.ts b/packages/ui/src/native/use-sign-out-guard.ts new file mode 100644 index 0000000..9c9bc33 --- /dev/null +++ b/packages/ui/src/native/use-sign-out-guard.ts @@ -0,0 +1,80 @@ +import { hasQueuedHighlightWrites } from '@youversion/platform-react-native-expo-core' +import { useCallback } from 'react' +import { Alert, Platform } from 'react-native' + +import { useSdkTranslation } from '../i18n/use-sdk-translation' + +/** + * The slice of auth context the guard needs. Deliberately structural rather than + * `AuthContextValue`: the reader reaches auth through `useYVAuthOptional()` and + * may have none at all, while the button uses `useYVAuth()`. + */ +export type SignOutGuardAuth = { + signOut: () => Promise + isAuthenticated?: boolean + userInfo?: { id?: string | null } | null +} | null + +/** + * Wraps `signOut()` in the native confirmation the reader toolbar already raised + * before extraction. Every SDK-owned sign-out surface routes through this so the + * warning cannot be true on one button and missing on another. + * + * When the Highlight Write Queue still holds unsent work, the copy escalates; on + * confirm the guard calls `signOut()` only — core's `clearAuthState` clears the + * queue and cache. Cancelling leaves the user signed in and the queue intact. + * + * On web, `Alert.alert` is a no-op, so the guard calls `signOut()` directly. + * + * When auth is configured but `isAuthenticated` is false (or has not caught up with + * a stored session), the guard still runs `signOut()` to clear leftover credentials + * — no Alert is shown. The Alert runs only when the user is authenticated. + * + * Returns `undefined` when auth is not configured (`signOut` is missing), so callers + * can pass the result straight through to an optional handler prop. + */ +export function useSignOutGuard(auth: SignOutGuardAuth): (() => Promise) | undefined { + const { t } = useSdkTranslation() + const signOut = auth?.signOut + const isAuthenticated = auth?.isAuthenticated ?? false + const userId = auth?.userInfo?.id ?? null + + const guardedSignOut = useCallback(async () => { + if (signOut === undefined) { + return + } + + if (!isAuthenticated) { + await signOut().catch((err) => console.error(err)) + return + } + + if (Platform.OS === 'web') { + await signOut().catch((err) => console.error(err)) + return + } + + const hasUnsentHighlights = hasQueuedHighlightWrites(userId) + + Alert.alert( + hasUnsentHighlights ? t('signOutPendingHighlightsQuestion') : t('signOutQuestion'), + hasUnsentHighlights ? t('signOutPendingHighlightsExplanation') : t('signOutExplanation'), + [ + { text: t('cancel'), style: 'cancel' }, + { + text: hasUnsentHighlights ? t('signOutPendingHighlightsConfirm') : t('signOut'), + style: 'destructive', + onPress: () => { + void signOut().catch((err) => console.error(err)) + }, + }, + ], + ) + }, [signOut, isAuthenticated, userId, t]) + + if (signOut === undefined) { + return undefined + } + + return guardedSignOut +} diff --git a/packages/ui/src/native/youversion-auth-button.tsx b/packages/ui/src/native/youversion-auth-button.tsx index 2e1639a..c9c3a84 100644 --- a/packages/ui/src/native/youversion-auth-button.tsx +++ b/packages/ui/src/native/youversion-auth-button.tsx @@ -3,6 +3,7 @@ import { Pressable, StyleSheet, Text } from 'react-native' import { Trans } from 'react-i18next' import { useSdkTranslation } from '../i18n/use-sdk-translation' import { BibleAppLogo } from './bible-app-logo' +import { useSignOutGuard } from './use-sign-out-guard' export type YouVersionAuthButtonProps = { background?: 'light' | 'dark' @@ -22,15 +23,23 @@ export function YouVersionAuthButton({ size = 'default', text, }: YouVersionAuthButtonProps) { - const { isAuthenticated, signOut, signIn } = useYVAuth() + const auth = useYVAuth() + const { isAuthenticated, signIn } = auth + const guardedSignOut = useSignOutGuard(auth) const { t, i18n } = useSdkTranslation() const authFunction = async () => { try { if (mode === 'auto') { - await (isAuthenticated ? signOut() : signIn()) + if (isAuthenticated) { + await guardedSignOut?.() + } else { + await signIn() + } + } else if (mode === 'signIn') { + await signIn() } else { - await (mode === 'signIn' ? signIn() : signOut()) + await guardedSignOut?.() } } catch (error) { console.error(error)