Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions .changeset/native-highlights-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

Expand All @@ -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**.

Expand All @@ -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).

Expand Down
Loading
Loading