diff --git a/.changeset/native-highlights-release.md b/.changeset/native-highlights-release.md index 82063ce..ae00a6a 100644 --- a/.changeset/native-highlights-release.md +++ b/.changeset/native-highlights-release.md @@ -23,7 +23,9 @@ npx expo install expo-network expo-clipboard expo-application `apply(color, verses)` and `remove(color, verses)` resolve a typed `HighlightWriteOutcome` — `ok`, `noop`, `queued`, or `error` with a `reason` of `not-signed-in` / `auth` / `invalid` / `transient`, plus `failedVerses` and `succeededVerses` so a partially applied batch is legible. Highlights come back as per-verse `Highlight[]`, ready for a controlled reader. `error` on the hook itself is fetch-only; writes report through the outcome they resolve to. -Also exported: `deriveServerColors` (projects the returned highlights to a verse → color map), `HIGHLIGHT_COLORS` and `isHighlightColor` (the five company-standard swatches — both write paths reject anything else), `refresh()` for pull-to-refresh, and the `Highlight` / `HighlightColor` / `HighlightScope` / `ServerColors` types. `isRefreshing` is named for "a GET is in flight" rather than `isLoading`, because `highlights` is always safe to render — gating a spinner on it would reintroduce the blank frame the cache exists to prevent. Mounted `useHighlights` subscriptions also refresh when the app becomes active. +Also exported: `deriveServerColors` (projects the returned highlights to a verse → color map), `HIGHLIGHT_COLORS` and `isHighlightColor` (the five company-standard swatches, which are what `apply` accepts — it rejects anything else as `invalid` before painting or issuing a request), `refresh()` for pull-to-refresh, and the `Highlight` / `HighlightColor` / `HighlightScope` / `ServerColors` types. + +The palette bounds what you can **create**, not what you can see or clear. A valid non-palette hex already on the account — made in the YouVersion app, or by another integration — paints normally, and `remove` clears by exact hex whether or not it is in the palette. Only an unparseable hex is dropped. `isRefreshing` is named for "a GET is in flight" rather than `isLoading`, because `highlights` is always safe to render — gating a spinner on it would reintroduce the blank frame the cache exists to prevent. Mounted `useHighlights` subscriptions also refresh when the app becomes active. The GET is gated on the app having **requested** the `highlights` permission (`auth.permissions` on `YouVersionProvider`). An app that renders a reader and never asked for highlights issues no highlights request at all. The gate reads the requested list, never a grant: a missing grant is indistinguishable from an unknown one, and treating unknown as denied would silently un-paint the highlights of users who signed in before grant reporting existed. @@ -60,7 +62,7 @@ Requires `auth` on `YouVersionProvider` and the `highlights` permission (a permi The auth context now reports which permissions the user granted, and can ask for one without signing out. -`useYVAuth()` adds `grantedPermissions`, `hasPermission()`, and `invalidatePermissions()`. `grantedPermissions` has three states: `null` means the app never requested permissions, `[]` means it requested them and the user denied, and a populated list means the user granted those. The SDK reads the grant from the OAuth app redirect, caches it per user in MMKV, loads it on cold start, and clears it on sign-out. `AuthPermission` is now an open union (`KnownAuthPermission | (string & {})`), so `AuthConfig.permissions` and `hasPermission()` accept a permission this SDK version does not know about, and the cache keeps every value the server returns rather than filtering. `requestedPermissions` carries the configured list alongside it — what was asked for, as against what came back. +`useYVAuth()` adds `grantedPermissions`, `hasPermission()`, and `invalidatePermissions()`. `grantedPermissions` has three states: `null` means the app never requested permissions, `[]` means it requested them and the user denied, and a populated list means the user granted those. The SDK reads the grant from the OAuth app redirect, caches it per user in MMKV, loads it on cold start, and clears it on sign-out. `AuthPermission` is now an open union — the permissions this SDK version knows about, plus any other string — so `AuthConfig.permissions` and `hasPermission()` accept a permission this SDK version does not know about, and the cache keeps every value the server returns rather than filtering. `requestedPermissions` carries the configured list alongside it — what was asked for, as against what came back. `requestPermissions(permissions)` lets a signed-in user grant a permission on the spot: it mints a data-exchange token, runs YouVersion's hosted consent page in an auth session, and merges what the user granted into the cache, so `hasPermission` answers true on the next render. It resolves a typed `DataExchangeOutcome` rather than throwing — `granted` (carrying the permissions the server actually reported, which may be fewer than were asked for), `cancel`, or `failure` with a `reason` of `not-signed-in`, `not-permitted` (this app key is not enabled for data exchange, deliberately distinct from a flaky network), `user-changed`, `in-progress` (another request holds the flow — wait for it rather than retrying straight away), or `transient`. The grant merges rather than replaces, so consenting to one permission never erases another; `cancel` and `failure` leave the cache untouched; and an initiator guard discards a grant that lands after the signed-in user changed, because a mis-attributed grant is invisible while a discarded one just re-prompts. The flow is permission-generic — nothing about it is specific to highlights. @@ -70,10 +72,9 @@ The cached grant is a hint for choosing UI and skipping redundant prompts. The s ## Tokens -Two additions to the auth context, both public: +One addition to the auth context: -- `ensureFreshToken()` — the leeway-gated refresh, cheap enough to await on every user gesture, unlike `refreshNow()` which always hits the token endpoint. -- `getAccessToken()` — the accessor that reports whether the refresh worked. It runs the same leeway-gated, single-flight refresh, then resolves an `AccessTokenResult`: `{ status: 'ok', token, userId }`, or `{ status: 'unavailable', reason: 'signed-out' | 'refresh-failed' }`. It never rejects, makes no network call when there is no refresh token to spend, and concurrent callers join one refresh. The `userId` is read in the same synchronous block as the token, so a caller holding an identity it captured earlier can tell whether the token it just got still belongs to that user — `userInfo` read from a render lags the token by a render on sign-in. +- `getAccessToken()` — the accessor that reports whether the refresh worked. It refreshes only when the token is at or near expiry, cheap enough to await on every user gesture unlike `refreshNow()` which always hits the token endpoint, then resolves an `AccessTokenResult`: `{ status: 'ok', token, userId }`, or `{ status: 'unavailable', reason: 'signed-out' | 'refresh-failed' }`. It never rejects, makes no network call when there is no refresh token to spend, and concurrent callers join one refresh. The `userId` is read in the same synchronous block as the token, so a caller holding an identity it captured earlier can tell whether the token it just got still belongs to that user — `userInfo` read from a render lags the token by a render on sign-in. `refresh-failed` leaves the tokens in storage: the session is intact and the user stays signed in. That matters because a token endpoint outage used to present to the user as a revoked permission. When the token was expired and the refresh failed for a reason that was not a revocation — a 5xx, a timeout, a captive portal — the write went out with the expired token anyway, came back 401, and the 401 read as a stale grant, so a valid `highlights` grant was invalidated and the user was asked to consent again; the re-consent minted with the same expired token and dead-ended as `not-permitted`. Both the highlights write path and `requestPermissions` now source their token from `getAccessToken()` and settle a `refresh-failed` as `transient` **without issuing the request**. @@ -96,15 +97,15 @@ Two new props carry selection across the bridge: `BibleReaderVerseSelection` and `BibleReaderShareData` are re-exported so a handler can be typed without depending on `@youversion/platform-react-ui` directly. -**The reader now asks before it signs anyone out**, matching the Swift SDK. Sign-out from the user menu raises a native alert instead of signing out on the spot; it is destructive here — it drops the access token, the cached user, the granted permissions, the highlights cache, and every highlight write still waiting — and the menu item sits one tap away from the reader. Two variants: an ordinary confirmation, or "Save your highlights?" when the queue still holds unsent work, which is what a user sees when a highlight was made offline and the drain has not landed it yet. All strings are localized through the SDK's own catalog. The confirmation is the reader's, and it is the only place the SDK offers sign-out — `YouVersionAuthButton` and `useYVAuth().signOut()` are unchanged and still sign out immediately, which is what a host app's own confirmation flow needs. Core exports `hasQueuedHighlightWrites(userId)` for the variant choice; it reads the write queue directly and never throws, so an unreadable store answers "nothing to lose" rather than breaking the gesture that raises the prompt. +**The SDK now asks before it signs anyone out**, matching the Swift SDK. Sign-out from the reader's user menu — and from `YouVersionAuthButton` — raises a native alert instead of signing out on the spot; it is destructive here, dropping the access token, the cached user, the granted permissions, the highlights cache, and every highlight write still waiting. Two variants: an ordinary confirmation, or "Save your highlights?" when the queue still holds unsent work, which is what a user sees when a highlight was made offline and the drain has not landed it yet. All strings are localized through the SDK's own catalog. Confirming calls `signOut()` and nothing more — core clears the queue and the caches. `useYVAuth().signOut()` is unchanged and still signs out immediately, which is what a host app's own confirmation flow needs; `useSignOutGuard` is exported from the UI package for apps that want the same prompt on their own sign-out UI. Core exports `hasQueuedHighlightWrites(userId)` for the variant choice; it reads the write queue directly and never throws, so an unreadable store answers "nothing to lose" rather than breaking the gesture that raises the prompt. -**Web.** Native verse actions and the sign-out confirmation are not available on web in this release. `NativeSheet` renders nothing there, so suppressing the popover would leave the reader with no verse action UI at all — the Web SDK popover is what web gets. React Native Web's `Alert.alert` is a no-op, so web signs out unprompted rather than leaving the menu item doing nothing. +**Web.** Native verse actions and the sign-out confirmation are not available on web in this release. `NativeSheet` renders nothing there, so suppressing the popover would leave the reader with no verse action UI at all — the Web SDK popover is what web gets. React Native Web's `Alert.alert` is a no-op, so both the reader's menu item and `YouVersionAuthButton` sign out unprompted on web rather than doing nothing at all. ## Fixes - **A token refresh already in flight was skipped rather than joined.** `refreshToken` tracked its in-flight request with a boolean, so a second caller returned immediately, resolving on the very token the refresh existed to replace. The common trigger is ordinary: the app comes to the foreground, the `AppState` listener starts a refresh, and the user acts a moment later — anything auth-sensitive in that window read the expired token and got a 401. It now holds the request as a promise and hands it to the second caller. - **`signOut()` rejected on a device store that refuses writes.** Clearing the session ends by saving null tokens, and that save wrote the cached token expiry unguarded, so a storage failure threw after the in-memory session and the stored tokens were already gone — the caller saw a rejected promise for a sign-out that had completed. The expiry is a cache over the tokens, which are the record, so it can no longer fail the save; a lost expiry costs one token refresh, because a missing one already reads as expired. The same failure leaves the cached user info readable, and the next launch seeds it back before auth settles; the tokens live in a different store and their removal takes, so the launch finds no refresh token and clears the identity regardless. `isAuthenticated` and `isLoading` remain the signals to gate on. -- **`refreshToken` is now total.** Its revocation branch awaited `clearAuthState()`, which ends in a Keychain delete that can reject; that rejection escaped through `ensureFreshToken`, `getAccessToken`, and `requestPermissions`, all three documented never to throw. Clearing is now best-effort, matching the retention policy everywhere else. +- **`refreshToken` is now total.** Its revocation branch awaited `clearAuthState()`, which ends in a Keychain delete that can reject; that rejection escaped through `getAccessToken` and `requestPermissions`, both documented never to throw. Clearing is now best-effort, matching the retention policy everywhere else. - **The verse action sheet's swatch tray did not scroll on Android**, making hidden swatches unreachable by touch. Six fit the tray, and a selection spanning two existing highlight colors already produces seven. `@gorhom/bottom-sheet` builds its pan gesture with no activation criteria, so `react-native-gesture-handler` fell back to a direction-agnostic touch slop: a sideways drag activated the sheet's pan, which cancels the touch stream in every native view underneath it. The sheet now constrains that pan to vertical intent. Swipe-down dismissal is unchanged. - Localization synced from platform-localization (ace9bbd). diff --git a/AGENTS.md b/AGENTS.md index 52d6866..ef52221 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,8 @@ Keep `GestureHandlerRootView` outside `YouVersionProvider`; bottom-sheet gesture `BibleReader` also owns highlight data: it subscribes `useHighlights` for its current `versionId` / `book` / `chapter` and feeds the result into its DOM wrapper's **required** `highlights` prop. Presence of that prop latches the Web SDK reader into controlled mode, which is what keeps the highlight path out of the WebView entirely (no network, no store, no auth surface). The latch is read once, at first mount (`useRef(highlights !== undefined)`); afterwards the SDK reads `highlights ?? []`, so a later drop un-paints rather than re-opening self-contained mode. The rule is therefore: **defined on the very first render, and never flipped after** — the DOM wrapper coerces a non-array to `[]` as a backstop, since the first render is unrecoverable and the failure is silent. It is omitted from the native props type so consumers cannot supply it. The DOM wrapper's `verseActions` prop is **required**, and it comes from `resolveVerseActions(Platform.OS)`. On iOS and Android it is `'none'`, because `BibleVerseActionSheet` replaces the in-WebView popover. On web it is `'popover'`, because `NativeSheet` renders nothing there and suppressing the popover would leave no verse action UI at all. Consumers get no say either way. `onVerseSelect` and `clearSelectionSignal` are the public native surface: the first reports every selection change, clears included, and the second dismisses one from native. +`BibleReader`'s two consumer-facing highlight surfaces are `ref` and `onHighlightError`. The `ref` is a `BibleReaderHandle` — currently just `refreshHighlights()`, `useHighlights`'s own `refresh` forwarded through `useImperativeHandle`, for a screen that regains focus and wants to pick up highlights made on another device. It de-dupes against a fetch in flight, no-ops signed out, and never clears what is painted, so it is safe to call from a `useFocusEffect` on every focus. `onHighlightError` reports through `lib/report-highlight-write-error.ts`, and the type it hands back (`HighlightWriteError`) is a deliberately **narrow slice** of `HighlightWriteOutcome`: only `queued` and `error` + `reason: 'transient'` fire. The rest are not the consumer's problem — `ok` and `noop` are silent successes, `invalid` is an SDK bug, and `not-signed-in` / `auth` are already the permission flow's job (it prompts; a toast on top would double up). `queued` is reported as information, not failure: the paint stands and the drain owes the server the write, so the copy is "saved on the device", never "failed". A throwing handler is caught and logged rather than taking the write down with it. + `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` 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. @@ -111,6 +113,7 @@ Read [ADR 0017](docs/adr/0017-native-verse-action-sheet.md) before you change an - It is the only `modal={false}` sheet. A backdrop intercepts the second verse tap that extends a selection. An `opacity: 0` backdrop does not help, because Gorhom overwrites `pointerEvents` to `'auto'` on open. There is therefore no tap-outside dismissal, by design. - It is also the only sheet passing `panActiveOffsetY`. Without it the swatch tray does not scroll on Android at all. Gorhom's pan has no activation criteria, so RNGH falls back to a direction-agnostic touch slop, the sheet claims the sideways drag, and the tray's `ScrollView` has its touches cancelled. **Do not "simplify" this to `enableContentPanningGesture={false}`.** Device-tested, that scrolls the tray but kills swipe-down, which is this sheet's only backdrop-free exit. - The swatch rule lives in `lib/verse-action-swatches.ts` (layer 1). It follows the same product rules as the web YPE-4494 tray (web #330 / `buildVerseActionSwatches`). **Remove row:** ANY rule — palette and valid non-palette hex at exact value; invalid hex dropped. **Apply row:** palette-only. Swift and Kotlin agree on the remove list. A partially-covering color appearing in both rows is intended. +- The tray's edge fades are gated by `lib/verse-action-fade-gates.ts` (layer 1). `swatchTrayFadeGates({ trayWidth, contentWidth, scrollX })` answers both ends from one triple — leading on distance scrolled, trailing on distance remaining — with `FADE_GATE_PX` of slack so a fade retires at the edge it guards instead of staying up for sub-pixel overflow. The three measurements travel together on purpose; deriving either fade from one of them alone strands it on. - `onCopy` and `onShare` are native-only props on `BibleReader`. They fall back to `expo-clipboard` and RN `Share`. `shareData` rides in on `onVerseSelect`, so neither button costs a round-trip into the WebView. - The sheet is gated on `selection !== null && prompt === 'none' && !flow.isConfirming`, so it never competes with the sign-in or consent sheet. Displacement would call its `onClose`, which clears the selection a **Pending Highlight** is waiting on. - Swatch presses route through core's `useHighlightPermissionFlow` for `apply`, and straight to `remove`. The reader adds only a sign-in prompt in front of the flow, because the flow calls `signIn()` with no UI of its own. That gate reads `auth !== null && !auth.isAuthenticated`. A `null` auth means the consumer configured none at all, which is not the same as signed out and must not raise a prompt. @@ -168,9 +171,9 @@ 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 `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` +**UI** (`@youversion/platform-react-native-expo-ui`): `YouVersionProvider`, `BibleCard`, `BibleChapterPickerSheet`, `BibleReader`, `BibleReaderSettingsSheet`, `BibleTextView`, `BibleVersionPickerSheet`, `VerseOfTheDay`, and `YouVersionAuthButton`, plus `useSignOutGuard`, and types `BibleReaderHandle`, `HighlightWriteError`, 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`) +**Core** (`@youversion/platform-react-native-expo-core`): `YouVersionProvider` (installation id + optional auth), `useYouVersion`, `useYVAuth` (its value carries `requestedPermissions` / `grantedPermissions` / `hasPermission` / `invalidatePermissions` / `requestPermissions` / `getAccessToken` alongside the sign-in surface), `useYVAuthOptional` (same value, `null` instead of a throw when `auth` is unconfigured — what SDK-internal callers that must work either way use), `useHighlights`, `useHighlightPermissionFlow`, `deriveServerColors`, `hasQueuedHighlightWrites`, `HIGHLIGHT_COLORS` / `isHighlightColor`, `mmkvStorage`, auth types (`AccessTokenResult`, `AuthConfig`, `AuthPermission`, `AuthScope`, `DataExchangeOutcome`, `DataExchangeFailureReason`, `YVUserInfo`), and highlights types (`Highlight`, `HighlightColor`, `HighlightScope`, `ServerColors`, `HighlightWriteOutcome` (`ok` / `queued` / `noop` / `error`), `HighlightWriteReason`, `HighlightsFetchError`, `UseHighlightsOptions`, `UseHighlightsResult`, `UseHighlightPermissionFlowResult`, `PermissionFlowError`, `PermissionFlowErrorReason`) UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider`. Import Bible components from UI; import `useYVAuth` from core. @@ -196,8 +199,8 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider - `useYVAuth()` throws if `auth` was not configured on the provider. - `YouVersionAuthButton` (UI package) is the drop-in sign-in/sign-out button built on `useYVAuth`; use it for standard sign-in UI instead of hand-rolling a button. - Tokens in `expo-secure-store`; expiry and cached user info in MMKV (`packages/core/src/storage/`). -- `refreshNow()` always hits the token endpoint. `ensureFreshToken()` is the leeway-gated refresh, cheap enough to await on every user gesture, and the one a permission-sensitive pre-flight should use. Both are **single-flight by promise**: a second caller joins the in-flight refresh rather than returning early on the token that refresh exists to replace. Do not put that back to a boolean flag — the app foregrounding starts a refresh, and a tap a moment later would read the stale token and 401. -- `getAccessToken()` is the accessor that **reports whether the refresh worked**: it runs the same leeway-gated single-flight refresh as `ensureFreshToken()` and resolves an `AccessTokenResult` — `{ status: 'ok', token, userId }`, or `{ status: 'unavailable', reason: 'signed-out' | 'refresh-failed' }`. The `userId` rides along because it is read in the **same synchronous block** as the token: the provider writes both refs together on sign-in, while a `userInfo` read through React state lags by a render, so a caller guarding on a captured identity must compare against this one or it will pass a check it should have failed. It never rejects, and `refresh-failed` keeps tokens in storage (session intact — a transient token-endpoint failure, not a sign-out). The highlights write path sources its token from it and settles `refresh-failed` as a `transient` outcome **without issuing the request** — before it existed, an expired token rode out to the API, 401'd, classified as `auth`, and `useHighlightPermissionFlow` misread that as a stale grant (invalidating it and re-prompting consent). `ensureFreshToken()` remains for callers that only want the side effect. +- `refreshNow()` always hits the token endpoint. `getAccessToken()` is the leeway-gated refresh, cheap enough to await on every user gesture, and the one a permission-sensitive pre-flight should use. Both are **single-flight by promise**: a second caller joins the in-flight refresh rather than returning early on the token that refresh exists to replace. Do not put that back to a boolean flag — the app foregrounding starts a refresh, and a tap a moment later would read the stale token and 401. +- `getAccessToken()` is the accessor that **reports whether the refresh worked**: it runs the leeway-gated single-flight refresh described above and resolves an `AccessTokenResult` — `{ status: 'ok', token, userId }`, or `{ status: 'unavailable', reason: 'signed-out' | 'refresh-failed' }`. The `userId` rides along because it is read in the **same synchronous block** as the token: the provider writes both refs together on sign-in, while a `userInfo` read through React state lags by a render, so a caller guarding on a captured identity must compare against this one or it will pass a check it should have failed. It never rejects, and `refresh-failed` keeps tokens in storage (session intact — a transient token-endpoint failure, not a sign-out). The highlights write path sources its token from it and settles `refresh-failed` as a `transient` outcome **without issuing the request** — before it existed, an expired token rode out to the API, 401'd, classified as `auth`, and `useHighlightPermissionFlow` misread that as a stale grant (invalidating it and re-prompting consent). - OAuth browser session via `expo-web-browser`; redirect handling is app-owned (example: `apps/example/app/callback.tsx` + `Linking.createURL('callback')`). - Register the same `redirectUri` in the YouVersion Platform console as used in app code. @@ -225,7 +228,7 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider - `useHighlightPermissionFlow({ versionId, book, chapter })` wraps `useHighlights` and guards **only `apply`** behind whatever the user is missing — sign-in, the `highlights` permission, or both. `remove` and everything else pass through untouched (a user with visible highlights already has the grant). It returns the whole `useHighlights` result plus `isConfirming` / `confirm()` / `decline()` / `flowError`. - The branch point, the exactly-once re-prompt bound, the in-memory pending highlight, and the choice of a hand-rolled reducer over `xstate` are all decided in [ADR 0016](docs/adr/0016-highlight-permission-flow.md). Read it before changing any of them; each has a cheaper-looking alternative that the ADR rejects for a stated reason. - State lives in the pure, React-free reducer in `packages/core/src/highlights/permission-flow.ts`. **Every event invalid for the current step is a no-op** — that is the mechanism that stops a browser round-trip landing after a `RESET` from resurrecting a discarded highlight, not defensive noise. The hook adds a generation token on top so a late continuation cannot resolve a superseded caller's promise. -- A pending highlight carries the `scope` it was tapped in, and that `scope` is **load-bearing**. The generation token only protects flows that already exist, so the two windows before one opens — the pre-flight `ensureFreshToken()` round-trip, and a straight-through write that comes back `auth` — compare the claimed scope against the current one before replaying. Both are regression-tested; verse numbers replayed into the wrong chapter paint text the user never selected. +- A pending highlight carries the `scope` it was tapped in, and that `scope` is **load-bearing**. The generation token only protects flows that already exist, so the two windows before one opens — the pre-flight `getAccessToken()` round-trip, and a straight-through write that comes back `auth` — compare the claimed scope against the current one before replaying. Both are regression-tested; verse numbers replayed into the wrong chapter paint text the user never selected. - A scope change dispatches `RESET` during render (same "adjust state when props change" pattern as `use-highlights.ts`). - After awaiting `signIn()`, auth state is re-read via a **forced render** (`nextCommittedRender`), not straight off the ref: `signIn` resolves in a microtask while React schedules its re-render on a macrotask, so reading the ref immediately is guaranteed to be too early. The "signs in, then applies" test fails if that is removed. - Ordinary highlights deliberately **do not** go through the reducer — modelling every tap as an exclusive flow step would serialize concurrent writes that `useHighlights` supports. Only a flow is exclusive; an overlapping tap during one gets a `transient` outcome rather than being queued behind a browser session. diff --git a/README.md b/README.md index b903626..22f7d47 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ A React Native SDK for displaying Bible content in Expo apps on iOS and Android. - **Bible Reader**: a complete reading experience with `BibleReader`, including built-in chapter and version pickers - **Verse of the Day**: built-in `VerseOfTheDay` component - **Sign in**: optional PKCE OAuth via `YouVersionProvider` and `useYVAuth` (`@youversion/platform-react-native-expo-core`) -- **Highlights**: `useHighlights` for optimistic highlight writes backed by an instant local cache (`@youversion/platform-react-native-expo-core`) +- **Highlights**: `useHighlights` for optimistic highlight writes backed by an instant local cache (`@youversion/platform-react-native-expo-core`); a highlight made offline keeps its paint, survives a relaunch, and lands on its own - **Verse actions**: selecting a verse in `BibleReader` opens a native bottom sheet with highlight colors, Copy, and Share - **Theming**: `light` / `dark` / `system` themes, with per-component overrides - **Native presentation**: verse actions, footnotes, chapter, and version pickers open in native bottom sheets via `@gorhom/bottom-sheet` @@ -170,6 +170,54 @@ Copy and Share fall back to `expo-clipboard` and React Native's `Share`. To hand On web, `BibleReader` keeps the React Web SDK's verse action popover, because native bottom sheets do not exist there. Its Copy and Share work. Its color swatches do not write. +#### Highlights made offline + +A highlight tapped without service keeps its paint rather than disappearing, is persisted through a force-quit, and reaches the user's account on its own once service returns — including while the reader is on a different chapter, or not mounted at all. Nothing is required of you for that to work. + +To surface it, pass `onHighlightError`. It fires only for writes worth telling the user about — a parked write and a transient failure — and stays silent for `ok`, `noop`, and the auth and invalid cases the reader already handles itself: + +```tsx +import { BibleReader, type HighlightWriteError } from '@youversion/platform-react-native-expo-ui' + +function Reader() { + return ( + { + // { status: 'queued', verses } — saved on the device, will sync + // { status: 'error', reason: 'transient', verses, message } — the write did not stick + }} + /> + ) +} +``` + +`queued` reports the write just made, not the verse's history, so it repeats on every tap of a verse that is still parked. Show "saved offline" once by holding that in your own state. + +#### Refreshing highlights + +`BibleReader` fetches highlights for the chapter on screen and refreshes when the app returns to the foreground. To pull in highlights made on another device or in the YouVersion app at some other moment — a screen refocus, a pull-to-refresh — call `refreshHighlights()` on the reader's ref: + +```tsx +import { useCallback, useRef } from 'react' +import { useFocusEffect } from 'expo-router' +import { BibleReader, type BibleReaderHandle } from '@youversion/platform-react-native-expo-ui' + +function ReaderScreen() { + const reader = useRef(null) + + useFocusEffect( + useCallback(() => { + void reader.current?.refreshHighlights() + }, []), + ) + + return +} +``` + +It is 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. + #### Verse selection `onVerseSelect` reports every selection change, so you can react to one however you like — analytics, your own action UI, a custom share flow. It fires alongside the verse action sheet, not instead of it. `clearSelectionSignal` dismisses the current selection from native: increment it, and note its value at mount is the baseline, so mounting never clears. @@ -300,6 +348,30 @@ function ProfileScreen() { It accepts `mode` (`'auto' | 'signIn' | 'signOut'`, default `'auto'` toggles based on auth state), `background` (`'light' | 'dark'`), `outline`, `radius` (`'rounded' | 'rectangular'`), `size` (`'default' | 'short' | 'icon'`), and `text` (string, replaces the default localized label). +#### Signing out + +Both SDK-owned sign-out surfaces — `YouVersionAuthButton` and `BibleReader`'s user menu — ask before signing out, matching the Swift SDK. Sign-out is destructive: it drops the access token, the cached profile, the granted permissions, the cached highlights, and every highlight write still waiting to reach the server. When the queue holds unsent work, the confirmation escalates to "Save your highlights?". Every string is localized through the SDK's own catalog, and there is nothing to enable. + +On web the confirmation is skipped and sign-out runs immediately, because React Native Web's `Alert.alert` is a no-op and a prompt there would leave the button doing nothing. + +For your own sign-out UI, `useSignOutGuard` gives you the same confirmation: + +```tsx +import { useYVAuth } from '@youversion/platform-react-native-expo-core' +import { useSignOutGuard } from '@youversion/platform-react-native-expo-ui' + +function SignOutButton() { + const auth = useYVAuth() + const signOut = useSignOutGuard(auth) + + return