Skip to content
Merged
16 changes: 16 additions & 0 deletions .changeset/ype-104-ui-consumer-api.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
cameronapak marked this conversation as resolved.
- **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.
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`)

Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
133 changes: 133 additions & 0 deletions packages/ui/src/lib/__tests__/report-highlight-write-error.test.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
Comment thread
cameronapak marked this conversation as resolved.
43 changes: 43 additions & 0 deletions packages/ui/src/lib/report-highlight-write-error.ts
Original file line number Diff line number Diff line change
@@ -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(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: This name only says that the function calls the handler.

The body isolates a throw from the consumer. A name that states that policy (for example callOnHighlightErrorSafely) is clearer.


Code Reviewer bot, sent on behalf of Cam.

onHighlightError: (error: HighlightWriteError) => void,
error: HighlightWriteError,
): void {
try {
void Promise.resolve(onHighlightError(error)).catch((err) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): This rejection branch is unreachable through the public type.

The prop is typed (error: HighlightWriteError) => void on both HighlightWriteError and BibleReaderProps:167, so a type-conforming caller can't return a promise — meaning neither this .catch nor the test added in 916b636 can fire in practice. Either widen the prop to void | Promise<void> (as onCopy/onShare already are) and keep the containment, or drop the wrapper and rely on the surrounding try/catch.

console.error('onHighlightError failed:', err)
})
} catch (err) {
console.error('onHighlightError failed:', err)
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

export function reportHighlightWriteError(
outcome: HighlightWriteOutcome,
onHighlightError?: (error: HighlightWriteError) => void,
): void {
if (onHighlightError === undefined) {
return
}
if (outcome.status === 'queued') {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Filter matches the lock — queued and error+transient only; ok/noop/invalid/auth/not-signed-in stay silent. Tests pin it.

Code Reviewer bot, sent on behalf of Cam.

invokeHighlightErrorHandler(onHighlightError, { status: 'queued', verses: outcome.verses })
return
}
if (outcome.status === 'error' && outcome.reason === 'transient') {
invokeHighlightErrorHandler(onHighlightError, {
status: 'error',
reason: 'transient',
verses: outcome.failedVerses,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): A partially-failed batch reports only its failures — verses: outcome.failedVerses, with succeededVerses dropped. Intended? The spec renames the type but says nothing about its shape, so flagging rather than asserting.

message: outcome.message,
})
}
}
Loading