From fe2d93db87c9d83b4aeef6e26cb4fc7236aed06e Mon Sep 17 00:00:00 2001 From: Dustin Kelley <141975656+Dustin-Kelley@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:08:10 -0500 Subject: [PATCH 01/43] feat(core): wrap platform-core HighlightsClient with RN token auth (YPE-4169) (1/3) (#97) * chore: add .claude/ to .gitignore to exclude Claude-related files from version control * feat(core): wrap platform-core HighlightsClient with RN token auth Adopt @youversion/platform-core@2.3.0 so native can get/create/delete highlights with an explicit access token and typed Result failures (auth vs transient), without exporting the surface from the package index yet. Co-authored-by: Cursor * fix(example): request highlights as AuthPermission, not a scope The auth server drops unknown OIDC scopes; wire permissions:['highlights'] and keep createHighlightsApi off the package barrel (relative example import). Also restore main .gitignore (drop unrelated .claude ignore) and harden createHighlight failure-path tests. Co-authored-by: Cursor * revert(example): remove local highlights Profile harness from PR Dev-only simulator buttons and permissions wiring were for local testing, not part of YPE-4169. Co-authored-by: Cursor * chore: add changeset for internal highlights client wrapper Co-authored-by: Cursor * refactor(core): use descriptive Result generic names Rename single-letter type params to Value/Error for clearer intent. Co-authored-by: Cursor * test(core): cover 5xx paths for create and delete highlights Co-authored-by: Cursor * chore: update .gitignore to include .claude/ directory for exclusion --------- Co-authored-by: Cursor --- .changeset/core-highlights-client-wrapper.md | 5 + .gitignore | 3 + packages/core/package.json | 1 + .../core/src/highlights/__tests__/api.test.ts | 247 ++++++++++++++++++ packages/core/src/highlights/api.ts | 101 +++++++ packages/core/src/highlights/index.ts | 12 + packages/core/src/result.ts | 15 ++ pnpm-lock.yaml | 15 ++ 8 files changed, 399 insertions(+) create mode 100644 .changeset/core-highlights-client-wrapper.md create mode 100644 packages/core/src/highlights/__tests__/api.test.ts create mode 100644 packages/core/src/highlights/api.ts create mode 100644 packages/core/src/highlights/index.ts create mode 100644 packages/core/src/result.ts diff --git a/.changeset/core-highlights-client-wrapper.md b/.changeset/core-highlights-client-wrapper.md new file mode 100644 index 00000000..d0abe49a --- /dev/null +++ b/.changeset/core-highlights-client-wrapper.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-native-expo-core': patch +--- + +Core now depends on `@youversion/platform-core@2.3.0` and includes an internal Highlights client wrapper (`createHighlightsApi`) that calls get/create/delete with an explicit access token and returns typed `Result` failures (`auth` for 401/403, `transient` otherwise). This surface is not exported from the package index yet — a later release will ship the public hook and API. diff --git a/.gitignore b/.gitignore index 331b579f..03dd53ab 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ apps/example/web-build/ # Cursor .cursor/ +# Claude +.claude/ + # Firecrawl .firecrawl/ diff --git a/packages/core/package.json b/packages/core/package.json index 7ec6f70e..9c444ff2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -101,6 +101,7 @@ "jest-expo": "56.0.5" }, "dependencies": { + "@youversion/platform-core": "2.3.0", "zod": "4.4.3" } } diff --git a/packages/core/src/highlights/__tests__/api.test.ts b/packages/core/src/highlights/__tests__/api.test.ts new file mode 100644 index 00000000..acd1376b --- /dev/null +++ b/packages/core/src/highlights/__tests__/api.test.ts @@ -0,0 +1,247 @@ +import { createHighlightsApi } from '../api' + +const mockFetch = jest.fn() + +beforeEach(() => { + mockFetch.mockReset() + global.fetch = mockFetch as unknown as typeof fetch +}) + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: String(status), + headers: { get: (name: string) => (name === 'content-type' ? 'application/json' : null) }, + json: () => Promise.resolve(body), + text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)), + } as unknown as Response +} + +function errorResponse(status: number, body = ''): Response { + return { + ok: false, + status, + statusText: String(status), + headers: { get: () => null }, + json: () => Promise.resolve(null), + text: () => Promise.resolve(body), + } as unknown as Response +} + +const api = () => + createHighlightsApi({ + appKey: 'appkey', + apiHost: 'api.example.com', + installationId: 'inst-1', + additionalHeaders: { 'x-yvp-sdk': 'ReactNativeSDK=1.0.0-dev' }, + }) + +describe('createHighlightsApi', () => { + describe('getHighlights', () => { + it('GETs /v1/highlights with auth and app headers and returns mapped highlights', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + data: [{ bible_id: 111, passage_id: 'JHN.3.16', color: 'fffe00' }], + next_page_token: null, + }), + ) + + const result = await api().getHighlights('tok', { + version_id: 111, + passage_id: 'JHN.3', + }) + + expect(result).toEqual({ + ok: true, + value: { + data: [{ version_id: 111, passage_id: 'JHN.3.16', color: 'fffe00' }], + next_page_token: null, + }, + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.example.com/v1/highlights?bible_id=111&passage_id=JHN.3') + expect(init.method).toBe('GET') + const headers = init.headers as Record + expect(headers.Authorization).toBe('Bearer tok') + expect(headers['X-YVP-App-Key']).toBe('appkey') + expect(headers['X-YVP-Installation-Id']).toBe('inst-1') + expect(headers['x-yvp-sdk']).toBe('ReactNativeSDK=1.0.0-dev') + }) + + it('returns auth failure for 401 and 403 without throwing', async () => { + mockFetch.mockResolvedValue(errorResponse(401)) + + const unauthorized = await api().getHighlights('tok', { + version_id: 111, + passage_id: 'JHN.3', + }) + + expect(unauthorized.ok).toBe(false) + if (unauthorized.ok) return + expect(unauthorized.error).toMatchObject({ kind: 'auth', status: 401 }) + + mockFetch.mockResolvedValue(errorResponse(403)) + const forbidden = await api().getHighlights('tok', { + version_id: 111, + passage_id: 'JHN.3', + }) + expect(forbidden.ok).toBe(false) + if (forbidden.ok) return + expect(forbidden.error).toMatchObject({ kind: 'auth', status: 403 }) + }) + + it('returns transient failure for 5xx and network errors', async () => { + mockFetch.mockResolvedValue(errorResponse(500)) + const serverError = await api().getHighlights('tok', { + version_id: 111, + passage_id: 'JHN.3', + }) + expect(serverError.ok).toBe(false) + if (serverError.ok) return + expect(serverError.error).toMatchObject({ kind: 'transient', status: 500 }) + + mockFetch.mockRejectedValue(new TypeError('Network request failed')) + const networkError = await api().getHighlights('tok', { + version_id: 111, + passage_id: 'JHN.3', + }) + expect(networkError.ok).toBe(false) + if (networkError.ok) return + expect(networkError.error).toMatchObject({ kind: 'transient' }) + expect(networkError.error.status).toBeUndefined() + }) + + it('returns transient failure when the payload fails schema validation', async () => { + mockFetch.mockResolvedValue(jsonResponse({ data: [{ wrong_shape: true }] })) + + const result = await api().getHighlights('tok', { + version_id: 111, + passage_id: 'JHN.3', + }) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe('transient') + expect(result.error.message).toMatch(/Unexpected highlights API response/) + }) + }) + + describe('createHighlight', () => { + it('POSTs a highlight and returns the mapped value', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ bible_id: 111, passage_id: 'JHN.3.16', color: 'fffe00' }), + ) + + const result = await api().createHighlight('tok', { + version_id: 111, + passage_id: 'JHN.3.16', + color: 'FFFE00', + }) + + expect(result).toEqual({ + ok: true, + value: { version_id: 111, passage_id: 'JHN.3.16', color: 'fffe00' }, + }) + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.example.com/v1/highlights') + expect(init.method).toBe('POST') + const headers = init.headers as Record + expect(headers.Authorization).toBe('Bearer tok') + expect(headers['X-YVP-App-Key']).toBe('appkey') + expect(headers['X-YVP-Installation-Id']).toBe('inst-1') + const body = JSON.parse(init.body as string) as { + highlight: { bible_id: number; passage_id: string; color: string } + } + expect(body.highlight).toEqual({ + bible_id: 111, + passage_id: 'JHN.3.16', + color: 'fffe00', + }) + }) + + it('returns auth failure for 401 without throwing', async () => { + mockFetch.mockResolvedValue(errorResponse(401)) + + const result = await api().createHighlight('tok', { + version_id: 111, + passage_id: 'JHN.3.16', + color: 'fffe00', + }) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error).toMatchObject({ kind: 'auth', status: 401 }) + }) + + it('returns transient failure for 5xx and network errors', async () => { + mockFetch.mockResolvedValue(errorResponse(500)) + const serverError = await api().createHighlight('tok', { + version_id: 111, + passage_id: 'JHN.3.16', + color: 'fffe00', + }) + expect(serverError.ok).toBe(false) + if (serverError.ok) return + expect(serverError.error).toMatchObject({ kind: 'transient', status: 500 }) + + mockFetch.mockRejectedValue(new TypeError('Network request failed')) + const networkError = await api().createHighlight('tok', { + version_id: 111, + passage_id: 'JHN.3.16', + color: 'fffe00', + }) + expect(networkError.ok).toBe(false) + if (networkError.ok) return + expect(networkError.error).toMatchObject({ kind: 'transient' }) + expect(networkError.error.status).toBeUndefined() + }) + }) + + describe('deleteHighlight', () => { + it('DELETEs by passage and returns void on success', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 204, + statusText: 'No Content', + headers: { get: () => null }, + json: () => Promise.resolve(null), + text: () => Promise.resolve(''), + } as unknown as Response) + + const result = await api().deleteHighlight('tok', 'JHN.3.16', { version_id: 111 }) + + expect(result).toEqual({ ok: true, value: undefined }) + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.example.com/v1/highlights/JHN.3.16?bible_id=111') + expect(init.method).toBe('DELETE') + const headers = init.headers as Record + expect(headers.Authorization).toBe('Bearer tok') + expect(headers['X-YVP-App-Key']).toBe('appkey') + expect(headers['X-YVP-Installation-Id']).toBe('inst-1') + }) + + it('returns auth failure for 403', async () => { + mockFetch.mockResolvedValue(errorResponse(403)) + + const result = await api().deleteHighlight('tok', 'JHN.3.16', { version_id: 111 }) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error).toMatchObject({ kind: 'auth', status: 403 }) + }) + + it('returns transient failure for 5xx', async () => { + mockFetch.mockResolvedValue(errorResponse(500)) + + const result = await api().deleteHighlight('tok', 'JHN.3.16', { version_id: 111 }) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error).toMatchObject({ kind: 'transient', status: 500 }) + }) + }) +}) diff --git a/packages/core/src/highlights/api.ts b/packages/core/src/highlights/api.ts new file mode 100644 index 00000000..520c74cb --- /dev/null +++ b/packages/core/src/highlights/api.ts @@ -0,0 +1,101 @@ +import { + ApiClient, + HighlightsClient, + type Collection, + type CreateHighlight, + type DeleteHighlightOptions, + type GetHighlightsOptions, + type Highlight, +} from '@youversion/platform-core' + +import { DEFAULT_API_HOST } from '../constants' +import { err, ok, type Result } from '../result' + +export type { Collection, CreateHighlight, DeleteHighlightOptions, GetHighlightsOptions, Highlight } + +/** Mirrors Web's binary split: 401/403 vs everything else (network, 5xx, validation). */ +export type HighlightsApiError = + | { kind: 'auth'; status: 401 | 403; message: string } + | { kind: 'transient'; status?: number; message: string } + +export type HighlightsApiResult = Result + +export type CreateHighlightsApiConfig = { + appKey: string + installationId: string + apiHost?: string + additionalHeaders?: Record + timeout?: number +} + +export type HighlightsApi = { + getHighlights: ( + accessToken: string, + options: GetHighlightsOptions, + ) => Promise>> + createHighlight: ( + accessToken: string, + data: CreateHighlight, + ) => Promise> + deleteHighlight: ( + accessToken: string, + passageId: string, + options: DeleteHighlightOptions, + ) => Promise> +} + +export function createHighlightsApi(config: CreateHighlightsApiConfig): HighlightsApi { + const client = new HighlightsClient( + new ApiClient({ + appKey: config.appKey, + apiHost: config.apiHost ?? DEFAULT_API_HOST, + installationId: config.installationId, + additionalHeaders: config.additionalHeaders, + timeout: config.timeout, + }), + ) + + return { + getHighlights(accessToken, options) { + return catchAsResult(() => client.getHighlights(options, accessToken)) + }, + createHighlight(accessToken, data) { + return catchAsResult(() => client.createHighlight(data, accessToken)) + }, + deleteHighlight(accessToken, passageId, options) { + return catchAsResult(() => client.deleteHighlight(passageId, options, accessToken)) + }, + } +} + +async function catchAsResult( + run: () => Promise, +): Promise> { + try { + return ok(await run()) + } catch (caught) { + return err(toHighlightsApiError(caught)) + } +} + +function toHighlightsApiError(caught: unknown): HighlightsApiError { + const status = extractStatus(caught) + const message = caught instanceof Error ? caught.message : String(caught) + + if (status === 401 || status === 403) { + return { kind: 'auth', status, message } + } + + return status === undefined + ? { kind: 'transient', message } + : { kind: 'transient', status, message } +} + +/** Pulls an HTTP status off a thrown ApiClient error (same shape Web uses). */ +function extractStatus(error: unknown): number | undefined { + if (typeof error === 'object' && error !== null && 'status' in error) { + const status = (error as { status?: unknown }).status + return typeof status === 'number' ? status : undefined + } + return undefined +} diff --git a/packages/core/src/highlights/index.ts b/packages/core/src/highlights/index.ts new file mode 100644 index 00000000..a3c400c5 --- /dev/null +++ b/packages/core/src/highlights/index.ts @@ -0,0 +1,12 @@ +export { + createHighlightsApi, + type Collection, + type CreateHighlight, + type CreateHighlightsApiConfig, + type DeleteHighlightOptions, + type GetHighlightsOptions, + type Highlight, + type HighlightsApi, + type HighlightsApiError, + type HighlightsApiResult, +} from './api' diff --git a/packages/core/src/result.ts b/packages/core/src/result.ts new file mode 100644 index 00000000..87b972f5 --- /dev/null +++ b/packages/core/src/result.ts @@ -0,0 +1,15 @@ +/** + * Local Result seam for S1 (YPE-3706). Keep callers importing from here so the + * ADR outcome (better-result / neverthrow / Effect) swaps a single module. + */ +export type Result = + | { ok: true; value: Value } + | { ok: false; error: Error } + +export function ok(value: Value): Result { + return { ok: true, value } +} + +export function err(error: Error): Result { + return { ok: false, error } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 617f5d2c..acb52576 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,9 @@ importers: packages/core: dependencies: + '@youversion/platform-core': + specifier: 2.3.0 + version: 2.3.0 expo: specifier: '>=56.0.0 <57.0.0' version: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) @@ -2289,6 +2292,14 @@ packages: linkedom: optional: true + '@youversion/platform-core@2.3.0': + resolution: {integrity: sha512-bsneE3s7jpoetWem+1gr+tZJe3Dryq7ywmdoKCPGpaLffP5GGYl0hS2hj+QAyHo0Jk9LwCGuJVtPVCDkLMPSHg==} + peerDependencies: + linkedom: ^0.18.12 + peerDependenciesMeta: + linkedom: + optional: true + '@youversion/platform-react-hooks@2.2.0': resolution: {integrity: sha512-QrPe2g6Lg0IM1D2LSh2OFWO4f1DBlhXZtvpSRYTt36lPaaXkV89RxJEJYk3G0eJ1ZyrzkwuxYGvfQYJetSLTfA==} peerDependencies: @@ -9157,6 +9168,10 @@ snapshots: dependencies: zod: 4.1.12 + '@youversion/platform-core@2.3.0': + dependencies: + zod: 4.1.12 + '@youversion/platform-react-hooks@2.2.0(react@19.2.5)': dependencies: '@youversion/platform-core': 2.2.0 From 98795f7c6a89716a86e3e52612f882d1e378a0c3 Mon Sep 17 00:00:00 2001 From: Dustin Kelley <141975656+Dustin-Kelley@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:26:22 -0500 Subject: [PATCH 02/43] feat(core): highlights API wrapper + MMKV highlights cache (YPE-4170) (2/3) (#98) * chore: add .claude/ to .gitignore to exclude Claude-related files from version control * feat(core): wrap platform-core HighlightsClient with RN token auth Adopt @youversion/platform-core@2.3.0 so native can get/create/delete highlights with an explicit access token and typed Result failures (auth vs transient), without exporting the surface from the package index yet. Co-authored-by: Cursor * fix(example): request highlights as AuthPermission, not a scope The auth server drops unknown OIDC scopes; wire permissions:['highlights'] and keep createHighlightsApi off the package barrel (relative example import). Also restore main .gitignore (drop unrelated .claude ignore) and harden createHighlight failure-path tests. Co-authored-by: Cursor * revert(example): remove local highlights Profile harness from PR Dev-only simulator buttons and permissions wiring were for local testing, not part of YPE-4169. Co-authored-by: Cursor * chore: add changeset for internal highlights client wrapper Co-authored-by: Cursor * refactor(core): use descriptive Result generic names Rename single-letter type params to Value/Error for clearer intent. Co-authored-by: Cursor * test(core): cover 5xx paths for create and delete highlights Co-authored-by: Cursor * chore: update .gitignore to include .claude/ directory for exclusion * feat(core): add MMKV Server Colors highlights cache Sync get/set/clear for Highlight Scope snapshots so Subtask 3 can hydrate without fetching in this layer; purge all yvp.highlights.* keys on sign-out. Co-authored-by: Cursor * feat(core): add MMKV Server Colors highlights cache Sync get/set/clear for Highlight Scope snapshots so Subtask 3 can hydrate without fetching in this layer; purge all yvp.highlights.* keys on sign-out. Co-authored-by: Cursor * refactor(highlights): update highlights caching and schema handling * chore: add changeset for internal highlights cache Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Cursor Co-authored-by: Claude Opus 5 --- .changeset/core-highlights-cache.md | 5 + CONTEXT.md | 14 ++ .../src/auth/__tests__/auth-provider.test.tsx | 15 +- packages/core/src/auth/auth-provider.tsx | 2 + .../src/highlights/__tests__/cache.test.ts | 226 ++++++++++++++++++ packages/core/src/highlights/cache.ts | 164 +++++++++++++ packages/core/src/highlights/constants.ts | 13 + packages/core/src/highlights/index.ts | 13 + 8 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 .changeset/core-highlights-cache.md create mode 100644 packages/core/src/highlights/__tests__/cache.test.ts create mode 100644 packages/core/src/highlights/cache.ts create mode 100644 packages/core/src/highlights/constants.ts diff --git a/.changeset/core-highlights-cache.md b/.changeset/core-highlights-cache.md new file mode 100644 index 00000000..320ad1ba --- /dev/null +++ b/.changeset/core-highlights-cache.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-native-expo-core': patch +--- + +Core now includes an internal MMKV highlights cache: synchronous per-user, per-chapter reads of the raw `Highlight[]` API shape, zod-validated so corrupt or legacy payloads read as a miss rather than throwing. A `deriveServerColors` projection maps cached highlights onto the displayed scope as the verse → hex color map (expanding range passage ids such as `JHN.3.16-18`), so passage ids survive a cold start and remain available for targeted deletes. Cached highlights are purged on sign-out and revoked-refresh alongside the rest of auth state. This surface is not exported from the package index yet — a later release will ship the public hook and API. diff --git a/CONTEXT.md b/CONTEXT.md index dc6f12c8..7909b75a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -98,6 +98,18 @@ _Avoid_: Treating the `-dev` suffix as a bug to remove; a bare `Dev` sentinel (d `@youversion/platform-react-ui` and `@youversion/platform-react-hooks` are `dependencies` (auto-installed). `react-dom` is a `peerDependency` to prevent duplicate React instances in apps that also target web. Transitive native module requirements (reanimated, gesture-handler, etc.) are listed as `peerDependencies` to protect consumers from missing runtime deps. _Avoid_: Bundled deps, vendored web SDK +**Highlight Scope**: +The chapter a highlights flow is operating on: `versionId` + `book` + `chapter`. Same shape as the web highlights machine (for reuse) and the same Bible-location triple as **Reader Location** — without a user. Per-user isolation for persistent cache is a separate axis at the storage boundary, not part of this type. +_Avoid_: Folding `userId` into this type; Reader Location (restore snapshot for uncontrolled readers, different purpose); cache key (implementation detail) + +**Server Colors**: +The verse→color map for a **Highlight Scope**: `Record` where keys are verse numbers and values are 6-char hex colors with no `#`. A _derived_ projection of **Cached Highlights** onto the displayed scope, used for optimistic overlay math — not something we persist, and not optimistic UI overlays themselves. Range passage ids expand to one entry per verse and colors are normalized to lowercase during projection. +_Avoid_: Persisting this shape (it destroys passage ids — see **Cached Highlights**); highlight colors (ambiguous with UI state), highlightedVerses (Web SDK render prop; often boolean-keyed) + +**Cached Highlights**: +The raw core API shape (`Highlight[]`: `version_id` + `passage_id` + `color`) persisted on native per `userId` + **Highlight Scope**. Passage ids may be verse ranges (`JHN.3.16-18`), so this is the only shape that can feed the web reader's controlled `highlights` prop on a cold start and that supports passage-id-targeted deletes. Reads are synchronous and validated; a valid empty array is a real snapshot (“none”), not a cache miss, and any corrupt or legacy payload reads as a miss. +_Avoid_: Flattening to **Server Colors** before writing; treating an empty array as a miss + ## Relationships - A **React Web SDK Component** may expose reusable content that can be rendered by an **Expo DOM Component**. @@ -120,6 +132,8 @@ _Avoid_: Bundled deps, vendored web SDK - **Compiled Distribution** ships `build/` to npm (via `expo-module-scripts`); `tsc` preserves `'use dom'` and the Expo Metro plugin processes it from compiled files in `node_modules`, so DOM Components work without shipping raw source. - The **Dependency Boundary** auto-installs web SDK packages but requires `react-dom` as a peer dep to avoid duplicate React instances when consumers also build for web. - The **SDK Attribution Header** depends on **Compiled Distribution**: because published builds run from `build/` while dev runs from `src/`, the publish-time stamp can give the two different channel signals from one source file. +- A **Highlight Scope** identifies the chapter for highlights (web-compatible location triple). Native persists **Cached Highlights** keyed by `userId` + **Highlight Scope**; without a known `userId`, the cache does not read or write. This is **Native-Owned State**, distinct from **Reader Location**. +- **Server Colors** are derived from **Cached Highlights** for a given **Highlight Scope**, never stored: entries whose version, book, or chapter does not match the scope are ignored, so stale data cannot mispaint. ## Example Dialogue diff --git a/packages/core/src/auth/__tests__/auth-provider.test.tsx b/packages/core/src/auth/__tests__/auth-provider.test.tsx index f390219a..79bf2dc7 100644 --- a/packages/core/src/auth/__tests__/auth-provider.test.tsx +++ b/packages/core/src/auth/__tests__/auth-provider.test.tsx @@ -18,6 +18,7 @@ jest.mock('../../storage/mmkv-storage', () => ({ }), getString: jest.fn((k: string) => mockMmkv.get(k)), remove: jest.fn((k: string) => mockMmkv.delete(k)), + getAllKeys: jest.fn(() => Array.from(mockMmkv.keys())), }, })) @@ -346,8 +347,13 @@ describe('AuthProvider — signIn', () => { }) describe('AuthProvider — signOut', () => { - it('clears tokens, resets in-memory state, and removes cached userInfo', async () => { + it('clears tokens, resets in-memory state, and removes cached userInfo and highlights', async () => { + const highlightsKey = 'yvp.highlights.user-1.111.JHN.3' mockMmkv.set(MMKV_AUTH_KEYS.cachedUserInfo, JSON.stringify({ id: 'u1' })) + mockMmkv.set( + highlightsKey, + JSON.stringify([{ version_id: 111, passage_id: 'JHN.3.16', color: 'fffe00' }]), + ) mockLoadTokens.mockResolvedValue({ accessToken: 'a', refreshToken: 'r', @@ -372,6 +378,7 @@ describe('AuthProvider — signOut', () => { expiryDate: null, }) expect(mockMmkv.has(MMKV_AUTH_KEYS.cachedUserInfo)).toBe(false) + expect(mockMmkv.has(highlightsKey)).toBe(false) }) }) @@ -401,6 +408,11 @@ describe('AuthProvider — refresh failure policy', () => { }) it('clears tokens when the refresh token is revoked (TokenEndpointError 401)', async () => { + const highlightsKey = 'yvp.highlights.user-1.111.JHN.3' + mockMmkv.set( + highlightsKey, + JSON.stringify([{ version_id: 111, passage_id: 'JHN.3.16', color: 'fffe00' }]), + ) mockLoadTokens.mockResolvedValue(expiredStored) mockRefreshTokens.mockRejectedValue(new TokenEndpointError(401, 'invalid_grant')) @@ -415,6 +427,7 @@ describe('AuthProvider — refresh failure policy', () => { expect(getText('accessToken')).toBe('null') expect(getText('error')).toMatch(/401/) expect(mockSaveTokens).toHaveBeenCalledWith(clearedTokens) + expect(mockMmkv.has(highlightsKey)).toBe(false) }) }) diff --git a/packages/core/src/auth/auth-provider.tsx b/packages/core/src/auth/auth-provider.tsx index 03faae51..51537d93 100644 --- a/packages/core/src/auth/auth-provider.tsx +++ b/packages/core/src/auth/auth-provider.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { AppState, type AppStateStatus } from 'react-native' import { z } from 'zod' +import { clearHighlightsCache } from '../highlights' import { mmkvStorage } from '../storage/mmkv-storage' import { AuthContext, type AuthContextValue } from './auth-context' import { MMKV_AUTH_KEYS, REFRESH_LEEWAY_SECONDS } from './constants' @@ -41,6 +42,7 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth const clearAuthState = useCallback(async () => { mmkvStorage.remove(MMKV_AUTH_KEYS.cachedUserInfo) + clearHighlightsCache() expiryRef.current = null refreshTokenRef.current = null setAccessToken(null) diff --git a/packages/core/src/highlights/__tests__/cache.test.ts b/packages/core/src/highlights/__tests__/cache.test.ts new file mode 100644 index 00000000..f22899ff --- /dev/null +++ b/packages/core/src/highlights/__tests__/cache.test.ts @@ -0,0 +1,226 @@ +import type { Highlight } from '@youversion/platform-core' +import { mmkvStorage } from '../../storage/mmkv-storage' +import { MMKV_AUTH_KEYS } from '../../auth/constants' +import { MMKV_KEYS } from '../../constants' +import { + clearHighlightsCache, + deriveServerColors, + expandPassageId, + getCachedHighlights, + highlightsCacheKey, + setCachedHighlights, + type HighlightScope, +} from '../cache' + +const mockMmkv = new Map() + +jest.mock('../../storage/mmkv-storage', () => ({ + mmkvStorage: { + set: jest.fn((k: string, v: string) => { + mockMmkv.set(k, v) + }), + getString: jest.fn((k: string) => mockMmkv.get(k)), + remove: jest.fn((k: string) => { + mockMmkv.delete(k) + }), + getAllKeys: jest.fn(() => Array.from(mockMmkv.keys())), + has: jest.fn((k: string) => mockMmkv.has(k)), + }, +})) + +const scope: HighlightScope = { versionId: 111, book: 'JHN', chapter: '3' } +const userId = 'user-1' + +function highlight(passageId: string, color: string, versionId = scope.versionId): Highlight { + return { version_id: versionId, passage_id: passageId, color } +} + +beforeEach(() => { + mockMmkv.clear() + jest.clearAllMocks() +}) + +describe('highlights cache', () => { + it('round-trips raw highlights with passage ids intact', () => { + const highlights = [highlight('JHN.3.16-18', 'FFFE00'), highlight('JHN.3.20', 'AaBbCc')] + + setCachedHighlights(userId, scope, highlights) + + // The raw API shape survives the round trip — passage ids and ranges are + // not flattened away, so the reader's controlled `highlights` prop and + // passage-id-targeted deletes still work on a cold start. + expect(getCachedHighlights(userId, scope)).toEqual(highlights) + expect(mockMmkv.has(highlightsCacheKey(userId, scope))).toBe(true) + }) + + it('returns synchronously (not a Promise)', () => { + setCachedHighlights(userId, scope, [highlight('JHN.3.1', 'fffe00')]) + const result = getCachedHighlights(userId, scope) + expect(result).not.toBeInstanceOf(Promise) + expect(typeof (result as { then?: unknown } | null)?.then).toBe('undefined') + }) + + it('returns null for corrupt or invalid cached payloads without throwing', () => { + const key = highlightsCacheKey(userId, scope) + + mockMmkv.set(key, '{not-json') + expect(getCachedHighlights(userId, scope)).toBeNull() + + // Legacy Server Colors payloads (the shape this cache used to persist). + mockMmkv.set(key, JSON.stringify({ 16: 'fffe00' })) + expect(getCachedHighlights(userId, scope)).toBeNull() + + mockMmkv.set(key, JSON.stringify([{ version_id: 111, passage_id: 'JHN.3.16' }])) + expect(getCachedHighlights(userId, scope)).toBeNull() + + mockMmkv.set(key, JSON.stringify([highlight('JHN.3.16', 'gg0000')])) + expect(getCachedHighlights(userId, scope)).toBeNull() + + mockMmkv.set( + key, + JSON.stringify([{ version_id: '111', passage_id: 'JHN.3.16', color: 'fffe00' }]), + ) + expect(getCachedHighlights(userId, scope)).toBeNull() + + mockMmkv.set(key, JSON.stringify([{ version_id: 0, passage_id: 'JHN.3.16', color: 'fffe00' }])) + expect(getCachedHighlights(userId, scope)).toBeNull() + + mockMmkv.set(key, JSON.stringify([highlight('', 'fffe00')])) + expect(getCachedHighlights(userId, scope)).toBeNull() + }) + + it('treats an empty highlights list as a hit', () => { + setCachedHighlights(userId, scope, []) + expect(getCachedHighlights(userId, scope)).toEqual([]) + }) + + it('misses on get and no-ops on set when userId is missing', () => { + expect(getCachedHighlights('', scope)).toBeNull() + + setCachedHighlights('', scope, [highlight('JHN.3.1', 'fffe00')]) + expect(mmkvStorage.set).not.toHaveBeenCalled() + expect(mockMmkv.size).toBe(0) + }) + + it('clears only yvp.highlights.* keys', () => { + const highlightsKey = highlightsCacheKey(userId, scope) + mockMmkv.set(highlightsKey, JSON.stringify([highlight('JHN.3.16', 'fffe00')])) + mockMmkv.set(MMKV_AUTH_KEYS.cachedUserInfo, '{"id":"u1"}') + mockMmkv.set(MMKV_AUTH_KEYS.expiryDateISO, '2026-01-01T00:00:00.000Z') + mockMmkv.set(MMKV_KEYS.installationId, 'inst-1') + + clearHighlightsCache() + + expect(mockMmkv.has(highlightsKey)).toBe(false) + expect(mockMmkv.get(MMKV_AUTH_KEYS.cachedUserInfo)).toBe('{"id":"u1"}') + expect(mockMmkv.get(MMKV_AUTH_KEYS.expiryDateISO)).toBe('2026-01-01T00:00:00.000Z') + expect(mockMmkv.get(MMKV_KEYS.installationId)).toBe('inst-1') + }) +}) + +describe('expandPassageId', () => { + it('expands a single verse and a verse range', () => { + expect(expandPassageId('JHN.3.16')).toEqual({ book: 'JHN', chapter: '3', verses: [16] }) + expect(expandPassageId('JHN.3.16-18')).toEqual({ + book: 'JHN', + chapter: '3', + verses: [16, 17, 18], + }) + }) + + it('rejects non-verse, malformed, reversed, and implausible passage ids', () => { + expect(expandPassageId('JHN.3')).toBeNull() // chapter scope + expect(expandPassageId('JHN')).toBeNull() + expect(expandPassageId('JHN.3.16.18')).toBeNull() + expect(expandPassageId('JHN..16')).toBeNull() + expect(expandPassageId('JHN.3.abc')).toBeNull() + expect(expandPassageId('JHN.3.16-')).toBeNull() + expect(expandPassageId('JHN.3.18-16')).toBeNull() // reversed + expect(expandPassageId('JHN.3.0')).toBeNull() // verse 0 + expect(expandPassageId('JHN.3.1-9999')).toBeNull() // implausibly large + expect(expandPassageId('')).toBeNull() + }) +}) + +describe('deriveServerColors', () => { + it('projects verses and normalizes hex to lowercase', () => { + const colors = deriveServerColors( + [highlight('JHN.3.16', 'FFFE00'), highlight('JHN.3.17', 'AaBbCc')], + scope, + ) + expect(colors).toEqual({ 16: 'fffe00', 17: 'aabbcc' }) + }) + + it('expands a range passage id across every verse it covers', () => { + expect(deriveServerColors([highlight('JHN.3.16-18', 'fffe00')], scope)).toEqual({ + 16: 'fffe00', + 17: 'fffe00', + 18: 'fffe00', + }) + }) + + it('ignores entries from a different version, book, or chapter', () => { + const colors = deriveServerColors( + [ + highlight('JHN.3.16', 'fffe00'), + highlight('JHN.3.17', 'ff0000', 222), // other version + highlight('MAT.3.18', 'ff0000'), // other book + highlight('JHN.4.19', 'ff0000'), // other chapter + ], + scope, + ) + expect(colors).toEqual({ 16: 'fffe00' }) + }) + + it('skips malformed, reversed, and chapter-scope passage ids without throwing', () => { + expect(() => + deriveServerColors( + [ + highlight('JHN.3', 'fffe00'), + highlight('JHN.3.18-16', 'fffe00'), + highlight('JHN.3.abc', 'fffe00'), + highlight('JHN.3.0', 'fffe00'), + highlight('', 'fffe00'), + ], + scope, + ), + ).not.toThrow() + + expect( + deriveServerColors( + [ + highlight('JHN.3', 'fffe00'), + highlight('JHN.3.18-16', 'fffe00'), + highlight('JHN.3.abc', 'fffe00'), + highlight('JHN.3.0', 'fffe00'), + highlight('JHN.3.16', 'fffe00'), + ], + scope, + ), + ).toEqual({ 16: 'fffe00' }) + }) + + it('lets later entries win on overlapping verses', () => { + expect( + deriveServerColors( + [highlight('JHN.3.16-17', 'fffe00'), highlight('JHN.3.17', 'ff0000')], + scope, + ), + ).toEqual({ 16: 'fffe00', 17: 'ff0000' }) + }) + + it('returns an empty map for an empty list', () => { + expect(deriveServerColors([], scope)).toEqual({}) + }) + + it('derives Server Colors from what the cache round-trips', () => { + setCachedHighlights(userId, scope, [highlight('JHN.3.16-18', 'FFFE00')]) + const cached = getCachedHighlights(userId, scope) + expect(cached).not.toBeNull() + expect(deriveServerColors(cached ?? [], scope)).toEqual({ + 16: 'fffe00', + 17: 'fffe00', + 18: 'fffe00', + }) + }) +}) diff --git a/packages/core/src/highlights/cache.ts b/packages/core/src/highlights/cache.ts new file mode 100644 index 00000000..32cf74ad --- /dev/null +++ b/packages/core/src/highlights/cache.ts @@ -0,0 +1,164 @@ +import type { Highlight } from '@youversion/platform-core' +import { z } from 'zod' +import { mmkvStorage } from '../storage/mmkv-storage' +import { + highlightsCacheKey, + MMKV_HIGHLIGHTS_KEY_PREFIX, + type HighlightScope, + type ServerColors, +} from './constants' + +export { + highlightsCacheKey, + MMKV_HIGHLIGHTS_KEY_PREFIX, + type HighlightScope, + type ServerColors, +} from './constants' + +/** + * Minimal hand-rolled mirror of the core `Highlight` API shape. + * + * `@youversion/platform-core` keeps its highlight schemas internal + * (`_HighlightSchema` is declared but not exported), so there is no published + * schema to reuse. Case-insensitive hex matches the core validator: the API may + * echo colors in any case. + */ +const highlightSchema = z.object({ + version_id: z.number().int().positive(), + passage_id: z.string().min(1), + color: z.string().regex(/^[0-9a-f]{6}$/i), +}) + +const highlightsSchema = z.array(highlightSchema) + +/** + * Defensive cap on how many verses a single range USFM may expand to. The + * longest chapter in any Bible (Psalm 119) has 176 verses, so any range longer + * than this is malformed data and is rejected rather than expanded. + */ +const MAX_RANGE_LENGTH = 250 + +/** A verse USFM (`JHN.3.16` / `JHN.3.16-18`) split into its parts. */ +export type ExpandedPassageId = { + book: string + chapter: string + /** Every verse number covered, ascending (a range expands to each verse). */ + verses: number[] +} + +/** + * Expands a verse or verse-range USFM passage id (`JHN.3.16`, `JHN.3.16-18`) + * into its book, chapter, and per-verse numbers. + * + * Returns `null` for anything that is not a highlightable verse unit: a + * chapter-scope USFM (`JHN.3`), malformed input, a reversed range, verse 0, or + * an implausibly large range. + */ +export function expandPassageId(passageId: string): ExpandedPassageId | null { + const parts = passageId.split('.') + if (parts.length !== 3) { + return null + } + const [book, chapter, versePart] = parts + if (!book || !chapter || !versePart) { + return null + } + + const match = /^(\d+)(?:-(\d+))?$/.exec(versePart) + if (!match) { + return null + } + + const startRaw = match[1] + if (startRaw === undefined) { + return null + } + const endRaw = match[2] + + const start = parseInt(startRaw, 10) + const end = endRaw === undefined ? start : parseInt(endRaw, 10) + if (start < 1 || end < start || end - start + 1 > MAX_RANGE_LENGTH) { + return null + } + + const verses: number[] = [] + for (let verse = start; verse <= end; verse++) { + verses.push(verse) + } + return { book, chapter, verses } +} + +/** + * Projects a cached `Highlight[]` (core API shape) onto the displayed scope as + * the verse -> hex color render map used for optimistic overlay math. + * + * Entries for other versions, books, or chapters are ignored — every entry + * carries its full identity, so stale data can never mispaint. Range passage + * ids are expanded per verse. Colors are normalized to lowercase (the API + * accepts uppercase at the boundary). Later entries win on collisions. + */ +export function deriveServerColors( + highlights: readonly Highlight[], + scope: HighlightScope, +): ServerColors { + const colors: ServerColors = {} + for (const { version_id, passage_id, color } of highlights) { + if (version_id !== scope.versionId) { + continue + } + const expanded = expandPassageId(passage_id) + if (!expanded || expanded.book !== scope.book || expanded.chapter !== scope.chapter) { + continue + } + const normalizedColor = color.toLowerCase() + for (const verse of expanded.verses) { + colors[verse] = normalizedColor + } + } + return colors +} + +/** + * Reads the cached raw highlights for a scope. Synchronous by design — the + * reader paints from cache before the network answers. Returns `null` on a + * miss or on any corrupt/invalid payload; never throws. + */ +export function getCachedHighlights(userId: string, scope: HighlightScope): Highlight[] | null { + if (!userId) { + return null + } + + try { + const raw = mmkvStorage.getString(highlightsCacheKey(userId, scope)) + if (raw == null) { + return null + } + const parsed = highlightsSchema.safeParse(JSON.parse(raw)) + if (!parsed.success) { + return null + } + return parsed.data + } catch { + return null + } +} + +/** Persists the raw API shape so passage ids (and ranges) survive a cold start. */ +export function setCachedHighlights( + userId: string, + scope: HighlightScope, + highlights: readonly Highlight[], +): void { + if (!userId) { + return + } + mmkvStorage.set(highlightsCacheKey(userId, scope), JSON.stringify(highlights)) +} + +export function clearHighlightsCache(): void { + for (const key of mmkvStorage.getAllKeys()) { + if (key.startsWith(MMKV_HIGHLIGHTS_KEY_PREFIX)) { + mmkvStorage.remove(key) + } + } +} diff --git a/packages/core/src/highlights/constants.ts b/packages/core/src/highlights/constants.ts new file mode 100644 index 00000000..b9a73dcc --- /dev/null +++ b/packages/core/src/highlights/constants.ts @@ -0,0 +1,13 @@ +export const MMKV_HIGHLIGHTS_KEY_PREFIX = 'yvp.highlights.' as const + +export type HighlightScope = { + versionId: number + book: string + chapter: string +} + +export type ServerColors = Record + +export function highlightsCacheKey(userId: string, scope: HighlightScope): string { + return `${MMKV_HIGHLIGHTS_KEY_PREFIX}${userId}.${scope.versionId}.${scope.book}.${scope.chapter}` +} diff --git a/packages/core/src/highlights/index.ts b/packages/core/src/highlights/index.ts index a3c400c5..eb3eebbd 100644 --- a/packages/core/src/highlights/index.ts +++ b/packages/core/src/highlights/index.ts @@ -10,3 +10,16 @@ export { type HighlightsApiError, type HighlightsApiResult, } from './api' + +export { + clearHighlightsCache, + deriveServerColors, + expandPassageId, + getCachedHighlights, + highlightsCacheKey, + MMKV_HIGHLIGHTS_KEY_PREFIX, + setCachedHighlights, + type ExpandedPassageId, + type HighlightScope, + type ServerColors, +} from './cache' From ae963bd8bca76fd0ce675affcab8f0f2f0bb5ae4 Mon Sep 17 00:00:00 2001 From: Dustin Kelley <141975656+Dustin-Kelley@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:16:11 -0500 Subject: [PATCH 03/43] fix(core): shim crypto.randomUUID so RN highlight creates send a valid request_id (YPE-4192) (#99) * chore: add .claude/ to .gitignore to exclude Claude-related files from version control * feat(core): wrap platform-core HighlightsClient with RN token auth Adopt @youversion/platform-core@2.3.0 so native can get/create/delete highlights with an explicit access token and typed Result failures (auth vs transient), without exporting the surface from the package index yet. Co-authored-by: Cursor * fix(example): request highlights as AuthPermission, not a scope The auth server drops unknown OIDC scopes; wire permissions:['highlights'] and keep createHighlightsApi off the package barrel (relative example import). Also restore main .gitignore (drop unrelated .claude ignore) and harden createHighlight failure-path tests. Co-authored-by: Cursor * revert(example): remove local highlights Profile harness from PR Dev-only simulator buttons and permissions wiring were for local testing, not part of YPE-4169. Co-authored-by: Cursor * chore: add changeset for internal highlights client wrapper Co-authored-by: Cursor * refactor(core): use descriptive Result generic names Rename single-letter type params to Value/Error for clearer intent. Co-authored-by: Cursor * test(core): cover 5xx paths for create and delete highlights Co-authored-by: Cursor * chore: update .gitignore to include .claude/ directory for exclusion * feat(core): add MMKV Server Colors highlights cache Sync get/set/clear for Highlight Scope snapshots so Subtask 3 can hydrate without fetching in this layer; purge all yvp.highlights.* keys on sign-out. Co-authored-by: Cursor * fix(core): shim crypto.randomUUID so RN highlight creates send a valid request_id platform-core's HighlightsClient mints the API-required request_id via the global crypto.randomUUID, absent on RN Hermes (Expo SDK 56). It silently falls back to a yvp- id the highlights API rejects with 422 (uuid_parsing), breaking every create from React Native (reads/deletes carry no request_id and are unaffected). Install an idempotent, self-installing shim backing crypto.randomUUID with expo-crypto (already a core dep; the same native UUID source used in installation-id.ts) before any platform-core client runs, so creates send a real RFC-4122 v4 UUID. Mirrors ui/lib/dom-local-storage.ts. Only randomUUID is shimmed; a native implementation is never overridden. Bridge until platform-core exposes an injectable request_id generator upstream. Co-Authored-By: Claude Opus 4.8 * test(core): pin the crypto shim wiring in the highlight create path The existing request_id assertion passes on any runtime that already has crypto.randomUUID, which Node >= 19 and therefore CI does, so it could not tell whether createHighlightsApi installs the shim at all. Add a case that drops the crypto global to reproduce RN Hermes and asserts the id came from expo-crypto. Verified to have teeth: commenting out the ensureCryptoRandomUUID call fails it with the production symptom, "yvp-19fa3d69052-637b519a", while the pre-existing regex assertion still passes. Also record why the null half of the cryptoScope guard stays: it is there for TypeScript narrowing, not runtime paranoia, and removing it needs a non-null assertion that ESLint rejects in source. Co-Authored-By: Claude Opus 5 * fix(core): shim crypto.randomUUID so RN highlight creates send a valid request_id platform-core's HighlightsClient mints the API-required request_id via the global crypto.randomUUID, absent on RN Hermes (Expo SDK 56). It silently falls back to a yvp- id the highlights API rejects with 422 (uuid_parsing), breaking every create from React Native (reads/deletes carry no request_id and are unaffected). Install an idempotent, self-installing shim backing crypto.randomUUID with expo-crypto (already a core dep; the same native UUID source used in installation-id.ts) before any platform-core client runs, so creates send a real RFC-4122 v4 UUID. Mirrors ui/lib/dom-local-storage.ts. Only randomUUID is shimmed; a native implementation is never overridden. Bridge until platform-core exposes an injectable request_id generator upstream. Co-Authored-By: Claude Opus 4.8 * test(core): pin the crypto shim wiring in the highlight create path The existing request_id assertion passes on any runtime that already has crypto.randomUUID, which Node >= 19 and therefore CI does, so it could not tell whether createHighlightsApi installs the shim at all. Add a case that drops the crypto global to reproduce RN Hermes and asserts the id came from expo-crypto. Verified to have teeth: commenting out the ensureCryptoRandomUUID call fails it with the production symptom, "yvp-19fa3d69052-637b519a", while the pre-existing regex assertion still passes. Also record why the null half of the cryptoScope guard stays: it is there for TypeScript narrowing, not runtime paranoia, and removing it needs a non-null assertion that ESLint rejects in source. Co-Authored-By: Claude Opus 5 * docs(core): record the accepted trade-off in the crypto shim header The header claimed a partial getRandomValues/subtle shim would be a worse footgun than a missing one, which inverts the YPE-4192 risk analysis and does not hold up: expo-crypto's getRandomValues is a real native CSPRNG, and defining globalThis.crypto at all already creates the partial surface. State the actual trade instead -- a library gating on the crypto object rather than the method loses its fallback -- why it is accepted, and when to revisit. Addresses review feedback on the ticket/code divergence. Co-Authored-By: Claude Opus 5 * test(core): share one crypto-global fixture between the shim suites SHIM_UUID, the crypto teardown, and the globalThis cast were duplicated across api.test.ts and ensure-crypto-uuid.test.ts. Move them to src/test-utils, beside the ui package's existing test-utils and outside __tests__ so jest does not collect the helper as a suite. Centralizing the teardown is the real win: globalThis.crypto is process-wide, so a restore that misses leaks a stubbed crypto into later suites. The shared helper also restores the original property descriptor rather than the value, which ensure-crypto-uuid.test.ts was not doing -- Node defines crypto as an accessor, and putting back a plain data property changes the global's shape. The jest.mock('expo-crypto') line stays per-file; jest hoists it, so sharing it would need a require() inside the factory for no real gain. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Cursor Co-authored-by: Claude Opus 4.8 --- .../core/src/highlights/__tests__/api.test.ts | 48 +++++++++++++ .../__tests__/ensure-crypto-uuid.test.ts | 62 +++++++++++++++++ packages/core/src/highlights/api.ts | 7 ++ .../core/src/highlights/ensure-crypto-uuid.ts | 69 +++++++++++++++++++ packages/core/src/test-utils/crypto-global.ts | 48 +++++++++++++ 5 files changed, 234 insertions(+) create mode 100644 packages/core/src/highlights/__tests__/ensure-crypto-uuid.test.ts create mode 100644 packages/core/src/highlights/ensure-crypto-uuid.ts create mode 100644 packages/core/src/test-utils/crypto-global.ts diff --git a/packages/core/src/highlights/__tests__/api.test.ts b/packages/core/src/highlights/__tests__/api.test.ts index acd1376b..fde50d35 100644 --- a/packages/core/src/highlights/__tests__/api.test.ts +++ b/packages/core/src/highlights/__tests__/api.test.ts @@ -1,5 +1,11 @@ +import * as Crypto from 'expo-crypto' + +import { SHIM_UUID, stubCryptoGlobal } from '../../test-utils/crypto-global' import { createHighlightsApi } from '../api' +jest.mock('expo-crypto', () => ({ randomUUID: jest.fn() })) + +const mockRandomUUID = Crypto.randomUUID as jest.Mock const mockFetch = jest.fn() beforeEach(() => { @@ -154,6 +160,7 @@ describe('createHighlightsApi', () => { expect(headers['X-YVP-App-Key']).toBe('appkey') expect(headers['X-YVP-Installation-Id']).toBe('inst-1') const body = JSON.parse(init.body as string) as { + request_id: string highlight: { bible_id: number; passage_id: string; color: string } } expect(body.highlight).toEqual({ @@ -161,6 +168,47 @@ describe('createHighlightsApi', () => { passage_id: 'JHN.3.16', color: 'fffe00', }) + // The API requires request_id to be a valid UUID (non-UUIDs 422). The + // ensure-crypto-uuid shim guarantees crypto.randomUUID exists on RN so + // platform-core mints a real one rather than its yvp- fallback. + expect(body.request_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ) + }) + + // The assertion above passes on any runtime that already has + // crypto.randomUUID — which Node ≥ 19 (and therefore CI) does — so on its own + // it cannot tell whether createHighlightsApi actually installs the shim. + // Dropping the global reproduces RN Hermes and pins the wiring: the id must + // come from expo-crypto, via the shim, on the way into platform-core. + describe('on a runtime with no crypto global (RN Hermes)', () => { + let restoreCrypto: () => void + + beforeEach(() => { + restoreCrypto = stubCryptoGlobal(undefined) + mockRandomUUID.mockReturnValue(SHIM_UUID) + }) + + afterEach(() => { + restoreCrypto() + }) + + it('mints request_id from expo-crypto instead of the yvp- fallback', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ bible_id: 111, passage_id: 'JHN.3.16', color: 'fffe00' }), + ) + + await api().createHighlight('tok', { + version_id: 111, + passage_id: 'JHN.3.16', + color: 'fffe00', + }) + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(init.body as string) as { request_id: string } + expect(body.request_id).toBe(SHIM_UUID) + expect(mockRandomUUID).toHaveBeenCalled() + }) }) it('returns auth failure for 401 without throwing', async () => { diff --git a/packages/core/src/highlights/__tests__/ensure-crypto-uuid.test.ts b/packages/core/src/highlights/__tests__/ensure-crypto-uuid.test.ts new file mode 100644 index 00000000..8598c11e --- /dev/null +++ b/packages/core/src/highlights/__tests__/ensure-crypto-uuid.test.ts @@ -0,0 +1,62 @@ +import * as Crypto from 'expo-crypto' + +import { cryptoGlobal, SHIM_UUID, stubCryptoGlobal } from '../../test-utils/crypto-global' +import { ensureCryptoRandomUUID } from '../ensure-crypto-uuid' + +jest.mock('expo-crypto', () => ({ randomUUID: jest.fn() })) + +const mockRandomUUID = Crypto.randomUUID as jest.Mock + +let restoreCrypto: (() => void) | undefined + +beforeEach(() => { + jest.clearAllMocks() + mockRandomUUID.mockReturnValue(SHIM_UUID) +}) + +afterEach(() => { + restoreCrypto?.() + restoreCrypto = undefined +}) + +describe('ensureCryptoRandomUUID', () => { + it('installs randomUUID backed by expo-crypto when no crypto global exists (RN Hermes)', () => { + restoreCrypto = stubCryptoGlobal(undefined) + + ensureCryptoRandomUUID() + + expect(typeof cryptoGlobal()?.randomUUID).toBe('function') + expect(cryptoGlobal()?.randomUUID?.()).toBe(SHIM_UUID) + expect(mockRandomUUID).toHaveBeenCalledTimes(1) + }) + + it('adds randomUUID when crypto exists but lacks it', () => { + restoreCrypto = stubCryptoGlobal({}) + + ensureCryptoRandomUUID() + + expect(typeof cryptoGlobal()?.randomUUID).toBe('function') + expect(cryptoGlobal()?.randomUUID?.()).toBe(SHIM_UUID) + }) + + it('never overrides a native crypto.randomUUID (browser, Node ≥ 19, fuller polyfill)', () => { + const native = jest.fn(() => 'native-uuid') + restoreCrypto = stubCryptoGlobal({ randomUUID: native }) + + ensureCryptoRandomUUID() + + expect(cryptoGlobal()?.randomUUID).toBe(native) + expect(native()).toBe('native-uuid') + expect(mockRandomUUID).not.toHaveBeenCalled() + }) + + it('is idempotent — a second call keeps the first shim', () => { + restoreCrypto = stubCryptoGlobal(undefined) + + ensureCryptoRandomUUID() + const first = cryptoGlobal()?.randomUUID + ensureCryptoRandomUUID() + + expect(cryptoGlobal()?.randomUUID).toBe(first) + }) +}) diff --git a/packages/core/src/highlights/api.ts b/packages/core/src/highlights/api.ts index 520c74cb..29ef26e5 100644 --- a/packages/core/src/highlights/api.ts +++ b/packages/core/src/highlights/api.ts @@ -10,6 +10,7 @@ import { import { DEFAULT_API_HOST } from '../constants' import { err, ok, type Result } from '../result' +import { ensureCryptoRandomUUID } from './ensure-crypto-uuid' export type { Collection, CreateHighlight, DeleteHighlightOptions, GetHighlightsOptions, Highlight } @@ -45,6 +46,12 @@ export type HighlightsApi = { } export function createHighlightsApi(config: CreateHighlightsApiConfig): HighlightsApi { + // platform-core's createHighlight generates the required `request_id` via + // `crypto.randomUUID`, absent on RN Hermes. Install the expo-crypto-backed + // shim before constructing the client so creates send a real UUID (not the + // yvp- fallback the API 422s). Idempotent; also runs on module import. + ensureCryptoRandomUUID() + const client = new HighlightsClient( new ApiClient({ appKey: config.appKey, diff --git a/packages/core/src/highlights/ensure-crypto-uuid.ts b/packages/core/src/highlights/ensure-crypto-uuid.ts new file mode 100644 index 00000000..647d33fe --- /dev/null +++ b/packages/core/src/highlights/ensure-crypto-uuid.ts @@ -0,0 +1,69 @@ +/** + * platform-core's `HighlightsClient` mints the `request_id` the highlights API + * requires on every create by calling the global `crypto.randomUUID`. On React + * Native (Hermes, Expo SDK 56) there is no global `crypto`, so it silently falls + * back to a non-UUID id (`yvp-…`) that the API rejects with HTTP 422 + * (`uuid_parsing`) — breaking every highlight create from RN. + * + * We back `crypto.randomUUID` with `expo-crypto` (already a core dependency — the + * same native UUID source used in installation-id.ts) so platform-core takes its + * intended path and sends a real RFC-4122 v4 UUID. An existing native + * `randomUUID` is never overridden. + * + * Only `randomUUID` is shimmed — the one thing platform-core needs — keeping the + * surface we add to a consumer's runtime as small as it can be. The accepted + * trade, recorded here because it cuts the other way: defining `crypto` at all + * makes `typeof crypto !== 'undefined'` true while `getRandomValues` and `subtle` + * stay undefined, so a library that gates on the object rather than the method + * takes its real branch and hits a TypeError where it previously fell back to its + * own path. Judged low-probability — maintained libraries feature-detect the + * method — and contained while this module is only reachable through the + * unexported highlights path. Revisit when `useHighlights` is exported from the + * package index and the install becomes app-wide. See YPE-4192. + * + * Idempotent and self-installing on import — mirrors ui/lib/dom-local-storage.ts. + * Import (and/or call) this before any platform-core client runs; api.ts does both. + * + * Long-term fix is upstream: platform-core should accept an injectable request_id + * generator or emit a UUID-shaped fallback (see the request_id 422 bug). Remove + * this shim once that ships. + */ +import * as Crypto from 'expo-crypto' + +type CryptoLike = { randomUUID?: () => string } + +/** + * Ensures `globalThis.crypto.randomUUID` exists, backed by expo-crypto. No-ops + * when the runtime already provides it (browsers, Node ≥ 19, a fuller polyfill). + * Safe to call repeatedly and from any entry point. + */ +export function ensureCryptoRandomUUID(): void { + const scope = globalThis as { crypto?: CryptoLike } + + // Defining/assigning a global can throw in locked-down runtimes. A failure + // here must never take down a create — platform-core just keeps its own + // fallback, which is no worse than not having attempted the shim. + try { + if (scope.crypto == null) { + Object.defineProperty(scope, 'crypto', { + value: {}, + configurable: true, + writable: true, + }) + } + + // The null half of this guard is for the type-checker, not for runtime + // paranoia: `crypto` is optional on `scope` and TypeScript does not narrow + // through `Object.defineProperty`. Dropping it needs a non-null assertion, + // which is an ESLint error in source (see AGENTS.md, Code Style). + const cryptoScope = scope.crypto + if (cryptoScope != null && typeof cryptoScope.randomUUID !== 'function') { + cryptoScope.randomUUID = () => Crypto.randomUUID() + } + } catch { + // Intentionally swallowed — see above. + } +} + +// Self-install on import so the shim is in place before any platform-core code runs. +ensureCryptoRandomUUID() diff --git a/packages/core/src/test-utils/crypto-global.ts b/packages/core/src/test-utils/crypto-global.ts new file mode 100644 index 00000000..84af64ba --- /dev/null +++ b/packages/core/src/test-utils/crypto-global.ts @@ -0,0 +1,48 @@ +/** + * Shared fixture for the suites that exercise the `crypto.randomUUID` shim + * (see highlights/ensure-crypto-uuid.ts). Both of them have to fake a runtime + * without a crypto global, because the test runner's own Node always has one. + * + * Restoring lives here rather than in each suite on purpose: `globalThis.crypto` + * is process-wide, so a teardown that misses would leak a stubbed crypto into + * every suite that runs after it. + */ + +export type CryptoLike = { randomUUID?: () => string } + +/** The id the mocked `expo-crypto` returns, so assertions can pin the source. */ +export const SHIM_UUID = '11111111-1111-4111-8111-111111111111' + +/** Reads the current crypto global without repeating the cast in every suite. */ +export function cryptoGlobal(): CryptoLike | undefined { + return (globalThis as { crypto?: CryptoLike }).crypto +} + +/** + * Replaces `globalThis.crypto` for the duration of a test and returns the undo. + * Pass `undefined` to reproduce RN Hermes, or an object to model a runtime with + * a partial (or complete) crypto. + * + * The global is replaced wholesale rather than mutated, so anything the shim + * assigns lands on the stub and the runtime's real crypto is never touched. The + * original property descriptor is restored — not just its value — because Node + * defines `crypto` as an accessor, and putting a plain data property back in its + * place would quietly change the global's shape for later suites. + */ +export function stubCryptoGlobal(value: CryptoLike | undefined): () => void { + const original = Object.getOwnPropertyDescriptor(globalThis, 'crypto') + + Object.defineProperty(globalThis, 'crypto', { + value, + configurable: true, + writable: true, + }) + + return () => { + if (original) { + Object.defineProperty(globalThis, 'crypto', original) + } else { + delete (globalThis as { crypto?: unknown }).crypto + } + } +} From 76219459d656076e177521a3c7c436b2bb438aec Mon Sep 17 00:00:00 2001 From: Dustin Kelley <141975656+Dustin-Kelley@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:21:33 -0500 Subject: [PATCH 04/43] =?UTF-8?q?feat(core):=20useHighlights=20=E2=80=94?= =?UTF-8?q?=20optimistic=20highlight=20writes=20over=20an=20instant=20cach?= =?UTF-8?q?e=20(YPE-3708)=20(3/3)=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: add .claude/ to .gitignore to exclude Claude-related files from version control * feat(core): wrap platform-core HighlightsClient with RN token auth Adopt @youversion/platform-core@2.3.0 so native can get/create/delete highlights with an explicit access token and typed Result failures (auth vs transient), without exporting the surface from the package index yet. Co-authored-by: Cursor * fix(example): request highlights as AuthPermission, not a scope The auth server drops unknown OIDC scopes; wire permissions:['highlights'] and keep createHighlightsApi off the package barrel (relative example import). Also restore main .gitignore (drop unrelated .claude ignore) and harden createHighlight failure-path tests. Co-authored-by: Cursor * revert(example): remove local highlights Profile harness from PR Dev-only simulator buttons and permissions wiring were for local testing, not part of YPE-4169. Co-authored-by: Cursor * chore: add changeset for internal highlights client wrapper Co-authored-by: Cursor * refactor(core): use descriptive Result generic names Rename single-letter type params to Value/Error for clearer intent. Co-authored-by: Cursor * test(core): cover 5xx paths for create and delete highlights Co-authored-by: Cursor * chore: update .gitignore to include .claude/ directory for exclusion * feat(core): add MMKV Server Colors highlights cache Sync get/set/clear for Highlight Scope snapshots so Subtask 3 can hydrate without fetching in this layer; purge all yvp.highlights.* keys on sign-out. Co-authored-by: Cursor * fix(core): shim crypto.randomUUID so RN highlight creates send a valid request_id platform-core's HighlightsClient mints the API-required request_id via the global crypto.randomUUID, absent on RN Hermes (Expo SDK 56). It silently falls back to a yvp- id the highlights API rejects with 422 (uuid_parsing), breaking every create from React Native (reads/deletes carry no request_id and are unaffected). Install an idempotent, self-installing shim backing crypto.randomUUID with expo-crypto (already a core dep; the same native UUID source used in installation-id.ts) before any platform-core client runs, so creates send a real RFC-4122 v4 UUID. Mirrors ui/lib/dom-local-storage.ts. Only randomUUID is shimmed; a native implementation is never overridden. Bridge until platform-core exposes an injectable request_id generator upstream. Co-Authored-By: Claude Opus 4.8 * test(core): pin the crypto shim wiring in the highlight create path The existing request_id assertion passes on any runtime that already has crypto.randomUUID, which Node >= 19 and therefore CI does, so it could not tell whether createHighlightsApi installs the shim at all. Add a case that drops the crypto global to reproduce RN Hermes and asserts the id came from expo-crypto. Verified to have teeth: commenting out the ensureCryptoRandomUUID call fails it with the production symptom, "yvp-19fa3d69052-637b519a", while the pre-existing regex assertion still passes. Also record why the null half of the cryptoScope guard stays: it is there for TypeScript narrowing, not runtime paranoia, and removing it needs a non-null assertion that ESLint rejects in source. Co-Authored-By: Claude Opus 5 * fix(core): shim crypto.randomUUID so RN highlight creates send a valid request_id platform-core's HighlightsClient mints the API-required request_id via the global crypto.randomUUID, absent on RN Hermes (Expo SDK 56). It silently falls back to a yvp- id the highlights API rejects with 422 (uuid_parsing), breaking every create from React Native (reads/deletes carry no request_id and are unaffected). Install an idempotent, self-installing shim backing crypto.randomUUID with expo-crypto (already a core dep; the same native UUID source used in installation-id.ts) before any platform-core client runs, so creates send a real RFC-4122 v4 UUID. Mirrors ui/lib/dom-local-storage.ts. Only randomUUID is shimmed; a native implementation is never overridden. Bridge until platform-core exposes an injectable request_id generator upstream. Co-Authored-By: Claude Opus 4.8 * test(core): pin the crypto shim wiring in the highlight create path The existing request_id assertion passes on any runtime that already has crypto.randomUUID, which Node >= 19 and therefore CI does, so it could not tell whether createHighlightsApi installs the shim at all. Add a case that drops the crypto global to reproduce RN Hermes and asserts the id came from expo-crypto. Verified to have teeth: commenting out the ensureCryptoRandomUUID call fails it with the production symptom, "yvp-19fa3d69052-637b519a", while the pre-existing regex assertion still passes. Also record why the null half of the cryptoScope guard stays: it is there for TypeScript narrowing, not runtime paranoia, and removing it needs a non-null assertion that ESLint rejects in source. Co-Authored-By: Claude Opus 5 * docs(core): record the accepted trade-off in the crypto shim header The header claimed a partial getRandomValues/subtle shim would be a worse footgun than a missing one, which inverts the YPE-4192 risk analysis and does not hold up: expo-crypto's getRandomValues is a real native CSPRNG, and defining globalThis.crypto at all already creates the partial surface. State the actual trade instead -- a library gating on the crypto object rather than the method loses its fallback -- why it is accepted, and when to revisit. Addresses review feedback on the ticket/code divergence. Co-Authored-By: Claude Opus 5 * test(core): share one crypto-global fixture between the shim suites SHIM_UUID, the crypto teardown, and the globalThis cast were duplicated across api.test.ts and ensure-crypto-uuid.test.ts. Move them to src/test-utils, beside the ui package's existing test-utils and outside __tests__ so jest does not collect the helper as a suite. Centralizing the teardown is the real win: globalThis.crypto is process-wide, so a restore that misses leaks a stubbed crypto into later suites. The shared helper also restores the original property descriptor rather than the value, which ensure-crypto-uuid.test.ts was not doing -- Node defines crypto as an accessor, and putting back a plain data property changes the global's shape. The jest.mock('expo-crypto') line stays per-file; jest hoists it, so sharing it would need a require() inside the factory for no real gain. Co-Authored-By: Claude Opus 5 * feat(core): useHighlights — optimistic highlight writes over an instant cache (YPE-3708) (3/3) Composes the client wrapper (1/3) and the MMKV cache (2/3) into the public hook. Paints from the cache synchronously on first render, applies and removes optimistically, reconciles against the server, and reverts writes that fail. Overlay math lives in a pure, React-free `optimistic.ts` ported from the web highlights machine, so both SDKs agree on what the user sees mid-write: - per-op ownership tokens, so a slow failure cannot wipe paint a newer write put down; - remove overlays that survive a stale replica echoing back the colour we just deleted ("vapor"), with a colour-aware retirement rule that is a deliberate, one-line-revertible divergence from web — see ADR 0013; - web's wire pattern: ranged POSTs per contiguous run, one DELETE per verse. Writes hold through the token-loading window rather than reporting not-signed-in for a user who is genuinely signed in, `error` stays fetch-only so a write failure cannot evict a live fetch error, and non-auth 4xx re-classify as `invalid` rather than `transient` so a permanent failure stops presenting as flaky network. Beyond the ticket, called out as intentional: `refresh()` and `isRefreshing` (named for "a GET is in flight" — `highlights` is always safe to render). The API wrapper, the MMKV cache, and the Result seam stay internal. Co-Authored-By: Claude Opus 5 * refactor(core): unify the highlight write path and close two test gaps Review follow-ups on the useHighlights commit. The apply and remove branches carried the same tally loop and remove built its passage id by hand, which contradicted the comment above it claiming both paths route through the same helper. Both now derive `{passageId, verses}` units and share one tally, so the ternary that picks ranged POSTs vs per-verse DELETEs really is the single call site to change if range DELETE is ever confirmed server-side. Rename `normalizeVerses` to `normalizeVerseSelection`: the plan rejected porting web's `normalizeVerses`, which is a different function private to `verse-share.ts`. Ours exists because writes need the canonical verse list while `collapseVerseRuns` yields runs — the header now says so. Adds the two tests the plan asked for and the first pass missed: a layer-1 reset case (asserting a cleared writeIntent makes a stale-scope settle a no-op) and a refresh landing mid-write, which must reconcile rather than clobber the optimistic overlay. Co-Authored-By: Claude Opus 5 * docs(core): update useHighlights hook version to patch * refactor(core): remove internal comments from index.ts and streamline exports * fix(core): guard user identity in useHighlights hook * refactor(core): improve state management in useHighlights hook --------- Co-authored-by: Cursor Co-authored-by: Claude Opus 4.8 --- .changeset/core-use-highlights.md | 13 + AGENTS.md | 12 +- CONTEXT.md | 10 + README.md | 1 + ...0013-native-highlights-optimistic-layer.md | 74 + packages/core/README.md | 32 + .../src/highlights/__tests__/exports.test.ts | 35 + .../highlights/__tests__/optimistic.test.ts | 514 +++++++ .../__tests__/use-highlights.test.tsx | 1269 +++++++++++++++++ packages/core/src/highlights/constants.ts | 22 + packages/core/src/highlights/index.ts | 11 + packages/core/src/highlights/optimistic.ts | 348 +++++ .../core/src/highlights/use-highlights.ts | 536 +++++++ packages/core/src/index.ts | 13 + 14 files changed, 2889 insertions(+), 1 deletion(-) create mode 100644 .changeset/core-use-highlights.md create mode 100644 docs/adr/0013-native-highlights-optimistic-layer.md create mode 100644 packages/core/src/highlights/__tests__/exports.test.ts create mode 100644 packages/core/src/highlights/__tests__/optimistic.test.ts create mode 100644 packages/core/src/highlights/__tests__/use-highlights.test.tsx create mode 100644 packages/core/src/highlights/optimistic.ts create mode 100644 packages/core/src/highlights/use-highlights.ts diff --git a/.changeset/core-use-highlights.md b/.changeset/core-use-highlights.md new file mode 100644 index 00000000..47503f6b --- /dev/null +++ b/.changeset/core-use-highlights.md @@ -0,0 +1,13 @@ +--- +'@youversion/platform-react-native-expo-core': patch +--- + +Add `useHighlights`, the public hook for reading and writing Bible highlights on native. + +It paints from the MMKV cache synchronously on first render (no blank frame on a cold start), applies and removes optimistically, reconciles against the server, and reverts writes that fail. `apply(color, verses)` and `remove(color, verses)` return a typed `HighlightWriteOutcome` — `ok`, `noop`, 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. + +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), and the `HighlightScope` / `ServerColors` / `Highlight` types. + +Two additions beyond the original ticket, called out so they read as intentional: `refresh()` for pull-to-refresh (it pairs with `isRefreshing`, which is safe to hand straight to `RefreshControl`), and `isRefreshing` is named for "a GET is in flight" rather than `isLoading` — `highlights` is always safe to render, so gating a spinner on it would reintroduce the blank frame the cache exists to prevent. + +The highlights API wrapper and the MMKV cache stay internal; `useHighlights` is the whole public surface. diff --git a/AGENTS.md b/AGENTS.md index 0d298967..831bf603 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,7 @@ Keep `apps/example/metro.config.js` minimal — just `getDefaultConfig(__dirname **UI** (`@youversion/platform-react-native-expo-ui`): `YouVersionProvider`, `BibleCard`, `BibleChapterPickerSheet`, `BibleReader`, `BibleReaderSettingsSheet`, `BibleTextView`, `BibleVersionPickerSheet`, `VerseOfTheDay`, and `YouVersionAuthButton` -**Core** (`@youversion/platform-react-native-expo-core`): `YouVersionProvider` (installation id + optional auth), `useYouVersion`, `useYVAuth`, `mmkvStorage`, and auth types (`AuthConfig`, `AuthPermission`, `AuthScope`, `YVUserInfo`) +**Core** (`@youversion/platform-react-native-expo-core`): `YouVersionProvider` (installation id + optional auth), `useYouVersion`, `useYVAuth`, `useHighlights`, `deriveServerColors`, `HIGHLIGHT_COLORS` / `isHighlightColor`, `mmkvStorage`, auth types (`AuthConfig`, `AuthPermission`, `AuthScope`, `YVUserInfo`), and highlights types (`Highlight`, `HighlightScope`, `ServerColors`, `HighlightWriteOutcome`, `HighlightsFetchError`, `UseHighlightsOptions`, `UseHighlightsResult`) UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider`. Import Bible components from UI; import `useYVAuth` from core. @@ -158,6 +158,16 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider - 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. +## Highlights (core) + +- `useHighlights({ versionId, book, chapter })` is the whole public surface. The `createHighlightsApi` wrapper over `@youversion/platform-core`'s `HighlightsClient`, the MMKV cache, and the local `Result` seam (`packages/core/src/result.ts`) all stay internal. +- Requires `auth` on `YouVersionProvider` and the `highlights` **permission** (see the permissions note above — highlights go in `requested_permissions[]`, never in `scope`). With no auth configured it behaves exactly as signed out. +- Paints from the MMKV cache **synchronously** in a `useState` initializer. That only works because `AuthProvider` seeds `userInfo` from its own initializer, so `userInfo.id` exists on the first render — load-bearing coupling, commented at both ends. +- `highlights` is always safe to render. `isRefreshing` means "a GET is in flight", never "no data yet"; gating a spinner on it reintroduces the blank first frame the cache exists to prevent. +- `error` is **fetch-only**. Writes report once, through the `HighlightWriteOutcome` they resolve to — that is also C3's branch point for the sign-in prompt (`reason === 'auth'` / `'not-signed-in'`). +- The five swatches in `HIGHLIGHT_COLORS` are a company standard enforced in core: both `apply` and `remove` reject anything else as `invalid` before painting or issuing a request. Do not relax this on layering grounds — the open improvement is relocating the palette to `@youversion/platform-core`, not deferring it to the UI layer. +- Overlay math lives in the pure, React-free `packages/core/src/highlights/optimistic.ts`, ported from the web highlights machine. Ownership tokens and the colour-aware overlay retirement rule are documented in [ADR 0013](docs/adr/0013-native-highlights-optimistic-layer.md); the retirement rule reads like a bug in both directions and is defended only by its regression pair, so read the ADR before touching `shouldRetire`. + ## Runtime Dependencies **UI** bundles: `@radix-ui/react-use-controllable-state`, `@rn-primitives/portal`, `zustand`, `@youversion/platform-react-hooks`, `@youversion/platform-react-ui`, and `@youversion/platform-react-native-expo-core`. diff --git a/CONTEXT.md b/CONTEXT.md index 7909b75a..0d30b83d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -110,6 +110,14 @@ _Avoid_: Persisting this shape (it destroys passage ids — see **Cached Highlig The raw core API shape (`Highlight[]`: `version_id` + `passage_id` + `color`) persisted on native per `userId` + **Highlight Scope**. Passage ids may be verse ranges (`JHN.3.16-18`), so this is the only shape that can feed the web reader's controlled `highlights` prop on a cold start and that supports passage-id-targeted deletes. Reads are synchronous and validated; a valid empty array is a real snapshot (“none”), not a cache miss, and any corrupt or legacy payload reads as a miss. _Avoid_: Flattening to **Server Colors** before writing; treating an empty array as a miss +**Highlight Overlay**: +The local layer of pending edits for a **Highlight Scope**, `Record` — a hex color where the user just applied one, `null` where they just removed one. Sits on top of **Server Colors** so the reader paints before the server answers; entries retire once the server confirms them (see [ADR 0013](docs/adr/0013-native-highlights-optimistic-layer.md) for the color-aware remove rule). Never persisted — see **Cached Highlights**. +_Avoid_: Optimistic state (too vague — this is one specific layer), **Server Colors** (the layer underneath), persisting it + +**Highlight Write Outcome**: +What an `apply` or `remove` resolves to: `ok` with the verses that landed, `noop` when there was nothing to write, or `error` carrying a `reason` (`not-signed-in` / `auth` / `invalid` / `transient`), a diagnostic `message`, and both `failedVerses` (reverted) and `succeededVerses` (landed — non-empty means a partial batch). The only channel a write failure reports on; the hook's `error` state is for fetches alone, so a failed write can never evict a fetch error that is still true. +_Avoid_: Branching on `message` (generic outside development builds); routing write failures through the hook's `error`; a separate `partial` status (the two verse arrays already say it) + ## Relationships - A **React Web SDK Component** may expose reusable content that can be rendered by an **Expo DOM Component**. @@ -134,6 +142,8 @@ _Avoid_: Flattening to **Server Colors** before writing; treating an empty array - The **SDK Attribution Header** depends on **Compiled Distribution**: because published builds run from `build/` while dev runs from `src/`, the publish-time stamp can give the two different channel signals from one source file. - A **Highlight Scope** identifies the chapter for highlights (web-compatible location triple). Native persists **Cached Highlights** keyed by `userId` + **Highlight Scope**; without a known `userId`, the cache does not read or write. This is **Native-Owned State**, distinct from **Reader Location**. - **Server Colors** are derived from **Cached Highlights** for a given **Highlight Scope**, never stored: entries whose version, book, or chapter does not match the scope are ignored, so stale data cannot mispaint. +- A **Highlight Overlay** sits on top of **Server Colors** and is the only optimistic layer in the stack — the web reader's controlled `highlights` prop is pure projection. Each write claims the verses it paints, and a settling write only reverts verses it still owns. +- A **Highlight Write Outcome** is the sole report of a write's fate, and is where C3's sign-in branch reads from. ## Example Dialogue diff --git a/README.md b/README.md index 549fb7c7..19f513ac 100644 --- a/README.md +++ b/README.md @@ -29,6 +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`) - **Theming**: `light` / `dark` / `system` themes, with per-component overrides - **Native presentation**: footnotes, chapter, and version pickers open in native bottom sheets via `@gorhom/bottom-sheet` diff --git a/docs/adr/0013-native-highlights-optimistic-layer.md b/docs/adr/0013-native-highlights-optimistic-layer.md new file mode 100644 index 00000000..73b54759 --- /dev/null +++ b/docs/adr/0013-native-highlights-optimistic-layer.md @@ -0,0 +1,74 @@ +# 13. Native highlights are the optimistic layer, with colour-aware overlay retirement + +Date: 2026-07-27 + +## Status + +Accepted + +## Context + +`useHighlights` (YPE-3708) is the native data layer for Bible highlights: it paints from the MMKV cache on first render, applies and removes optimistically, reconciles against the server, and reverts what fails. + +The web SDK already solved this once. `bible-reader-highlights-machine.ts` in `platform-sdk-react` is an xstate statechart that unifies the highlight auth/dialog flow with an optimistic write queue. It is internal-only and imports web-specific modules, so it cannot be reused directly — but its semantics are hard-won and re-deriving them would reproduce its bugs. + +Two constraints frame the decision: + +- **This is the only optimistic layer in the stack.** W1's ADR for the reader is explicit that the controlled `highlights` prop is pure projection with no optimistic echo. If native does not own it, nothing does. +- **Output crosses the native/DOM bridge as a serialized prop**, so state transitions that change nothing must return the same object. + +## Decision + +Port the web machine's overlay math into a pure, React-free module (`packages/core/src/highlights/optimistic.ts`) and drive it from a hook. Three semantics are adopted deliberately; one diverges. + +### Adopted: per-op ownership tokens + +Every write allocates a fresh token object and stamps the verses it claims. A settling write only touches verses it _still_ owns, compared by object identity. + +Without this: tap yellow on verse 16; before that POST returns, tap green on 16; then yellow's POST fails. Yellow's revert would delete the overlay entry and wipe the green the user is currently looking at, over a failure that has nothing to do with it. + +### Adopted: a promise chain instead of a write queue + +The web machine maintains an explicit queue because xstate cannot `await`. A promise chain is inherently FIFO and needs no queue state. Optimistic paint still lands synchronously; only the network writes serialize. + +### Adopted: web's range pattern on the wire + +Applies collapse contiguous verses into one ranged POST per run (`[16,17,18,20]` → `JHN.3.16-18` + `JHN.3.20`); removes issue one DELETE per verse, never a range, because range DELETE is not supported server-side. Both paths route through `collapseVerseRuns`, so switching removes to one-per-run is a single call site if that changes. + +### Diverged: colour-aware retirement of remove overlays + +Web's `reconcileOverlay` never retires a remove entry: + +```ts +if (entry.op !== 'apply') continue // remove entries never retire (vapor fix) +``` + +That fixes a real bug — a stale read replica echoing back the colour just deleted repaints the verse for a beat ("vapor") — but the suppression is opaque and unbounded. It holds until a reset path runs, so a _new_ colour set on another device stays invisible until the user navigates away and back. Web's own header states this as an accepted cost. + +`ReconcileEntry` already carries the colour, and web simply ignores it for removes. So we keep the fix and drop most of the cost: + +```ts +function shouldRetire(entry: ReconcileEntry, serverColor: string | undefined): boolean { + if (entry.op === 'apply') return serverColor === entry.color + // Remove: the vapor case is the server echoing back the colour we deleted. + // A DIFFERENT colour cannot be an echo of that deletion — it is newer data. + return serverColor !== undefined && serverColor !== entry.color +} +``` + +The failure mode this introduces is strictly narrower than the one it fixes: verse was green → user set yellow → user removed it → a replica stale enough to still report _green_ retires the overlay and briefly paints green. That needs the server two steps behind rather than one. + +**Reverting to web's behaviour is `return false` in the remove branch.** It is a single named function for exactly that reason. + +### Not ported: the permission flow + +Web's settle routes a 401/403 into invalidate → re-stash pending highlight → re-prompt. That is C3 (YPE-3709). Here, failure handling stops at revert + classify, and the returned `HighlightWriteOutcome.reason === 'auth'` is C3's branch point. + +## Consequences + +- Native and web agree on what the user sees mid-write, and the shared vocabulary (`claim` / `settle` / reconcile / ownership token) survives in both codebases. Anyone diffing the two files finds the divergence documented rather than having to reverse-engineer whether it was deliberate. +- The colour-aware rule needs both directions pinned by tests, because it reads like a bug in each direction: a stale GET echoing the deleted colour must **not** resurrect the verse, and a GET reporting a different colour **must** retire the overlay. +- Two smaller decisions follow from the same "one optimistic layer" premise and are recorded here because reviewers ask about both: + - **`error` is fetch-only.** Writes report once, through their return value. With one error slot, a transient write failure would evict a fetch error that is still true (the reader is showing stale cached data _because_ the GET failed), and a consumer with both a call-site handler and an error banner would render two UIs for one event. + - **Writes hold through the token-loading window** on `accessToken !== null || !isLoading`, never on `isLoading` alone — `postTokenEndpoint` has no `AbortController`, so a hung network can leave `isLoading` true indefinitely. Without the hold, a cold-start write returns `not-signed-in` for a genuinely signed-in user, which is the exact value C3 branches on to launch a sign-in prompt. +- The cache stores server truth only. A _confirmed_ write would be safe to persist — this is a cost decision, not a correctness one: merging a remove into cached ranges drags range-splitting onto the write path to fix a flash that requires the app to die inside a one-request window. F1's offline write queue will need exactly that machinery. diff --git a/packages/core/README.md b/packages/core/README.md index 8c09b112..8bd12722 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -10,6 +10,7 @@ Use `@youversion/platform-react-native-expo-core` when you need: - ✅ Sign-in via optional PKCE OAuth (`useYVAuth`, `auth` config on the provider) - ✅ Token storage handled for you (`expo-secure-store` + MMKV) +- ✅ Bible highlights with optimistic writes and an instant local cache (`useHighlights`) ❌ Want ready-made Bible UI instead? Use [@youversion/platform-react-native-expo-ui](https://www.npmjs.com/package/@youversion/platform-react-native-expo-ui). @@ -51,6 +52,37 @@ export default function App() { } ``` +### Highlights + +`useHighlights` gives you a chapter's highlights, cached locally so they paint on the first frame, and write functions that apply optimistically and roll back on failure. + +```tsx +import { useHighlights, HIGHLIGHT_COLORS } from '@youversion/platform-react-native-expo-core' + +function Reader() { + const { highlights, apply, remove, isRefreshing, refresh } = useHighlights({ + versionId: 111, + book: 'JHN', + chapter: '3', + }) + + async function highlightVerse() { + const outcome = await apply(HIGHLIGHT_COLORS[0], [16, 17]) + if (outcome.status === 'error' && outcome.reason === 'not-signed-in') { + // Prompt for sign-in, then retry outcome.failedVerses. + } + } + + return +} +``` + +`highlights` is one entry per verse, ready for the reader's controlled `highlights` prop, and is always safe to render — `isRefreshing` only means a network refresh is in flight, so pair it with `RefreshControl` rather than gating a spinner on it. + +Writes resolve to a typed outcome rather than throwing: `{ status: 'ok', verses }`, `{ status: 'noop' }`, or `{ status: 'error', reason, message, failedVerses, succeededVerses }` where `reason` is `'not-signed-in' | 'auth' | 'invalid' | 'transient'`. Branch on `reason`, not `message` — the message is generic outside development builds. `failedVerses` is what to retry; `succeededVerses` being non-empty alongside it means the batch partly landed. + +Requires an `auth` config on the provider plus the `highlights` permission, and only the five colors in `HIGHLIGHT_COLORS` are accepted. Signed out, `highlights` is empty and writes return `reason: 'not-signed-in'` without touching state. + ## License This SDK is licensed under [Apache 2.0](./LICENSE). diff --git a/packages/core/src/highlights/__tests__/exports.test.ts b/packages/core/src/highlights/__tests__/exports.test.ts new file mode 100644 index 00000000..17ab675c --- /dev/null +++ b/packages/core/src/highlights/__tests__/exports.test.ts @@ -0,0 +1,35 @@ +/** + * Guards the public API surface: `useHighlights` and the projection helper must + * be reachable from the package index, while the API wrapper, the MMKV cache, + * and the `Result` seam must not be — those are internals a consumer coupling to + * would pin us out of the S1 library decision. + */ +import * as core from '../../index' + +jest.mock('../../storage/mmkv-storage', () => ({ + mmkvStorage: { + set: jest.fn(), + getString: jest.fn(), + remove: jest.fn(), + getAllKeys: jest.fn(() => []), + }, +})) + +describe('package exports', () => { + it('exposes the highlights hook and the palette', () => { + expect(typeof core.useHighlights).toBe('function') + expect(typeof core.deriveServerColors).toBe('function') + expect(typeof core.isHighlightColor).toBe('function') + expect(core.HIGHLIGHT_COLORS).toEqual(['fffe00', '5dff79', '00d6ff', 'ffc66f', 'ff95ef']) + }) + + it('keeps the client wrapper, the cache, and the Result seam internal', () => { + const names = Object.keys(core) + expect(names).not.toContain('createHighlightsApi') + expect(names).not.toContain('getCachedHighlights') + expect(names).not.toContain('setCachedHighlights') + expect(names).not.toContain('clearHighlightsCache') + expect(names).not.toContain('ok') + expect(names).not.toContain('err') + }) +}) diff --git a/packages/core/src/highlights/__tests__/optimistic.test.ts b/packages/core/src/highlights/__tests__/optimistic.test.ts new file mode 100644 index 00000000..11df2943 --- /dev/null +++ b/packages/core/src/highlights/__tests__/optimistic.test.ts @@ -0,0 +1,514 @@ +import { deriveServerColors } from '../cache' +import { HIGHLIGHT_COLORS, isHighlightColor, type HighlightScope } from '../constants' + +import { + claim, + collapseVerseRuns, + createOptimisticState, + createWriteToken, + formatPassageId, + normalizeVerseSelection, + selectHighlights, + selectMergedColors, + selectVersesInColor, + serverColorsEqual, + serverUpdated, + settle, + shouldRetire, + versesInRun, + type OptimisticState, +} from '../optimistic' + +// `optimistic.ts` is pure, but the round-trip assertions below reach for +// `deriveServerColors`, and importing `cache.ts` boots the real MMKV native +// module. Stub the storage boundary; nothing in this file touches it. +jest.mock('../../storage/mmkv-storage', () => ({ + mmkvStorage: { + set: jest.fn(), + getString: jest.fn(), + remove: jest.fn(), + getAllKeys: jest.fn(() => []), + }, +})) + +const scope: HighlightScope = { versionId: 111, book: 'JHN', chapter: '3' } +const YELLOW = 'fffe00' +const GREEN = '5dff79' +const BLUE = '00d6ff' + +function stateWith(serverColors: Record = {}): OptimisticState { + return createOptimisticState({ scope, userId: 'user-1', serverColors }) +} + +describe('the highlight palette', () => { + // Pinning test: these five values are a company-wide standard shared with the + // web SDK's HIGHLIGHT_COLORS. Changing them is a product decision, not a + // refactor. + it('pins the five company-standard swatches', () => { + expect(HIGHLIGHT_COLORS).toEqual(['fffe00', '5dff79', '00d6ff', 'ffc66f', 'ff95ef']) + }) + + it('matches swatches case-insensitively and rejects everything else', () => { + expect(isHighlightColor('FFFE00')).toBe(true) + expect(isHighlightColor(YELLOW)).toBe(true) + expect(isHighlightColor('ff0000')).toBe(false) + expect(isHighlightColor('#fffe00')).toBe(false) + expect(isHighlightColor('')).toBe(false) + }) +}) + +describe('claim', () => { + it('paints an apply, stamps ownership, and drops a pending reconcile', () => { + const token = createWriteToken('apply') + const claimed = claim(stateWith({ 16: GREEN }), [16, 17], token, YELLOW) + + expect(claimed.overlay).toEqual({ 16: YELLOW, 17: YELLOW }) + expect(claimed.writeIntent.get(16)).toBe(token) + expect(claimed.writeIntent.get(17)).toBe(token) + expect(selectMergedColors(claimed)).toEqual({ 16: YELLOW, 17: YELLOW }) + }) + + it('paints a remove as a null overlay entry that hides server truth', () => { + const claimed = claim( + stateWith({ 16: YELLOW, 17: GREEN }), + [16], + createWriteToken('remove'), + null, + ) + + expect(claimed.overlay).toEqual({ 16: null }) + expect(selectMergedColors(claimed)).toEqual({ 17: GREEN }) + }) + + it('supersedes a pending reconcile entry for the same verse', () => { + const first = createWriteToken('apply') + const claimed = claim(stateWith(), [16], first, YELLOW) + const settled = settle(claimed, { + token: first, + op: 'apply', + color: YELLOW, + succeededVerses: [16], + failedVerses: [], + }) + expect(settled.reconcile.has(16)).toBe(true) + + const reclaimed = claim(settled, [16], createWriteToken('apply'), GREEN) + expect(reclaimed.reconcile.has(16)).toBe(false) + }) + + it('returns the same state for an empty verse list', () => { + const state = stateWith({ 16: YELLOW }) + expect(claim(state, [], createWriteToken('apply'), YELLOW)).toBe(state) + }) +}) + +describe('settle', () => { + it('keeps paint for succeeded verses and registers them for reconciliation', () => { + const token = createWriteToken('apply') + const claimed = claim(stateWith(), [16, 17], token, YELLOW) + + const settled = settle(claimed, { + token, + op: 'apply', + color: YELLOW, + succeededVerses: [16, 17], + failedVerses: [], + }) + + expect(settled.overlay).toEqual({ 16: YELLOW, 17: YELLOW }) + expect(settled.reconcile.get(16)).toEqual({ op: 'apply', color: YELLOW }) + // Settled writes release their claim so intents cannot accumulate. + expect(settled.writeIntent.size).toBe(0) + }) + + it('reverts paint for failed verses', () => { + const token = createWriteToken('apply') + const claimed = claim(stateWith({ 16: GREEN }), [16], token, YELLOW) + + const settled = settle(claimed, { + token, + op: 'apply', + color: YELLOW, + succeededVerses: [], + failedVerses: [16], + }) + + expect(settled.overlay).toEqual({}) + expect(selectMergedColors(settled)).toEqual({ 16: GREEN }) + }) + + it('restores the highlight when a remove fails', () => { + const token = createWriteToken('remove') + const claimed = claim(stateWith({ 16: YELLOW }), [16], token, null) + expect(selectMergedColors(claimed)).toEqual({}) + + const settled = settle(claimed, { + token, + op: 'remove', + color: YELLOW, + succeededVerses: [], + failedVerses: [16], + }) + + expect(selectMergedColors(settled)).toEqual({ 16: YELLOW }) + }) + + it('splits a partial batch: succeeded verses hold, failed verses revert', () => { + const token = createWriteToken('apply') + const claimed = claim(stateWith(), [16, 20], token, YELLOW) + + const settled = settle(claimed, { + token, + op: 'apply', + color: YELLOW, + succeededVerses: [16], + failedVerses: [20], + }) + + expect(selectMergedColors(settled)).toEqual({ 16: YELLOW }) + expect(settled.reconcile.get(16)).toEqual({ op: 'apply', color: YELLOW }) + expect(settled.reconcile.has(20)).toBe(false) + }) + + // AC 4 — the ownership token. This is the whole reason writeIntent exists. + it('does not clobber a newer claim when an older write fails (ownership token)', () => { + const yellowToken = createWriteToken('apply') + const greenToken = createWriteToken('apply') + + // Tap yellow on 16, then tap green on 16 before yellow's POST returns. + let state = claim(stateWith(), [16], yellowToken, YELLOW) + state = claim(state, [16], greenToken, GREEN) + expect(selectMergedColors(state)).toEqual({ 16: GREEN }) + + // Now yellow's POST fails. It no longer owns verse 16. + const settled = settle(state, { + token: yellowToken, + op: 'apply', + color: YELLOW, + succeededVerses: [], + failedVerses: [16], + }) + + expect(selectMergedColors(settled)).toEqual({ 16: GREEN }) + expect(settled.writeIntent.get(16)).toBe(greenToken) + // Nothing this op owned, so the state object is untouched. + expect(settled).toBe(state) + }) + + it('does not register a reconcile entry for a verse it no longer owns', () => { + const first = createWriteToken('apply') + const second = createWriteToken('apply') + let state = claim(stateWith(), [16], first, YELLOW) + state = claim(state, [16], second, GREEN) + + const settled = settle(state, { + token: first, + op: 'apply', + color: YELLOW, + succeededVerses: [16], + failedVerses: [], + }) + + expect(settled.reconcile.has(16)).toBe(false) + expect(settled.writeIntent.get(16)).toBe(second) + }) +}) + +describe('reset (createOptimisticState)', () => { + it('clears overlay, reconcile and write intents, and re-seeds identity', () => { + const applyToken = createWriteToken('apply') + const pending = createWriteToken('apply') + let state = settle(claim(stateWith(), [16], applyToken, YELLOW), { + token: applyToken, + op: 'apply', + color: YELLOW, + succeededVerses: [16], + failedVerses: [], + }) + // A second write is still in flight when the user navigates away. + state = claim(state, [20], pending, GREEN) + expect(state.reconcile.size).toBe(1) + expect(state.writeIntent.size).toBe(1) + + const nextScope: HighlightScope = { versionId: 111, book: 'JHN', chapter: '4' } + const reset = createOptimisticState({ + scope: nextScope, + userId: 'user-2', + serverColors: { 1: BLUE }, + }) + + expect(reset.scope).toEqual(nextScope) + expect(reset.userId).toBe('user-2') + expect(reset.overlay).toEqual({}) + expect(reset.reconcile.size).toBe(0) + // Clearing writeIntent is what stops the in-flight write from settling onto + // a colliding verse number in the new scope. + expect(reset.writeIntent.size).toBe(0) + expect( + settle(reset, { + token: pending, + op: 'apply', + color: GREEN, + succeededVerses: [20], + failedVerses: [], + }), + ).toBe(reset) + }) +}) + +describe('serverUpdated', () => { + it('retires an apply overlay once the server reports the written color', () => { + const token = createWriteToken('apply') + const claimed = claim(stateWith(), [16], token, YELLOW) + const settled = settle(claimed, { + token, + op: 'apply', + color: YELLOW, + succeededVerses: [16], + failedVerses: [], + }) + + const reconciled = serverUpdated(settled, { 16: YELLOW }) + + expect(reconciled.overlay).toEqual({}) + expect(reconciled.reconcile.size).toBe(0) + expect(selectMergedColors(reconciled)).toEqual({ 16: YELLOW }) + }) + + it('holds an apply overlay while the server still reports the old color', () => { + const token = createWriteToken('apply') + const settled = settle(claim(stateWith({ 16: GREEN }), [16], token, YELLOW), { + token, + op: 'apply', + color: YELLOW, + succeededVerses: [16], + failedVerses: [], + }) + + const reconciled = serverUpdated(settled, { 16: GREEN }) + + expect(reconciled.overlay).toEqual({ 16: YELLOW }) + expect(selectMergedColors(reconciled)).toEqual({ 16: YELLOW }) + }) + + // AC 5 — the vapor fix. A stale read replica echoing the color we just + // deleted must not resurrect the highlight. + it('never resurrects a removed verse when a stale fetch echoes the deleted color', () => { + const token = createWriteToken('remove') + const settled = settle(claim(stateWith({ 16: YELLOW }), [16], token, null), { + token, + op: 'remove', + color: YELLOW, + succeededVerses: [16], + failedVerses: [], + }) + + // The replica has not caught up: it still reports the yellow we deleted. + const reconciled = serverUpdated(settled, { 16: YELLOW }) + + expect(reconciled.overlay).toEqual({ 16: null }) + expect(selectMergedColors(reconciled)).toEqual({}) + // Still held — a later fetch gets another chance to confirm it. + expect(reconciled.reconcile.has(16)).toBe(true) + }) + + // The other half of the colour-aware retirement pair: our deliberate + // divergence from web, which would suppress this repaint indefinitely. + it('retires a remove overlay when the server reports a DIFFERENT color', () => { + const token = createWriteToken('remove') + const settled = settle(claim(stateWith({ 16: YELLOW }), [16], token, null), { + token, + op: 'remove', + color: YELLOW, + succeededVerses: [16], + failedVerses: [], + }) + + // Another device set green on this verse after our delete landed. A green + // echo cannot be vapor from deleting yellow, so it is newer data. + const reconciled = serverUpdated(settled, { 16: GREEN }) + + expect(reconciled.overlay).toEqual({}) + expect(selectMergedColors(reconciled)).toEqual({ 16: GREEN }) + }) + + it('retires a remove overlay once the verse is genuinely gone server-side', () => { + const token = createWriteToken('remove') + const settled = settle(claim(stateWith({ 16: YELLOW }), [16], token, null), { + token, + op: 'remove', + color: YELLOW, + succeededVerses: [16], + failedVerses: [], + }) + + // The plan's rule holds the overlay while the deleted color is echoed; an + // absent verse is not an echo, but it also is not "a different color" — the + // overlay stays until the server stops lying, and the rendered result is + // identical either way. + const reconciled = serverUpdated(settled, {}) + expect(selectMergedColors(reconciled)).toEqual({}) + }) + + it('returns the same object when nothing changed (bridge stability)', () => { + const state = stateWith({ 16: YELLOW }) + expect(serverUpdated(state, { 16: YELLOW })).toBe(state) + }) + + it('returns a new object when server colors change', () => { + const state = stateWith({ 16: YELLOW }) + const next = serverUpdated(state, { 16: GREEN }) + expect(next).not.toBe(state) + expect(next.serverColors).toEqual({ 16: GREEN }) + }) + + it('keeps holding entries that did not retire while retiring the ones that did', () => { + const applyToken = createWriteToken('apply') + const removeToken = createWriteToken('remove') + let state = stateWith({ 20: BLUE }) + state = settle(claim(state, [16], applyToken, YELLOW), { + token: applyToken, + op: 'apply', + color: YELLOW, + succeededVerses: [16], + failedVerses: [], + }) + state = settle(claim(state, [20], removeToken, null), { + token: removeToken, + op: 'remove', + color: BLUE, + succeededVerses: [20], + failedVerses: [], + }) + + const reconciled = serverUpdated(state, { 16: YELLOW, 20: BLUE }) + + expect(reconciled.reconcile.has(16)).toBe(false) // apply confirmed + expect(reconciled.reconcile.has(20)).toBe(true) // remove echo held + expect(selectMergedColors(reconciled)).toEqual({ 16: YELLOW }) + }) +}) + +describe('shouldRetire', () => { + it('retires an apply only on an exact color match', () => { + expect(shouldRetire({ op: 'apply', color: YELLOW }, YELLOW)).toBe(true) + expect(shouldRetire({ op: 'apply', color: YELLOW }, GREEN)).toBe(false) + expect(shouldRetire({ op: 'apply', color: YELLOW }, undefined)).toBe(false) + }) + + it('retires a remove only on a different, present color', () => { + expect(shouldRetire({ op: 'remove', color: YELLOW }, YELLOW)).toBe(false) // vapor + expect(shouldRetire({ op: 'remove', color: YELLOW }, GREEN)).toBe(true) + expect(shouldRetire({ op: 'remove', color: YELLOW }, undefined)).toBe(false) + }) +}) + +describe('serverColorsEqual', () => { + it('compares by content, not identity', () => { + expect(serverColorsEqual({ 16: YELLOW }, { 16: YELLOW })).toBe(true) + expect(serverColorsEqual({}, {})).toBe(true) + expect(serverColorsEqual({ 16: YELLOW }, { 16: GREEN })).toBe(false) + expect(serverColorsEqual({ 16: YELLOW }, { 16: YELLOW, 17: GREEN })).toBe(false) + expect(serverColorsEqual({ 16: YELLOW, 17: GREEN }, { 16: YELLOW })).toBe(false) + expect(serverColorsEqual({ 16: YELLOW }, { 17: YELLOW })).toBe(false) + }) +}) + +describe('selectHighlights', () => { + it('emits one per-verse highlight, ascending', () => { + const state = claim(stateWith({ 20: BLUE }), [16, 17], createWriteToken('apply'), YELLOW) + + expect(selectHighlights(state)).toEqual([ + { version_id: 111, passage_id: 'JHN.3.16', color: YELLOW }, + { version_id: 111, passage_id: 'JHN.3.17', color: YELLOW }, + { version_id: 111, passage_id: 'JHN.3.20', color: BLUE }, + ]) + }) + + it('omits verses the overlay removed', () => { + const state = claim( + stateWith({ 16: YELLOW, 17: GREEN }), + [16], + createWriteToken('remove'), + null, + ) + expect(selectHighlights(state)).toEqual([ + { version_id: 111, passage_id: 'JHN.3.17', color: GREEN }, + ]) + }) + + it('round-trips exactly through deriveServerColors', () => { + const state = claim(stateWith({ 20: BLUE }), [16, 17], createWriteToken('apply'), YELLOW) + expect(deriveServerColors(selectHighlights(state), scope)).toEqual(selectMergedColors(state)) + }) + + // Defensive contract test, NOT a production path: the API stores highlights + // per verse and only accepts ranges on the wire, so a GET never echoes a + // range. This guards `expandPassageId` in case that ever changes. + it('splits a range that arrives in server truth into its verses', () => { + const fromRange = deriveServerColors( + [{ version_id: 111, passage_id: 'JHN.3.16-18', color: YELLOW }], + scope, + ) + const state = claim( + createOptimisticState({ scope, userId: 'user-1', serverColors: fromRange }), + [17], + createWriteToken('remove'), + null, + ) + + expect(selectHighlights(state)).toEqual([ + { version_id: 111, passage_id: 'JHN.3.16', color: YELLOW }, + { version_id: 111, passage_id: 'JHN.3.18', color: YELLOW }, + ]) + }) +}) + +describe('selectVersesInColor', () => { + it('targets only verses the user currently sees in that color', () => { + const state = stateWith({ 16: YELLOW, 17: BLUE, 18: YELLOW }) + expect(selectVersesInColor(state, [16, 17, 18, 19], YELLOW)).toEqual([16, 18]) + }) + + it('counts optimistic paint, not just server truth', () => { + const state = claim(stateWith({ 16: BLUE }), [16], createWriteToken('apply'), YELLOW) + expect(selectVersesInColor(state, [16], YELLOW)).toEqual([16]) + expect(selectVersesInColor(state, [16], BLUE)).toEqual([]) + }) + + it('ignores verses an optimistic remove has already hidden', () => { + const state = claim(stateWith({ 16: YELLOW }), [16], createWriteToken('remove'), null) + expect(selectVersesInColor(state, [16], YELLOW)).toEqual([]) + }) +}) + +describe('USFM range helpers', () => { + it('collapses verses into contiguous runs, de-duped and sorted', () => { + expect(collapseVerseRuns([16, 17, 18])).toEqual([{ start: 16, end: 18 }]) + expect(collapseVerseRuns([4, 1, 3])).toEqual([ + { start: 1, end: 1 }, + { start: 3, end: 4 }, + ]) + expect(collapseVerseRuns([5, 5, 5])).toEqual([{ start: 5, end: 5 }]) + expect(collapseVerseRuns([])).toEqual([]) + }) + + it('drops non-positive and non-integer verse numbers', () => { + expect(collapseVerseRuns([0, -1, 2, 3.5])).toEqual([{ start: 2, end: 2 }]) + }) + + it('formats a run as a range USFM, collapsing single verses', () => { + expect(formatPassageId('JHN', '3', { start: 16, end: 18 })).toBe('JHN.3.16-18') + expect(formatPassageId('JHN', '3', { start: 5, end: 5 })).toBe('JHN.3.5') + }) + + it('expands a run back into its verses', () => { + expect(versesInRun({ start: 2, end: 4 })).toEqual([2, 3, 4]) + expect(versesInRun({ start: 7, end: 7 })).toEqual([7]) + }) + + it('normalizes a verse list through the same run machinery', () => { + expect(normalizeVerseSelection([18, 16, 16, 0, 17, 20])).toEqual([16, 17, 18, 20]) + expect(normalizeVerseSelection([])).toEqual([]) + }) +}) diff --git a/packages/core/src/highlights/__tests__/use-highlights.test.tsx b/packages/core/src/highlights/__tests__/use-highlights.test.tsx new file mode 100644 index 00000000..d0c7f8a3 --- /dev/null +++ b/packages/core/src/highlights/__tests__/use-highlights.test.tsx @@ -0,0 +1,1269 @@ +import type { Collection, Highlight } from '@youversion/platform-core' +import { act, render, renderHook } from '@testing-library/react-native' +import { Text } from 'react-native' +import type { ReactNode } from 'react' + +import { AuthContext, type AuthContextValue } from '../../auth/auth-context' +import { YouVersionContext } from '../../youversion-context' +import type { Result } from '../../result' +import type { HighlightsApiError } from '../api' +import { highlightsCacheKey, type HighlightScope } from '../constants' +import { + useHighlights, + type HighlightWriteOutcome, + type UseHighlightsResult, +} from '../use-highlights' + +// ── Boundaries ─────────────────────────────────────────────────────────────── + +const mockMmkv = new Map() + +jest.mock('../../storage/mmkv-storage', () => ({ + mmkvStorage: { + set: jest.fn((k: string, v: string) => { + mockMmkv.set(k, v) + }), + getString: jest.fn((k: string) => mockMmkv.get(k)), + remove: jest.fn((k: string) => { + mockMmkv.delete(k) + }), + getAllKeys: jest.fn(() => Array.from(mockMmkv.keys())), + has: jest.fn((k: string) => mockMmkv.has(k)), + }, +})) + +const mockGetHighlights = jest.fn() +const mockCreateHighlight = jest.fn() +const mockDeleteHighlight = jest.fn() + +jest.mock('../api', () => ({ + createHighlightsApi: jest.fn(() => ({ + getHighlights: mockGetHighlights, + createHighlight: mockCreateHighlight, + deleteHighlight: mockDeleteHighlight, + })), +})) + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const YELLOW = 'fffe00' +const GREEN = '5dff79' +const BLUE = '00d6ff' + +const scope: HighlightScope = { versionId: 111, book: 'JHN', chapter: '3' } +const options = { versionId: 111, book: 'JHN', chapter: '3' } +const userId = 'user-1' + +function highlight(passageId: string, color: string, versionId = scope.versionId): Highlight { + return { version_id: versionId, passage_id: passageId, color } +} + +function collection(data: Highlight[]): Result, HighlightsApiError> { + return { ok: true, value: { data, next_page_token: null } } +} + +function apiError(error: HighlightsApiError): Result { + return { ok: false, error } +} + +const transient = (status?: number, message = 'boom') => + apiError({ kind: 'transient', ...(status === undefined ? {} : { status }), message }) +const authError = (status: 401 | 403 = 401) => + apiError({ kind: 'auth', status, message: 'unauthorized' }) + +type Deferred = { promise: Promise; resolve: (value: T) => void } + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +type AuthShape = Partial | null + +const signedIn: AuthShape = { + isAuthenticated: true, + accessToken: 'token-1', + userInfo: { id: userId }, + isLoading: false, +} + +const signedOut: AuthShape = { + isAuthenticated: false, + accessToken: null, + userInfo: null, + isLoading: false, +} + +/** Signed in, `userInfo` seeded from cache, but `loadTokens()` has not resolved. */ +const tokenLoading: AuthShape = { + isAuthenticated: false, + accessToken: null, + userInfo: { id: userId }, + isLoading: true, +} + +const refreshNow = jest.fn(async () => undefined) + +function authValue(overrides: Partial): AuthContextValue { + return { + isAuthenticated: false, + accessToken: null, + userInfo: null, + error: null, + signIn: jest.fn(async () => undefined), + signOut: jest.fn(async () => undefined), + refreshNow, + isLoading: false, + ...overrides, + } +} + +// `renderHook`'s `rerender` re-supplies hook props but keeps the original +// wrapper, so auth-state transitions (the token-loading window, sign-out +// mid-write) swap this value and re-render rather than remounting — a remount +// would lose the in-flight write under test. +let currentAuth: AuthShape = signedIn + +function Wrapper({ children }: { children: ReactNode }) { + const inner = + currentAuth === null ? ( + children + ) : ( + {children} + ) + return ( + + {inner} + + ) +} + +function renderUseHighlights(auth: AuthShape = signedIn, initialProps = options) { + currentAuth = auth + return renderHook((props: typeof options) => useHighlights(props), { + wrapper: Wrapper, + initialProps, + }) +} + +/** Move to a new auth state without remounting the hook. */ +function setAuth(rerender: (props: typeof options) => void, auth: AuthShape): void { + currentAuth = auth + act(() => { + rerender({ ...options }) + }) +} + +function seedCache(highlights: Highlight[], forUser = userId, forScope = scope) { + mockMmkv.set(highlightsCacheKey(forUser, forScope), JSON.stringify(highlights)) +} + +/** + * Cache and server agree — the steady state. Seeding only the cache lets the + * mount fetch (which defaults to an empty collection) legitimately wipe it, + * which is right behaviour but the wrong starting point for most tests. + */ +function seedServer(highlights: Highlight[]) { + seedCache(highlights) + mockGetHighlights.mockResolvedValue(collection(highlights)) +} + +function readCache(forUser = userId, forScope = scope): Highlight[] | null { + const raw = mockMmkv.get(highlightsCacheKey(forUser, forScope)) + return raw === undefined ? null : (JSON.parse(raw) as Highlight[]) +} + +function colorsOf(result: UseHighlightsResult): Record { + return Object.fromEntries(result.highlights.map((h) => [h.passage_id, h.color])) +} + +beforeEach(() => { + mockMmkv.clear() + jest.clearAllMocks() + // `clearAllMocks` clears calls but NOT queued `mockResolvedValueOnce` values, + // so an unconsumed queue would leak into the next test. Reset these three + // explicitly rather than `resetAllMocks`, which would also wipe the MMKV fake. + mockGetHighlights.mockReset() + mockCreateHighlight.mockReset() + mockDeleteHighlight.mockReset() + mockGetHighlights.mockResolvedValue(collection([])) + mockCreateHighlight.mockResolvedValue({ ok: true, value: highlight('JHN.3.16', YELLOW) }) + mockDeleteHighlight.mockResolvedValue({ ok: true, value: undefined }) +}) + +// ── AC 1: instant mount ────────────────────────────────────────────────────── + +describe('instant mount from cache', () => { + it('returns cached highlights on the FIRST render, before any effect runs', async () => { + seedCache([highlight('JHN.3.16', 'FFFE00'), highlight('JHN.3.20-21', GREEN)]) + + // renderHook wraps in act(), which flushes effects — so capture the value + // during render instead. The first entry is what the reader would paint on + // its very first frame. + const renders: UseHighlightsResult[] = [] + const fetchesAtRender: number[] = [] + function Probe() { + fetchesAtRender.push(mockGetHighlights.mock.calls.length) + renders.push(useHighlights(options)) + return probe + } + + currentAuth = signedIn + render( + + + , + ) + + const first = renders[0] + expect(first).toBeDefined() + expect(colorsOf(first as UseHighlightsResult)).toEqual({ + 'JHN.3.16': YELLOW, // normalized to lowercase on projection + 'JHN.3.20': GREEN, // the cached range expands per verse + 'JHN.3.21': GREEN, + }) + // That frame was pure cache: no GET had been issued when it was produced. + expect(fetchesAtRender[0]).toBe(0) + + // These assertions are deliberately pre-flush; drain the mount fetch so its + // state update lands inside act(). + await act(async () => { + await Promise.resolve() + }) + }) + + it('starts empty with no cache and never reports a loading state that hides data', async () => { + const { result } = renderUseHighlights() + expect(result.current.highlights).toEqual([]) + expect(result.current.error).toBeNull() + + // These assertions are deliberately pre-flush; drain the mount fetch so its + // state update lands inside act(). + await act(async () => { + await Promise.resolve() + }) + }) + + it('reports the scope it is serving', async () => { + const { result } = renderUseHighlights() + expect(result.current.scope).toEqual(scope) + + // These assertions are deliberately pre-flush; drain the mount fetch so its + // state update lands inside act(). + await act(async () => { + await Promise.resolve() + }) + }) + + it('does not read the cache when signed out', () => { + seedCache([highlight('JHN.3.16', YELLOW)]) + const { result } = renderUseHighlights(signedOut) + expect(result.current.highlights).toEqual([]) + }) +}) + +// ── Fetch / reconcile ──────────────────────────────────────────────────────── + +describe('fetching server truth', () => { + it('scopes the GET to the chapter and rewrites the cache on success', async () => { + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + expect(mockGetHighlights).toHaveBeenCalledWith('token-1', { + version_id: 111, + passage_id: 'JHN.3', + }) + expect(result.current.highlights).toEqual([]) + + mockGetHighlights.mockResolvedValue(collection([highlight('JHN.3.16', YELLOW)])) + await act(async () => { + await result.current.refresh() + }) + + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + expect(readCache()).toEqual([highlight('JHN.3.16', YELLOW)]) + }) + + it('keeps rendering cached data when the fetch fails (stale-while-error)', async () => { + seedCache([highlight('JHN.3.16', YELLOW)]) + mockGetHighlights.mockResolvedValue(transient(500)) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + expect(result.current.error).toEqual({ reason: 'transient', message: 'boom' }) + }) + + it('classifies a fetch 401 as auth without refreshing or retrying', async () => { + mockGetHighlights.mockResolvedValue(authError()) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + expect(result.current.error).toEqual({ reason: 'auth', message: 'unauthorized' }) + expect(refreshNow).not.toHaveBeenCalled() + expect(mockGetHighlights).toHaveBeenCalledTimes(1) + }) + + it('does not fetch while signed out', async () => { + renderUseHighlights(signedOut) + await act(async () => { + await Promise.resolve() + }) + expect(mockGetHighlights).not.toHaveBeenCalled() + }) + + it('does not fetch when no auth is configured at all', async () => { + const { result } = renderUseHighlights(null) + await act(async () => { + await Promise.resolve() + }) + expect(mockGetHighlights).not.toHaveBeenCalled() + expect(result.current.highlights).toEqual([]) + }) + + it('drops a late response for a scope the reader has left', async () => { + const pending = deferred, HighlightsApiError>>() + mockGetHighlights.mockReturnValueOnce(pending.promise) + + const { result, rerender } = renderUseHighlights() + + rerender({ versionId: 111, book: 'JHN', chapter: '4' }) + expect(result.current.scope.chapter).toBe('4') + + await act(async () => { + pending.resolve(collection([highlight('JHN.3.16', YELLOW)])) + await pending.promise + }) + + // Chapter 3's data must not paint chapter 4, nor land in chapter 4's cache. + expect(result.current.highlights).toEqual([]) + expect(readCache(userId, { versionId: 111, book: 'JHN', chapter: '4' })).not.toContainEqual( + highlight('JHN.3.16', YELLOW), + ) + }) + + it('repaints instantly from cache when the chapter changes', async () => { + seedCache([highlight('JHN.4.1', BLUE)], userId, { versionId: 111, book: 'JHN', chapter: '4' }) + const { result, rerender } = renderUseHighlights() + + rerender({ versionId: 111, book: 'JHN', chapter: '4' }) + + expect(colorsOf(result.current)).toEqual({ 'JHN.4.1': BLUE }) + + // These assertions are deliberately pre-flush; drain the mount fetch so its + // state update lands inside act(). + await act(async () => { + await Promise.resolve() + }) + }) + + it('reconciles a refresh that lands mid-write instead of clobbering the overlay', async () => { + const pendingWrite = deferred>() + mockCreateHighlight.mockReturnValueOnce(pendingWrite.promise) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let outcome: Promise | undefined + await act(async () => { + outcome = result.current.apply(YELLOW, [16]) + await Promise.resolve() + }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + + // A refresh lands while the POST is still open. The server has not seen the + // write yet, so it reports nothing for verse 16 — the optimistic paint must + // survive it. + mockGetHighlights.mockResolvedValue(collection([])) + await act(async () => { + await result.current.refresh() + }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + + // Once the write settles and the server catches up, the overlay retires and + // the same colour is now server truth rather than optimism. + mockGetHighlights.mockResolvedValue(collection([highlight('JHN.3.16', YELLOW)])) + await act(async () => { + pendingWrite.resolve({ ok: true, value: highlight('JHN.3.16', YELLOW) }) + await outcome + await Promise.resolve() + }) + + expect(await outcome).toEqual({ status: 'ok', verses: [16] }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + }) + + it('clears isRefreshing when signing out abandons an in-flight fetch', async () => { + const pending = deferred, HighlightsApiError>>() + mockGetHighlights.mockReturnValueOnce(pending.promise) + + const { result, rerender } = renderUseHighlights() + expect(result.current.isRefreshing).toBe(true) + + // Signing out abandons the fetch, and no replacement starts — nothing else + // would ever clear the flag. + setAuth(rerender, signedOut) + expect(result.current.isRefreshing).toBe(false) + + await act(async () => { + pending.resolve(collection([])) + await pending.promise + }) + expect(result.current.isRefreshing).toBe(false) + }) + + it('shares one in-flight request between concurrent refresh calls', async () => { + const pending = deferred, HighlightsApiError>>() + mockGetHighlights.mockReturnValueOnce(pending.promise) + + const { result } = renderUseHighlights() + expect(result.current.isRefreshing).toBe(true) + + await act(async () => { + const a = result.current.refresh() + const b = result.current.refresh() + pending.resolve(collection([])) + await Promise.all([a, b]) + }) + + expect(mockGetHighlights).toHaveBeenCalledTimes(1) + expect(result.current.isRefreshing).toBe(false) + }) +}) + +// ── AC 2 / 3: optimistic apply ─────────────────────────────────────────────── + +describe('apply', () => { + it('paints synchronously, then reconciles against the server and rewrites the cache', async () => { + const pendingWrite = deferred>() + mockCreateHighlight.mockReturnValueOnce(pendingWrite.promise) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let outcome: Promise | undefined + act(() => { + outcome = result.current.apply(YELLOW, [16, 17]) + }) + + // Painted before the request resolved. + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW, 'JHN.3.17': YELLOW }) + + mockGetHighlights.mockResolvedValue( + collection([highlight('JHN.3.16', YELLOW), highlight('JHN.3.17', YELLOW)]), + ) + + await act(async () => { + pendingWrite.resolve({ ok: true, value: highlight('JHN.3.16-17', YELLOW) }) + await outcome + await Promise.resolve() + }) + + expect(await outcome).toEqual({ status: 'ok', verses: [16, 17] }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW, 'JHN.3.17': YELLOW }) + expect(readCache()).toEqual([highlight('JHN.3.16', YELLOW), highlight('JHN.3.17', YELLOW)]) + }) + + it('collapses contiguous verses into one ranged POST per run', async () => { + const { result } = renderUseHighlights() + await act(async () => { + await result.current.apply(YELLOW, [16, 17, 18, 20]) + }) + + expect(mockCreateHighlight).toHaveBeenCalledTimes(2) + expect(mockCreateHighlight).toHaveBeenCalledWith('token-1', { + version_id: 111, + passage_id: 'JHN.3.16-18', + color: YELLOW, + }) + expect(mockCreateHighlight).toHaveBeenCalledWith('token-1', { + version_id: 111, + passage_id: 'JHN.3.20', + color: YELLOW, + }) + }) + + // AC 3 + it('reverts the paint and returns a typed error when the write fails', async () => { + seedServer([highlight('JHN.3.16', GREEN)]) + mockCreateHighlight.mockResolvedValue(transient(500)) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [16]) + }) + + expect(outcome).toEqual({ + status: 'error', + reason: 'transient', + message: 'boom', + failedVerses: [16], + succeededVerses: [], + }) + // Reverted to what the server last said, not to nothing. + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) + }) + + it('classifies a write 401 as auth without refreshing or retrying', async () => { + mockCreateHighlight.mockResolvedValue(authError()) + + const { result } = renderUseHighlights() + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [16]) + }) + + expect(outcome).toMatchObject({ status: 'error', reason: 'auth' }) + expect(mockCreateHighlight).toHaveBeenCalledTimes(1) + expect(refreshNow).not.toHaveBeenCalled() + }) + + // Without this, the pre-#99 `uuid_parsing` 422 presents as flaky network. + it('classifies a non-auth 4xx as invalid, not transient', async () => { + mockCreateHighlight.mockResolvedValue(transient(422, 'uuid_parsing')) + + const { result } = renderUseHighlights() + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [16]) + }) + + expect(outcome).toMatchObject({ status: 'error', reason: 'invalid' }) + }) + + it('classifies a 5xx and a network failure as transient', async () => { + mockCreateHighlight.mockResolvedValueOnce(transient(503)) + const { result } = renderUseHighlights() + + let first: HighlightWriteOutcome | undefined + await act(async () => { + first = await result.current.apply(YELLOW, [16]) + }) + expect(first).toMatchObject({ reason: 'transient' }) + + mockCreateHighlight.mockResolvedValueOnce(transient(undefined, 'Network request failed')) + let second: HighlightWriteOutcome | undefined + await act(async () => { + second = await result.current.apply(YELLOW, [16]) + }) + expect(second).toMatchObject({ reason: 'transient' }) + }) + + it('is a noop for an empty verse list, with no request', async () => { + const { result } = renderUseHighlights() + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, []) + }) + + expect(outcome).toEqual({ status: 'noop' }) + expect(mockCreateHighlight).not.toHaveBeenCalled() + }) +}) + +// ── Partial batches ────────────────────────────────────────────────────────── + +describe('partial batches', () => { + it('retains succeeded verses, reverts failed ones, and reports both', async () => { + mockCreateHighlight + .mockResolvedValueOnce({ ok: true, value: highlight('JHN.3.16-17', YELLOW) }) + .mockResolvedValueOnce(transient(500)) + + const { result } = renderUseHighlights() + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [16, 17, 20]) + }) + + expect(outcome).toEqual({ + status: 'error', + reason: 'transient', + message: 'boom', + failedVerses: [20], + succeededVerses: [16, 17], + }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW, 'JHN.3.17': YELLOW }) + }) + + it('resolves mixed failure reasons as auth > invalid > transient', async () => { + mockCreateHighlight + .mockResolvedValueOnce(transient(500, 'five hundred')) + .mockResolvedValueOnce(transient(422, 'bad passage')) + .mockResolvedValueOnce(authError(403)) + + const { result } = renderUseHighlights() + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [1, 3, 5]) + }) + + expect(outcome).toMatchObject({ reason: 'auth', message: 'unauthorized' }) + }) + + it('prefers invalid over transient when no auth failure is present', async () => { + mockCreateHighlight + .mockResolvedValueOnce(transient(500, 'five hundred')) + .mockResolvedValueOnce(transient(400, 'bad passage')) + + const { result } = renderUseHighlights() + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [1, 3]) + }) + + expect(outcome).toMatchObject({ reason: 'invalid', message: 'bad passage' }) + }) +}) + +// ── AC 4 / 7: ownership and serialization ──────────────────────────────────── + +describe('overlapping writes', () => { + it('serializes network writes while painting both immediately', async () => { + const first = deferred>() + mockCreateHighlight.mockReturnValueOnce(first.promise) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let a: Promise | undefined + let b: Promise | undefined + await act(async () => { + a = result.current.apply(YELLOW, [16]) + b = result.current.apply(GREEN, [20]) + // Let the head of the chain reach the network; the tail stays queued + // behind the unresolved deferred. + await Promise.resolve() + }) + + // Both painted; only the first has reached the network. + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW, 'JHN.3.20': GREEN }) + expect(mockCreateHighlight).toHaveBeenCalledTimes(1) + + await act(async () => { + first.resolve({ ok: true, value: highlight('JHN.3.16', YELLOW) }) + await Promise.all([a, b]) + }) + + expect(mockCreateHighlight).toHaveBeenCalledTimes(2) + }) + + // AC 4 — the ownership token, end to end. + it('a failed older write does not wipe the color a newer write painted', async () => { + const slowYellow = deferred>() + mockCreateHighlight.mockReturnValueOnce(slowYellow.promise) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let yellowOutcome: Promise | undefined + act(() => { + yellowOutcome = result.current.apply(YELLOW, [16]) + }) + + // The user re-taps verse 16 in green before yellow's POST comes back. + let greenOutcome: Promise | undefined + act(() => { + greenOutcome = result.current.apply(GREEN, [16]) + }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) + + await act(async () => { + slowYellow.resolve(transient(500)) + await yellowOutcome + await greenOutcome + }) + + // Yellow failed, but it no longer owned verse 16 — green survives. + expect(await yellowOutcome).toMatchObject({ status: 'error', failedVerses: [16] }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) + }) +}) + +// ── AC 5: the vapor fix ────────────────────────────────────────────────────── + +describe('reconciling against a stale replica', () => { + it('does not resurrect a removed highlight when a later GET echoes the deleted color', async () => { + seedServer([highlight('JHN.3.16', YELLOW)]) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + // The post-settle refetch hits a replica that has not caught up. + mockGetHighlights.mockResolvedValue(collection([highlight('JHN.3.16', YELLOW)])) + + await act(async () => { + await result.current.remove(YELLOW, [16]) + await Promise.resolve() + }) + + expect(mockDeleteHighlight).toHaveBeenCalledWith('token-1', 'JHN.3.16', { version_id: 111 }) + expect(result.current.highlights).toEqual([]) + }) + + it('retires the remove overlay once the server reports a different color', async () => { + seedServer([highlight('JHN.3.16', YELLOW)]) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + // Another device set green after our delete landed — newer data, not vapor. + mockGetHighlights.mockResolvedValue(collection([highlight('JHN.3.16', GREEN)])) + + await act(async () => { + await result.current.remove(YELLOW, [16]) + await Promise.resolve() + }) + + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) + }) +}) + +// ── remove ─────────────────────────────────────────────────────────────────── + +describe('remove', () => { + it('only deletes verses the user currently sees in that color', async () => { + seedServer([ + highlight('JHN.3.16', YELLOW), + highlight('JHN.3.17', BLUE), + highlight('JHN.3.18', YELLOW), + ]) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.remove(YELLOW, [16, 17, 18]) + }) + + expect(outcome).toEqual({ status: 'ok', verses: [16, 18] }) + // One DELETE per verse, never a range — and nothing for the blue verse. + expect(mockDeleteHighlight).toHaveBeenCalledTimes(2) + expect(mockDeleteHighlight).toHaveBeenCalledWith('token-1', 'JHN.3.16', { version_id: 111 }) + expect(mockDeleteHighlight).toHaveBeenCalledWith('token-1', 'JHN.3.18', { version_id: 111 }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.17': BLUE }) + }) + + it('is a noop with no request when nothing in the selection matches the color', async () => { + seedServer([highlight('JHN.3.17', BLUE)]) + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.remove(YELLOW, [16, 17]) + }) + + expect(outcome).toEqual({ status: 'noop' }) + expect(mockDeleteHighlight).not.toHaveBeenCalled() + }) + + it('restores the highlight when the delete fails', async () => { + seedServer([highlight('JHN.3.16', YELLOW)]) + mockDeleteHighlight.mockResolvedValue(transient(500)) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.remove(YELLOW, [16]) + }) + + expect(outcome).toMatchObject({ status: 'error', failedVerses: [16] }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + }) + + it('targets optimistic paint too, not just server truth', async () => { + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + await act(async () => { + await result.current.apply(GREEN, [16]) + }) + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.remove(GREEN, [16]) + }) + + expect(outcome).toEqual({ status: 'ok', verses: [16] }) + }) + + // A toggle that applies and un-applies within one handler never yields to + // React, so nothing has re-rendered and the ref-sync effect has not run. If + // the selection were read from the last committed render, this would no-op and + // strand the highlight the apply just painted. + it('sees a claim made earlier in the same tick, before any re-render', async () => { + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let applied: Promise | undefined + let removed: Promise | undefined + act(() => { + applied = result.current.apply(GREEN, [16]) + removed = result.current.remove(GREEN, [16]) + }) + + await act(async () => { + await applied + await removed + }) + + expect(await removed).toEqual({ status: 'ok', verses: [16] }) + expect(mockDeleteHighlight).toHaveBeenCalledWith('token-1', 'JHN.3.16', { version_id: 111 }) + expect(result.current.highlights).toEqual([]) + }) +}) + +// ── The palette ────────────────────────────────────────────────────────────── + +describe('color validation', () => { + it('rejects a non-swatch color from apply with no paint and no request', async () => { + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply('ff0000', [16]) + }) + + expect(outcome).toMatchObject({ status: 'error', reason: 'invalid', failedVerses: [16] }) + expect(result.current.highlights).toEqual([]) + expect(mockCreateHighlight).not.toHaveBeenCalled() + }) + + it('rejects a non-swatch color from remove with no request', async () => { + seedServer([highlight('JHN.3.16', 'ff0000')]) + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.remove('ff0000', [16]) + }) + + expect(outcome).toMatchObject({ status: 'error', reason: 'invalid' }) + expect(mockDeleteHighlight).not.toHaveBeenCalled() + }) + + it('accepts a swatch given in uppercase', async () => { + const { result } = renderUseHighlights() + await act(async () => { + await result.current.apply('FFFE00', [16]) + }) + + expect(mockCreateHighlight).toHaveBeenCalledWith('token-1', { + version_id: 111, + passage_id: 'JHN.3.16', + color: YELLOW, + }) + }) +}) + +// ── AC 6: signed out, and the token-loading window ─────────────────────────── + +describe('auth states', () => { + it('returns a typed not-signed-in failure without touching state', async () => { + const { result } = renderUseHighlights(signedOut) + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [16]) + }) + + expect(outcome).toEqual({ + status: 'error', + reason: 'not-signed-in', + message: expect.stringContaining('Not signed in'), + failedVerses: [16], + succeededVerses: [], + }) + expect(result.current.highlights).toEqual([]) + expect(mockCreateHighlight).not.toHaveBeenCalled() + }) + + it('behaves as signed out when no auth is configured', async () => { + const { result } = renderUseHighlights(null) + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [16]) + }) + + expect(outcome).toMatchObject({ reason: 'not-signed-in' }) + }) + + // The exposure the hold exists for: reporting `not-signed-in` here would send + // C3 off to prompt a user who is already signed in. + it('holds a write through the token-loading window instead of failing it', async () => { + seedCache([highlight('JHN.3.16', GREEN)]) + const { result, rerender } = renderUseHighlights(tokenLoading) + + // Cache paints even though `isAuthenticated` is still false. + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) + + let outcome: Promise | undefined + act(() => { + outcome = result.current.apply(YELLOW, [16]) + }) + + // Painted, but held: no request yet, and no premature failure. + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + expect(mockCreateHighlight).not.toHaveBeenCalled() + + // The token arrives. `isLoading` is still true — the hold must release on + // the token, not on loading clearing. + setAuth(rerender, { ...tokenLoading, accessToken: 'token-1' }) + + await act(async () => { + await outcome + }) + + expect(mockCreateHighlight).toHaveBeenCalledWith('token-1', { + version_id: 111, + passage_id: 'JHN.3.16', + color: YELLOW, + }) + expect(await outcome).toEqual({ status: 'ok', verses: [16] }) + }) + + it('releases the hold as a not-signed-in failure when auth settles with no token', async () => { + seedCache([highlight('JHN.3.16', GREEN)]) + const { result, rerender } = renderUseHighlights(tokenLoading) + + let outcome: Promise | undefined + act(() => { + outcome = result.current.apply(YELLOW, [16]) + }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + + setAuth(rerender, { ...tokenLoading, isLoading: false }) + + await act(async () => { + await outcome + }) + + expect(await outcome).toMatchObject({ reason: 'not-signed-in', failedVerses: [16] }) + // The optimistic paint is reverted, not left stranded. + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) + expect(mockCreateHighlight).not.toHaveBeenCalled() + }) + + it('lets remove target cache-painted verses during the token-loading window', async () => { + seedCache([highlight('JHN.3.16', YELLOW)]) + const { result, rerender } = renderUseHighlights(tokenLoading) + + let outcome: Promise | undefined + act(() => { + // Guarding on `isAuthenticated` (as web does) would silently no-op this. + outcome = result.current.remove(YELLOW, [16]) + }) + expect(result.current.highlights).toEqual([]) + + setAuth(rerender, { ...tokenLoading, accessToken: 'token-1' }) + + await act(async () => { + await outcome + }) + + expect(await outcome).toEqual({ status: 'ok', verses: [16] }) + expect(mockDeleteHighlight).toHaveBeenCalledTimes(1) + }) + + // A queued write must not be issued under whoever happens to be signed in when + // its turn comes: `runWrite` reads the current token by design (a mid-write + // refresh must not fail the write), so nothing but the identity guard stops + // one user's intent from mutating another user's highlights server-side. + it('abandons a queued write when a different user signs in before it runs', async () => { + const heldWrite = deferred>() + mockCreateHighlight.mockReturnValueOnce(heldWrite.promise) + + const { result, rerender } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let first: Promise | undefined + let queued: Promise | undefined + act(() => { + first = result.current.apply(YELLOW, [16]) + }) + // Let the first write reach the network, where it hangs — that is what holds + // the chain open across the identity change. + await act(async () => { + await Promise.resolve() + }) + expect(mockCreateHighlight).toHaveBeenCalledTimes(1) + + act(() => { + queued = result.current.apply(GREEN, [17]) + }) + + setAuth(rerender, { + isAuthenticated: true, + accessToken: 'token-2', + userInfo: { id: 'user-2' }, + isLoading: false, + }) + + await act(async () => { + heldWrite.resolve({ ok: true, value: highlight('JHN.3.16', YELLOW) }) + await first + await queued + }) + + expect(await queued).toEqual({ + status: 'error', + reason: 'not-signed-in', + message: expect.stringContaining('Not signed in'), + failedVerses: [17], + succeededVerses: [], + }) + // Only the write that was already on the wire under user-1's token ran. + expect(mockCreateHighlight).toHaveBeenCalledTimes(1) + expect(mockCreateHighlight).not.toHaveBeenCalledWith('token-2', expect.anything()) + }) + + it('abandons a queued remove rather than deleting the new user’s highlights', async () => { + seedServer([highlight('JHN.3.16', YELLOW), highlight('JHN.3.17', YELLOW)]) + const heldWrite = deferred>() + mockDeleteHighlight.mockReturnValueOnce(heldWrite.promise) + + const { result, rerender } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let first: Promise | undefined + let queued: Promise | undefined + act(() => { + first = result.current.remove(YELLOW, [16]) + }) + await act(async () => { + await Promise.resolve() + }) + expect(mockDeleteHighlight).toHaveBeenCalledTimes(1) + + act(() => { + queued = result.current.remove(YELLOW, [17]) + }) + + setAuth(rerender, { + isAuthenticated: true, + accessToken: 'token-2', + userInfo: { id: 'user-2' }, + isLoading: false, + }) + + await act(async () => { + heldWrite.resolve({ ok: true, value: undefined }) + await first + await queued + }) + + expect(await queued).toMatchObject({ reason: 'not-signed-in', failedVerses: [17] }) + expect(mockDeleteHighlight).toHaveBeenCalledTimes(1) + expect(mockDeleteHighlight).not.toHaveBeenCalledWith('token-2', expect.anything()) + }) + + // The counterpart: same user, new token. Capturing the token at claim time + // instead of reading it here would fail this write for no reason. + it('runs a queued write under a refreshed token for the same user', async () => { + const heldWrite = deferred>() + mockCreateHighlight.mockReturnValueOnce(heldWrite.promise) + + const { result, rerender } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let first: Promise | undefined + let queued: Promise | undefined + act(() => { + first = result.current.apply(YELLOW, [16]) + queued = result.current.apply(GREEN, [17]) + }) + + setAuth(rerender, { ...signedIn, accessToken: 'token-refreshed' }) + + await act(async () => { + heldWrite.resolve({ ok: true, value: highlight('JHN.3.16', YELLOW) }) + await first + await queued + }) + + expect(await queued).toEqual({ status: 'ok', verses: [17] }) + expect(mockCreateHighlight).toHaveBeenLastCalledWith('token-refreshed', { + version_id: 111, + passage_id: 'JHN.3.17', + color: GREEN, + }) + }) + + it('does not repopulate the cache when sign-out lands between settle and refetch', async () => { + seedCache([highlight('JHN.3.16', GREEN)]) + const pendingWrite = deferred>() + mockCreateHighlight.mockReturnValueOnce(pendingWrite.promise) + + const { result, rerender } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let outcome: Promise | undefined + act(() => { + outcome = result.current.apply(YELLOW, [16]) + }) + + // AuthProvider.clearAuthState() has just emptied the highlights cache. + mockMmkv.clear() + setAuth(rerender, signedOut) + + mockGetHighlights.mockResolvedValue(collection([highlight('JHN.3.16', YELLOW)])) + await act(async () => { + pendingWrite.resolve({ ok: true, value: highlight('JHN.3.16', YELLOW) }) + await outcome + await Promise.resolve() + }) + + expect(readCache()).toBeNull() + }) +}) + +// ── error is fetch-only ────────────────────────────────────────────────────── + +describe('error surface', () => { + it('never lets a failed write evict a fetch error that is still true', async () => { + seedCache([highlight('JHN.3.16', GREEN)]) + mockGetHighlights.mockResolvedValue(transient(500, 'fetch died')) + mockCreateHighlight.mockResolvedValue(transient(503, 'write died')) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + expect(result.current.error).toEqual({ reason: 'transient', message: 'fetch died' }) + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [17]) + await Promise.resolve() + }) + + // The write reported through its return value only. + expect(outcome).toMatchObject({ message: 'write died' }) + expect(result.current.error).toEqual({ reason: 'transient', message: 'fetch died' }) + }) + + it('clears a stale fetch error once a fetch succeeds', async () => { + mockGetHighlights.mockResolvedValueOnce(transient(500)) + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + expect(result.current.error).not.toBeNull() + + mockGetHighlights.mockResolvedValue(collection([])) + await act(async () => { + await result.current.refresh() + }) + expect(result.current.error).toBeNull() + }) +}) + +// ── Degraded: no user id ───────────────────────────────────────────────────── + +describe('missing user id', () => { + it('runs cache-less with a single dev warning', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const noUserId: AuthShape = { + isAuthenticated: true, + accessToken: 'token-1', + userInfo: { name: 'Someone' }, + isLoading: false, + } + mockGetHighlights.mockResolvedValue(collection([highlight('JHN.3.16', YELLOW)])) + + const { result, unmount } = renderUseHighlights(noUserId) + await act(async () => { + await Promise.resolve() + }) + + // Network still paints; only the cache is disabled. + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + expect(mockMmkv.size).toBe(0) + expect(warn).toHaveBeenCalledTimes(1) + + unmount() + renderUseHighlights(noUserId) + await act(async () => { + await Promise.resolve() + }) + // Warned once per process, not once per mount. + expect(warn).toHaveBeenCalledTimes(1) + + warn.mockRestore() + }) + + it('rejects writes when there is no user id to key the cache by', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => undefined) + const { result } = renderUseHighlights({ + isAuthenticated: true, + accessToken: 'token-1', + userInfo: { name: 'Someone' }, + isLoading: false, + }) + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [16]) + }) + + expect(outcome).toMatchObject({ reason: 'not-signed-in' }) + }) +}) diff --git a/packages/core/src/highlights/constants.ts b/packages/core/src/highlights/constants.ts index b9a73dcc..8270b244 100644 --- a/packages/core/src/highlights/constants.ts +++ b/packages/core/src/highlights/constants.ts @@ -1,5 +1,27 @@ export const MMKV_HIGHLIGHTS_KEY_PREFIX = 'yvp.highlights.' as const +/** + * The five highlight swatches, a company-wide standard across every YouVersion + * SDK. Custom colors are not supported by the product, so both write paths + * reject anything outside this list before painting or issuing a request. + * + * Duplicated from `@youversion/platform-react-ui`'s `HIGHLIGHT_COLORS` rather + * than imported: that package peer-depends on `react-dom` (which core must not + * require of a native consumer), exposes no deep import path, and would pull a + * second `@youversion/platform-core` into this package's subtree. A pinning + * test guards the values. Upstream ask: relocate the palette into + * `@youversion/platform-core`, which both SDKs already depend on, and make this + * a re-export. + */ +export const HIGHLIGHT_COLORS = ['fffe00', '5dff79', '00d6ff', 'ffc66f', 'ff95ef'] as const + +export type HighlightColor = (typeof HIGHLIGHT_COLORS)[number] + +/** Case-insensitive membership test against {@link HIGHLIGHT_COLORS}. */ +export function isHighlightColor(color: string): color is HighlightColor { + return (HIGHLIGHT_COLORS as readonly string[]).includes(color.toLowerCase()) +} + export type HighlightScope = { versionId: number book: string diff --git a/packages/core/src/highlights/index.ts b/packages/core/src/highlights/index.ts index eb3eebbd..f3fdda19 100644 --- a/packages/core/src/highlights/index.ts +++ b/packages/core/src/highlights/index.ts @@ -23,3 +23,14 @@ export { type HighlightScope, type ServerColors, } from './cache' + +export { HIGHLIGHT_COLORS, isHighlightColor, type HighlightColor } from './constants' + +export { + useHighlights, + type HighlightsFetchError, + type HighlightWriteOutcome, + type HighlightWriteReason, + type UseHighlightsOptions, + type UseHighlightsResult, +} from './use-highlights' diff --git a/packages/core/src/highlights/optimistic.ts b/packages/core/src/highlights/optimistic.ts new file mode 100644 index 00000000..214c46b3 --- /dev/null +++ b/packages/core/src/highlights/optimistic.ts @@ -0,0 +1,348 @@ +/** + * Pure optimistic-overlay math for the native highlights layer. + * + * Ported from the web SDK's `bible-reader-highlights-machine.ts` (`claimVerses`, + * `settleWrite`, `reconcileOverlay`) so the two SDKs agree on what the user sees + * while a write is in flight. Two semantics are load-bearing and are adopted + * rather than re-derived: + * + * 1. **Ownership tokens.** Every write allocates a fresh token object and stamps + * the verses it claims. A settling write only touches verses it *still* owns, + * so a slow failure cannot wipe paint a newer write has since put down. + * 2. **Remove overlays survive reconciliation.** A stale read replica can echo + * back the color that was just deleted; retiring the overlay on that echo + * repaints the verse for a beat ("vapor"). See {@link shouldRetire} — our + * rule diverges from web's, deliberately. + * + * This module is React-free and side-effect-free: no storage, no network, no + * hooks. Every state transition returns the *same* object when nothing changed, + * because the projected output crosses the native/DOM bridge as a serialized + * prop. + */ + +import type { Highlight } from '@youversion/platform-core' +import type { HighlightScope, ServerColors } from './constants' + +/** + * Pending local edits for a scope: a hex color where the user just applied one, + * `null` where they just removed one. Sits on top of Server Colors. Never + * persisted. + */ +export type HighlightOverlay = Record + +export type WriteOp = 'apply' | 'remove' + +/** + * Per-write ownership marker. Compared by **object identity**, never by value — + * the `op` field is for debugging only. Allocate a fresh one per write. + */ +export type WriteToken = { readonly op: WriteOp } + +/** What a settled write is waiting for the server to confirm. */ +export type ReconcileEntry = { op: WriteOp; color: string } + +export type OptimisticState = { + scope: HighlightScope + userId: string | null + serverColors: ServerColors + overlay: HighlightOverlay + reconcile: ReadonlyMap + writeIntent: ReadonlyMap +} + +export function createWriteToken(op: WriteOp): WriteToken { + return { op } +} + +/** + * A fresh state for an identity (scope + user), seeded with whatever server + * truth is already known. Also the `reset` transition: clearing `writeIntent` is + * what stops an in-flight write from settling onto a colliding verse number in + * the scope the user has since navigated to. + */ +export function createOptimisticState(input: { + scope: HighlightScope + userId: string | null + serverColors: ServerColors +}): OptimisticState { + return { + scope: input.scope, + userId: input.userId, + serverColors: input.serverColors, + overlay: {}, + reconcile: new Map(), + writeIntent: new Map(), + } +} + +/** + * Claims verses for a write: stamps each with the op's ownership `token`, drops + * any pending reconciliation (a newer write supersedes it), and paints the + * optimistic overlay (`color` for an apply, `null` for a remove). + * + * Dropping the reconcile entry is also the third retirement path for a remove + * overlay — the other two are a scope/identity change and a confirming fetch. + */ +export function claim( + state: OptimisticState, + verses: readonly number[], + token: WriteToken, + color: string | null, +): OptimisticState { + if (verses.length === 0) { + return state + } + const writeIntent = new Map(state.writeIntent) + const reconcile = new Map(state.reconcile) + const overlay = { ...state.overlay } + for (const verse of verses) { + writeIntent.set(verse, token) + reconcile.delete(verse) + overlay[verse] = color + } + return { ...state, writeIntent, reconcile, overlay } +} + +/** + * Cleanup for a finished batch, deciding what happens to paint already on + * screen. Succeeded verses keep their paint and register a reconcile entry so a + * later fetch knows when to retire it; failed verses have their overlay entry + * **deleted** — for a failed remove that restores the highlight, same mechanism, + * correct result. + * + * Both loops are guarded on `writeIntent.get(verse) === token` by object + * identity. The scenario: tap yellow on verse 16; before that POST returns, tap + * green on 16 (which re-stamps the intent); then yellow's POST fails. Unguarded, + * yellow's settle deletes the overlay and wipes the green the user is looking at + * over a failure that has nothing to do with it. Guarded, yellow sees green's + * token instead of its own and leaves it alone. + * + * Releasing the claim (`writeIntent.delete`) is what stops intents accumulating + * until sign-out. + */ +export function settle( + state: OptimisticState, + batch: { + token: WriteToken + op: WriteOp + color: string + succeededVerses: readonly number[] + failedVerses: readonly number[] + }, +): OptimisticState { + const overlay = { ...state.overlay } + const reconcile = new Map(state.reconcile) + const writeIntent = new Map(state.writeIntent) + let changed = false + + for (const verse of batch.succeededVerses) { + if (state.writeIntent.get(verse) !== batch.token) { + continue + } + reconcile.set(verse, { op: batch.op, color: batch.color }) + writeIntent.delete(verse) + changed = true + } + + for (const verse of batch.failedVerses) { + if (state.writeIntent.get(verse) !== batch.token) { + continue + } + if (verse in overlay) { + delete overlay[verse] + } + writeIntent.delete(verse) + changed = true + } + + return changed ? { ...state, overlay, reconcile, writeIntent } : state +} + +/** + * Decides whether a pending reconcile entry is confirmed by fresh server truth. + * + * **Deliberate divergence from web.** Web's `reconcileOverlay` never retires a + * remove entry (`if (entry.op !== 'apply') continue`). That fixes the vapor bug, + * but the suppression is unbounded: a *new* color set on another device stays + * invisible until a reset path runs. `ReconcileEntry` already carries the color + * and web simply ignores it for removes, so we can keep the fix and drop most of + * the cost — a color that differs from the one we deleted cannot be an echo of + * that deletion, it is newer data. + * + * Narrower failure mode this introduces: verse was green, user set yellow, user + * removed it, and a replica stale enough to still report *green* retires the + * overlay and briefly paints green. That needs the server two steps behind + * rather than one. + * + * Reverting to web's behavior is `return false` in the remove branch. + */ +export function shouldRetire(entry: ReconcileEntry, serverColor: string | undefined): boolean { + if (entry.op === 'apply') { + return serverColor === entry.color + } + return serverColor !== undefined && serverColor !== entry.color +} + +/** + * Stores fresh server truth and retires any reconcile entries it confirms. + * Returns the same state object when the fetch changed nothing, so the projected + * highlights prop stays referentially stable across the bridge. + */ +export function serverUpdated(state: OptimisticState, serverColors: ServerColors): OptimisticState { + const colorsChanged = !serverColorsEqual(state.serverColors, serverColors) + + if (state.reconcile.size === 0) { + return colorsChanged ? { ...state, serverColors } : state + } + + const overlay = { ...state.overlay } + const reconcile = new Map(state.reconcile) + let retired = false + let overlayChanged = false + + for (const [verse, entry] of state.reconcile) { + if (!shouldRetire(entry, serverColors[verse])) { + continue + } + reconcile.delete(verse) + retired = true + if (verse in overlay) { + delete overlay[verse] + overlayChanged = true + } + } + + if (!colorsChanged && !retired) { + return state + } + return { + ...state, + serverColors, + reconcile: retired ? reconcile : state.reconcile, + overlay: overlayChanged ? overlay : state.overlay, + } +} + +export function serverColorsEqual(a: ServerColors, b: ServerColors): boolean { + if (a === b) { + return true + } + const aKeys = Object.keys(a) + if (aKeys.length !== Object.keys(b).length) { + return false + } + for (const key of aKeys) { + if (a[Number(key)] !== b[Number(key)]) { + return false + } + } + return true +} + +// ── Selectors ──────────────────────────────────────────────────────────────── + +/** Server truth with the optimistic overlay applied — what the user sees. */ +export function selectMergedColors(state: OptimisticState): Record { + const merged: Record = { ...state.serverColors } + for (const [verse, color] of Object.entries(state.overlay)) { + if (color === null) { + delete merged[Number(verse)] + } else { + merged[Number(verse)] = color + } + } + return merged +} + +/** + * The rendered state as one `Highlight` per verse, ascending. Per-verse (never + * ranges) so that `deriveServerColors(selectHighlights(state), scope)` is an + * exact round trip. + */ +export function selectHighlights(state: OptimisticState): Highlight[] { + const merged = selectMergedColors(state) + const { versionId, book, chapter } = state.scope + return Object.keys(merged) + .map(Number) + .sort((a, b) => a - b) + .flatMap((verse) => { + const color = merged[verse] + return color === undefined + ? [] + : [{ version_id: versionId, passage_id: `${book}.${chapter}.${verse}`, color }] + }) +} + +/** + * Of `verses`, the ones the user currently *sees* in `color` — optimistic paint + * included. The remove path targets what is on screen, not what the server last + * said, because a DELETE carries a passage id and no color: removing yellow + * across a selection that also holds a blue verse must not destroy the blue one. + */ +export function selectVersesInColor( + state: OptimisticState, + verses: readonly number[], + color: string, +): number[] { + const merged = selectMergedColors(state) + return verses.filter((verse) => merged[verse] === color) +} + +// ── USFM range helpers (ported from web's `usfm-ranges.ts`) ─────────────────── + +export type VerseRun = { start: number; end: number } + +/** + * Groups a verse list into contiguous ascending runs: + * `[16,17,18] -> [{16,18}]`, `[1,3,4] -> [{1,1},{3,4}]`. + * De-duplicates and sorts; non-positive verse numbers are dropped. + */ +export function collapseVerseRuns(verses: readonly number[]): VerseRun[] { + const sorted = [...new Set(verses)].filter((verse) => Number.isInteger(verse) && verse > 0) + sorted.sort((a, b) => a - b) + const runs: VerseRun[] = [] + + for (const verse of sorted) { + const current = runs[runs.length - 1] + if (current && verse === current.end + 1) { + current.end = verse + } else { + runs.push({ start: verse, end: verse }) + } + } + + return runs +} + +/** + * The range USFM for one contiguous run: + * `("JHN", "3", {2,3}) -> "JHN.3.2-3"`, `("JHN", "3", {5,5}) -> "JHN.3.5"`. + */ +export function formatPassageId(book: string, chapter: string, run: VerseRun): string { + return run.start === run.end + ? `${book}.${chapter}.${run.start}` + : `${book}.${chapter}.${run.start}-${run.end}` +} + +/** Expands a contiguous run back into its verse numbers: `{2,4} -> [2,3,4]`. */ +export function versesInRun(run: VerseRun): number[] { + const verses: number[] = [] + for (let verse = run.start; verse <= run.end; verse++) { + verses.push(verse) + } + return verses +} + +/** + * A raw verse selection reduced to its canonical form: de-duplicated, sorted + * ascending, non-positive numbers dropped. Reuses {@link collapseVerseRuns} so + * normalization lives in exactly one place. + * + * Not web's `normalizeVerses` — that one is private to `verse-share.ts` and + * serves copy/share reference formatting. This exists because writes need the + * canonical *verse list* (to claim the overlay and to report outcomes), while + * `collapseVerseRuns` yields runs. + */ +export function normalizeVerseSelection(verses: readonly number[]): number[] { + return collapseVerseRuns(verses).flatMap(versesInRun) +} diff --git a/packages/core/src/highlights/use-highlights.ts b/packages/core/src/highlights/use-highlights.ts new file mode 100644 index 00000000..920b0fca --- /dev/null +++ b/packages/core/src/highlights/use-highlights.ts @@ -0,0 +1,536 @@ +import type { Highlight } from '@youversion/platform-core' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { useYVAuthOptional } from '../auth' +import { useYouVersion } from '../use-youversion' +import { createHighlightsApi, type HighlightsApi, type HighlightsApiError } from './api' +import { deriveServerColors, getCachedHighlights, setCachedHighlights } from './cache' +import { isHighlightColor, type HighlightScope } from './constants' +import { + claim, + collapseVerseRuns, + createOptimisticState, + createWriteToken, + formatPassageId, + normalizeVerseSelection, + selectHighlights, + selectVersesInColor, + serverUpdated, + settle, + versesInRun, + type OptimisticState, + type WriteOp, + type WriteToken, +} from './optimistic' + +export type UseHighlightsOptions = { + versionId: number + book: string + chapter: string +} + +export type HighlightWriteReason = 'not-signed-in' | 'auth' | 'transient' | 'invalid' + +export type HighlightWriteOutcome = + | { status: 'ok'; verses: number[] } + | { status: 'noop' } + | { + status: 'error' + reason: HighlightWriteReason + /** + * Diagnostic only. `ApiClient` replaces the response body with + * `Request failed with status ` outside development builds, so UI must + * branch on `reason` and never on this text. + */ + message: string + /** Did not land, and any optimistic paint for them has been reverted. */ + failedVerses: number[] + /** Landed server-side. Non-empty alongside `failedVerses` means a partial batch. */ + succeededVerses: number[] + } + +/** + * Fetch failures only. Writes never populate this — they report once, through + * their return value, so a transient write failure cannot evict a fetch error + * that is still true. + */ +export type HighlightsFetchError = { + reason: HighlightWriteReason + message: string +} + +export type UseHighlightsResult = { + /** Per-verse passage ids, ascending, lowercase. Feed straight into a controlled reader. */ + highlights: Highlight[] + /** The scope these highlights belong to, so callers can gate an incoming intent. */ + scope: HighlightScope + /** + * A GET is in flight. `highlights` is ALWAYS safe to render — the cache read + * is synchronous, so this never means "no data yet". Never gate a spinner on it. + */ + isRefreshing: boolean + error: HighlightsFetchError | null + refresh: () => Promise + apply: (color: string, verses: number[]) => Promise + remove: (color: string, verses: number[]) => Promise +} + +const NOT_SIGNED_IN_MESSAGE = 'Not signed in — highlights require an authenticated YouVersion user.' +const INVALID_COLOR_MESSAGE = + 'Unsupported highlight color. Use one of the five YouVersion highlight swatches.' + +/** + * `auth` wins (it changes what the user must do); retrying `invalid` is + * pointless. `not-signed-in` is ranked but unreachable here — it is never + * produced by {@link classifyApiError}, only constructed directly, so it can + * never be one of several competing failures in a batch. + */ +const REASON_RANK: Record = { + 'not-signed-in': 4, + auth: 3, + invalid: 2, + transient: 1, +} + +/** + * `api.ts` maps 401/403 to `auth` and everything else to `transient`, but + * `transient` promises "a retry may help" — wrong for any other 4xx. A malformed + * passage id, a rejected color, and a `uuid_parsing` 422 are all permanent, and + * a permanent failure presenting as flaky network is expensive to diagnose. + */ +function classifyApiError(error: HighlightsApiError): HighlightWriteReason { + if (error.kind === 'auth') { + return 'auth' + } + if (error.status !== undefined && error.status >= 400 && error.status < 500) { + return 'invalid' + } + return 'transient' +} + +let hasWarnedMissingUserId = false + +/** + * `YVUserInfo.id` is optional, and `setAuthState` only persists user info when + * one is passed — which happens on sign-in, never on the refresh path. Running + * cache-less is a real degradation (no instant mount), so say so once rather + * than failing silently. + */ +function warnMissingUserId(): void { + if (hasWarnedMissingUserId || process.env.NODE_ENV === 'production') { + return + } + hasWarnedMissingUserId = true + console.warn( + '[YouVersion SDK] Signed in but no user id is available, so highlights cannot be cached. ' + + 'Highlights still load from the network; the instant-mount cache is disabled for this session.', + ) +} + +type Identity = { key: string; scope: HighlightScope; userId: string | null } + +function identityKeyFor(userId: string | null, scope: HighlightScope): string { + return `${userId ?? ''}|${scope.versionId}|${scope.book}|${scope.chapter}` +} + +function initialStateFor(scope: HighlightScope, userId: string | null): OptimisticState { + const cached = userId === null ? null : getCachedHighlights(userId, scope) + return createOptimisticState({ + scope, + userId, + serverColors: cached === null ? {} : deriveServerColors(cached, scope), + }) +} + +function sameIdentity(state: OptimisticState, identity: Identity): boolean { + return identityKeyFor(state.userId, state.scope) === identity.key +} + +/** + * Instant, optimistic, self-healing highlight state for one chapter. + * + * Paints from the MMKV cache synchronously on first render, applies and removes + * optimistically, reconciles against the server, and reverts what fails. This is + * the only optimistic layer in the stack — the web reader's controlled + * `highlights` prop is pure projection. + * + * Requires `auth` to be configured on `YouVersionProvider`; with no auth + * configured it behaves exactly as signed out. + */ +export function useHighlights(options: UseHighlightsOptions): UseHighlightsResult { + const { appKey, apiHost, installationId } = useYouVersion() + const auth = useYVAuthOptional() + + const accessToken = auth?.accessToken ?? null + const isAuthLoading = auth?.isLoading ?? false + const userId = auth?.userInfo?.id ?? null + + const scope = useMemo( + () => ({ versionId: options.versionId, book: options.book, chapter: options.chapter }), + [options.versionId, options.book, options.chapter], + ) + + const api = useMemo( + () => createHighlightsApi({ appKey, apiHost, installationId }), + [appKey, apiHost, installationId], + ) + + const currentIdentityKey = identityKeyFor(userId, scope) + + // AC 1 — the synchronous cache read. This is only correct on a cold start + // because AuthProvider seeds `userInfo` from its own useState initializer + // (`loadCachedUserInfo()`), so `userInfo.id` already exists on first render. + // Load-bearing coupling: if that seeding ever goes async, instant mount goes + // with it. + const [state, setState] = useState(() => initialStateFor(scope, userId)) + const [identityKey, setIdentityKey] = useState(currentIdentityKey) + const [error, setError] = useState(null) + const [isRefreshing, setIsRefreshing] = useState(false) + + // Reset during render rather than in an effect: an effect would leave one + // frame where the previous chapter's overlay paints over the new chapter's + // verse numbers. This is React's documented "adjust state when props change" + // pattern — the re-render happens before anything is committed to the screen. + let renderedState = state + if (identityKey !== currentIdentityKey) { + renderedState = initialStateFor(scope, userId) + setIdentityKey(currentIdentityKey) + setState(renderedState) + setError(null) + } + + // Latest-value refs for the async layer. Seeded on mount (the fetch effect + // below runs on the same commit and must see real values), then re-synced by + // the effect that follows. Async continuations read these rather than closing + // over one render's values. + const identityRef = useRef({ key: currentIdentityKey, scope, userId }) + const stateRef = useRef(renderedState) + const authRef = useRef({ accessToken, isAuthLoading }) + + // ── The token-loading hold ───────────────────────────────────────────────── + // `userInfo` is seeded synchronously but `accessToken` only arrives after + // AuthProvider's async `loadTokens()`. In that window the user IS signed in, + // so reporting `not-signed-in` would send C3 off to prompt an already + // signed-in user. Reads recover on their own (the fetch effect is keyed on the + // token); writes are the exposure, so they wait here. + // + // Resolve on EITHER a token arriving OR auth settling with none — never on + // `isLoading` alone, because `postTokenEndpoint` has no AbortController and a + // hung network can leave `isLoading` true indefinitely. + const authWaitersRef = useRef<(() => void)[]>([]) + + // Runs after EVERY render, and must stay declared above the fetch effect: + // effects fire in declaration order, so this is what guarantees `runFetch` + // reads the identity and token of the render that scheduled it. + useEffect(() => { + identityRef.current = { key: currentIdentityKey, scope, userId } + stateRef.current = renderedState + authRef.current = { accessToken, isAuthLoading } + + if (accessToken !== null && userId === null) { + warnMissingUserId() + } + + if (accessToken === null && isAuthLoading) { + return + } + const waiters = authWaitersRef.current + authWaitersRef.current = [] + for (const resolve of waiters) { + resolve() + } + }) + + const waitForAuthSettled = useCallback((): Promise => { + const current = authRef.current + if (current.accessToken !== null || !current.isAuthLoading) { + return Promise.resolve() + } + return new Promise((resolve) => { + authWaitersRef.current.push(resolve) + }) + }, []) + + // ── Fetch ────────────────────────────────────────────────────────────────── + const inFlightRef = useRef | null>(null) + + const runFetch = useCallback((): Promise => { + const existing = inFlightRef.current + if (existing !== null) { + return existing + } + + const token = authRef.current.accessToken + if (token === null) { + // Signed out. An abandoned fetch can no longer clear the flag itself (its + // `finally` sees it is no longer the active request), and nothing else + // will run — so without this, signing out mid-fetch would leave + // `isRefreshing` true forever and any bound RefreshControl spinning. + setIsRefreshing(false) + return Promise.resolve() + } + + const captured = identityRef.current + setIsRefreshing(true) + + const promise: Promise = api + .getHighlights(token, { + version_id: captured.scope.versionId, + passage_id: `${captured.scope.book}.${captured.scope.chapter}`, + }) + .then((result) => { + // Late responses for a scope or user the reader has left are dropped — + // including the sign-out case, where writing the cache would repopulate + // what `clearHighlightsCache()` just emptied. + if (identityRef.current.key !== captured.key) { + return + } + if (!result.ok) { + setError({ reason: classifyApiError(result.error), message: result.error.message }) + return + } + if (captured.userId !== null) { + setCachedHighlights(captured.userId, captured.scope, result.value.data) + } + const serverColors = deriveServerColors(result.value.data, captured.scope) + setState((prev) => + sameIdentity(prev, captured) ? serverUpdated(prev, serverColors) : prev, + ) + setError(null) + }) + .finally(() => { + if (inFlightRef.current === promise) { + inFlightRef.current = null + setIsRefreshing(false) + } + }) + + inFlightRef.current = promise + return promise + }, [api]) + + useEffect(() => { + // Abandon any fetch belonging to the previous identity or token: there is no + // AbortController on the client, so the old request is left to resolve into + // the identity guard above while a fresh one starts here. + inFlightRef.current = null + void runFetch() + }, [identityKey, accessToken, runFetch]) + + const refresh = useCallback((): Promise => runFetch(), [runFetch]) + + // ── Writes ───────────────────────────────────────────────────────────────── + // A promise chain, not a queue: the web machine needs an explicit queue only + // because xstate cannot await. Claims paint immediately; network writes + // serialize behind this (AC 7). + const chainRef = useRef>(Promise.resolve()) + + const enqueue = useCallback((run: () => Promise) => { + const next = chainRef.current.then(run, run) + chainRef.current = next.then( + () => undefined, + () => undefined, + ) + return next + }, []) + + const runWrite = useCallback( + async (batch: { + op: WriteOp + color: string + verses: number[] + token: WriteToken + captured: Identity + }): Promise => { + const { op, color, verses, token, captured } = batch + + await waitForAuthSettled() + + // The write chain outlives an identity change: `enqueue` serializes behind + // whatever is in flight, and there is no AbortController, so one hung + // request can hold a queued batch across a sign-out and a sign-in as + // somebody else. Below we read the CURRENT token rather than one captured + // at claim time — deliberately, so a mid-write refresh does not fail the + // write — which without this guard would issue the departed user's + // passage under the new user's token, creating or deleting highlights on + // an account that never asked for them. + // + // Compare user ids, not `captured.key`: the key also encodes scope, and a + // write issued for JHN.3 that settles after the reader moved on to JHN.4 + // is still a legitimate write for JHN.3. + const isSameUser = identityRef.current.userId === captured.userId + const accessTokenNow = authRef.current.accessToken + + if (!isSameUser || accessTokenNow === null) { + // Auth settled with no token, or with a different user: from the caller + // that issued this batch, both are "you are not signed in". Reverting + // the paint is a no-op in the user-switch case — the identity change + // already reset state during render — but stays correct if that reset + // ever stops covering it. + setState((prev) => + settle(prev, { token, op, color, succeededVerses: [], failedVerses: verses }), + ) + return { + status: 'error', + reason: 'not-signed-in', + message: NOT_SIGNED_IN_MESSAGE, + failedVerses: verses, + succeededVerses: [], + } + } + + const succeededVerses: number[] = [] + const failedVerses: number[] = [] + const errors: HighlightsApiError[] = [] + + // One request per unit, each covering the verses it is responsible for. + // Apply collapses contiguous verses into a single ranged POST per run — + // [16,17,18,20] is two requests, not four. Remove issues one DELETE per + // verse, never a range, because range DELETE is unsupported server-side; + // if that is ever confirmed to work, this ternary is the only call site + // that changes. + const units = + op === 'apply' + ? collapseVerseRuns(verses).map((run) => ({ + passageId: formatPassageId(captured.scope.book, captured.scope.chapter, run), + verses: versesInRun(run), + })) + : verses.map((verse) => ({ + passageId: formatPassageId(captured.scope.book, captured.scope.chapter, { + start: verse, + end: verse, + }), + verses: [verse], + })) + + const results = await Promise.all( + units.map((unit) => + op === 'apply' + ? api.createHighlight(accessTokenNow, { + version_id: captured.scope.versionId, + passage_id: unit.passageId, + color, + }) + : api.deleteHighlight(accessTokenNow, unit.passageId, { + version_id: captured.scope.versionId, + }), + ), + ) + + units.forEach((unit, index) => { + const result = results[index] + // `results` is 1:1 with `units`, so `undefined` is unreachable — treat + // it as a failure rather than silently counting it as a success. + if (result !== undefined && result.ok) { + succeededVerses.push(...unit.verses) + return + } + failedVerses.push(...unit.verses) + if (result !== undefined) { + errors.push(result.error) + } + }) + + setState((prev) => settle(prev, { token, op, color, succeededVerses, failedVerses })) + + // Exactly one GET per settled batch, success or failure — this is what + // reconciles a partial success back to server truth. Guarded internally + // against a scope change or sign-out landing mid-write. + void runFetch() + + if (failedVerses.length === 0) { + return { status: 'ok', verses: succeededVerses } + } + + const reasons = errors.map(classifyApiError) + const reason = reasons.reduce( + (worst, candidate) => (REASON_RANK[candidate] > REASON_RANK[worst] ? candidate : worst), + 'transient', + ) + const message = + errors.find((candidate) => classifyApiError(candidate) === reason)?.message ?? + 'Highlight write failed.' + + return { status: 'error', reason, message, failedVerses, succeededVerses } + }, + [api, runFetch, waitForAuthSettled], + ) + + const startWrite = useCallback( + (op: WriteOp, rawColor: string, rawVerses: number[]): Promise => { + const captured = identityRef.current + const color = rawColor.toLowerCase() + + // Three rejections happen before any paint and must not touch state at + // all. Distinct from the token-loading hold above: holding means + // "genuinely signed in, token not here yet" (paint and wait); rejecting + // here means there is no user or no valid request to make. + if (captured.userId === null) { + return Promise.resolve({ + status: 'error', + reason: 'not-signed-in', + message: NOT_SIGNED_IN_MESSAGE, + failedVerses: normalizeVerseSelection(rawVerses), + succeededVerses: [], + }) + } + + if (!isHighlightColor(color)) { + return Promise.resolve({ + status: 'error', + reason: 'invalid', + message: INVALID_COLOR_MESSAGE, + failedVerses: normalizeVerseSelection(rawVerses), + succeededVerses: [], + }) + } + + // A remove targets what the user can SEE in that color, optimistic paint + // included. A DELETE carries a passage id and no color, so removing yellow + // across a selection that also holds a blue verse would otherwise destroy + // the blue one. Guarded on `userId` and not `isAuthenticated`, because + // during the token-loading window highlights are painted from cache while + // `isAuthenticated` is still false. + const verses = + op === 'remove' + ? selectVersesInColor(stateRef.current, normalizeVerseSelection(rawVerses), color) + : normalizeVerseSelection(rawVerses) + + if (verses.length === 0) { + return Promise.resolve({ status: 'noop' }) + } + + // Paint synchronously, before the promise is returned. + const token = createWriteToken(op) + const claimColor = op === 'apply' ? color : null + setState((prev) => claim(prev, verses, token, claimColor)) + + // Advance the ref with it. The effect that syncs `stateRef` only runs + // after a render, so a second write issued in the same tick — a toggle + // that applies and removes inside one handler — would otherwise select + // against the pre-claim paint, no-op, and strand what the apply painted. + // Chaining off `stateRef.current` instead of capturing the updater's + // result keeps the updater pure (React may invoke it twice) and computes + // the same thing React will: the same claims, in the same order, over the + // same committed state. + stateRef.current = claim(stateRef.current, verses, token, claimColor) + + return enqueue(() => runWrite({ op, color, verses, token, captured })) + }, + [enqueue, runWrite], + ) + + const apply = useCallback( + (color: string, verses: number[]) => startWrite('apply', color, verses), + [startWrite], + ) + + const remove = useCallback( + (color: string, verses: number[]) => startWrite('remove', color, verses), + [startWrite], + ) + + const highlights = useMemo(() => selectHighlights(renderedState), [renderedState]) + + return { highlights, scope, isRefreshing, error, refresh, apply, remove } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 98987bec..818c2094 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,4 +5,17 @@ export { default as YouVersionProvider } from './youversion-provider' export { useYVAuth, useYVAuthOptional } from './auth' export type { AuthConfig, AuthPermission, AuthScope, YVUserInfo } from './auth' +export { deriveServerColors, HIGHLIGHT_COLORS, isHighlightColor, useHighlights } from './highlights' +export type { + Highlight, + HighlightColor, + HighlightScope, + HighlightsFetchError, + HighlightWriteOutcome, + HighlightWriteReason, + ServerColors, + UseHighlightsOptions, + UseHighlightsResult, +} from './highlights' + export { mmkvStorage } from './storage' From 9f5ef2c89ddd41171582d01e22dff1feea2b9d8b Mon Sep 17 00:00:00 2001 From: Dustin Kelley <141975656+Dustin-Kelley@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:14:25 -0500 Subject: [PATCH 05/43] chore(deps): update Web SDK packages to 2.4.0 (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): update Web SDK packages to 2.4.0 Bumps `@youversion/platform-core` (core, 2.3.0) and `@youversion/platform-react-ui` (UI, 2.2.0) to 2.4.0, which also pulls `@youversion/platform-core` and `@youversion/platform-react-hooks` 2.4.0 into the UI subtree — one copy of each now resolves across the workspace. 2.4.0 carries the contracts the native highlights track is built against: BibleReader's controlled highlights mode (YPE-3705), the `ApiClient` fix that reads an empty-body 2xx (a successful highlight DELETE) as success, and the data-exchange primitives the just-in-time `highlights` permission grant needs. `platform-react-ui@2.4.0` requires `better-result`, which ships ESM only (`type: module`, `.mjs`, no CJS build), so two UI suites importing a value from the Web SDK barrel failed to parse. Allowing it through `transformIgnorePatterns` is not enough on its own: jest-expo's transform key is `\.[jt]sx?$`, which never matches `.mjs`, so the file reached the CJS runtime untransformed. The UI jest config moves from package.json to jest.config.js so the added `.mjs` entry can reuse the preset's own babel-jest options rather than duplicate its absolute paths. Co-Authored-By: Claude Opus 5 * fix(ui): keep the DOM reader in controlled highlights mode Web SDK 2.4.0 changed what an absent `highlights` prop means. It used to select a localStorage-backed demo — a no-op on native, where our localStorage is a per-WebView in-memory shim — and now selects the live, server-backed path. Combined with the access token `dom-apply` already hands the WebView, a color tap wrote a real highlight to the user's account, and a missing `highlights` permission could redirect the reader WebView to the hosted consent page. Passing `highlights={[]}` latches controlled mode at first mount, which makes the highlight slice a pure projection: no highlights API calls, no local store, no auth surface from the highlight path. That restores the invariant that native owns highlights (locked decision 1) until U1 (YPE-3710) wires the prop to real `useHighlights` data — presence is constant across both, so the mode never toggles. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .changeset/bump-web-sdk-2-4-0.md | 14 + packages/core/package.json | 2 +- packages/ui/jest.config.js | 30 + packages/ui/package.json | 26 +- packages/ui/src/dom/bible-reader.tsx | 9 + pnpm-lock.yaml | 1668 ++++++++++++++++++++++++-- 6 files changed, 1642 insertions(+), 107 deletions(-) create mode 100644 .changeset/bump-web-sdk-2-4-0.md create mode 100644 packages/ui/jest.config.js diff --git a/.changeset/bump-web-sdk-2-4-0.md b/.changeset/bump-web-sdk-2-4-0.md new file mode 100644 index 00000000..0f6a0091 --- /dev/null +++ b/.changeset/bump-web-sdk-2-4-0.md @@ -0,0 +1,14 @@ +--- +'@youversion/platform-react-native-expo-core': patch +'@youversion/platform-react-native-expo-ui': patch +--- + +Update the Web SDK dependencies to 2.4.0 — `@youversion/platform-core` (core, from 2.3.0) and `@youversion/platform-react-ui` (UI, from 2.2.0), which brings `@youversion/platform-core` and `@youversion/platform-react-hooks` 2.4.0 with it, so a single copy of each resolves across the workspace. + +What this pulls in that matters here: + +- `BibleReader`'s controlled highlights mode (`highlights?: Highlight[]`, `onVerseSelect`, `onHighlightApply`, `onHighlightRemove`) — the contract the native highlight bridge is built against. +- A core `ApiClient` fix: an empty-body 2xx (what a successful highlight DELETE returns) is now read as success rather than a failure. +- The data-exchange primitives (`DataExchangeClient`, `buildDataExchangeUrl`, `parseDataExchangeCallback`, `parseGrantedPermissions`) used by the just-in-time `highlights` permission grant. + +No public API changes in either package. diff --git a/packages/core/package.json b/packages/core/package.json index 9c444ff2..93a5aaa7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -101,7 +101,7 @@ "jest-expo": "56.0.5" }, "dependencies": { - "@youversion/platform-core": "2.3.0", + "@youversion/platform-core": "2.4.0", "zod": "4.4.3" } } diff --git a/packages/ui/jest.config.js b/packages/ui/jest.config.js new file mode 100644 index 00000000..81f77ce3 --- /dev/null +++ b/packages/ui/jest.config.js @@ -0,0 +1,30 @@ +// Jest config lives here rather than in package.json so the `.mjs` transform +// below can reuse the preset's own babel-jest entry instead of duplicating its +// (absolute-path) options. +const preset = require('jest-expo/jest-preset') + +module.exports = { + preset: 'jest-expo', + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '^react$': '/../../node_modules/react', + '^react/(.*)$': '/../../node_modules/react/$1', + '^react-dom$': '/../../node_modules/react-dom', + '^react-dom/(.*)$': '/../../node_modules/react-dom/$1', + }, + setupFiles: ['/jest.setup.js'], + coverageReporters: ['json', 'lcov', 'text', 'clover', 'json-summary'], + transformIgnorePatterns: [ + '/node_modules/(?!(.pnpm|react-native|@react-native|@react-native-community|expo|expo-.*|@expo|@expo-google-fonts|react-navigation|@react-navigation|@sentry/react-native|native-base|jest-expo|@rn-primitives|better-result))', + '/node_modules/react-native-reanimated/plugin/', + ], + // `@youversion/platform-react-ui` requires `better-result`, which ships ESM + // only (`.mjs`, no CJS build). jest-expo's transform key matches `.[jt]sx?` + // alone, so without this the file reaches the CJS runtime untransformed and + // dies on its `export {}` — taking down any suite that imports a value from + // the Web SDK barrel. + transform: { + ...preset.transform, + '\\.mjs$': preset.transform['\\.[jt]sx?$'], + }, +} diff --git a/packages/ui/package.json b/packages/ui/package.json index e26f821d..fd5921ea 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -52,35 +52,11 @@ "typecheck": "tsc --noEmit -p tsconfig.test.json", "prepublishOnly": "expo-module prepublishOnly && node scripts/stamp-sdk-version.cjs" }, - "jest": { - "preset": "jest-expo", - "moduleNameMapper": { - "^@/(.*)$": "/src/$1", - "^react$": "/../../node_modules/react", - "^react/(.*)$": "/../../node_modules/react/$1", - "^react-dom$": "/../../node_modules/react-dom", - "^react-dom/(.*)$": "/../../node_modules/react-dom/$1" - }, - "setupFiles": [ - "/jest.setup.js" - ], - "transformIgnorePatterns": [ - "/node_modules/(?!(.pnpm|react-native|@react-native|@react-native-community|expo|expo-.*|@expo|@expo-google-fonts|react-navigation|@react-navigation|@sentry/react-native|native-base|jest-expo|@rn-primitives))", - "/node_modules/react-native-reanimated/plugin/" - ], - "coverageReporters": [ - "json", - "lcov", - "text", - "clover", - "json-summary" - ] - }, "dependencies": { "@radix-ui/react-use-controllable-state": "1.2.2", "@rn-primitives/portal": "1.4.0", "@youversion/platform-react-native-expo-core": "workspace:*", - "@youversion/platform-react-ui": "2.2.0", + "@youversion/platform-react-ui": "2.4.0", "expo-localization": "56.0.6", "i18next": "26.3.1", "react-i18next": "17.0.8", diff --git a/packages/ui/src/dom/bible-reader.tsx b/packages/ui/src/dom/bible-reader.tsx index d25d1fd2..f0af535e 100644 --- a/packages/ui/src/dom/bible-reader.tsx +++ b/packages/ui/src/dom/bible-reader.tsx @@ -162,6 +162,15 @@ export default function BibleReaderDOM(props: BibleReaderProps) {
=56.0.0 <57.0.0' version: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) @@ -197,8 +197,8 @@ importers: specifier: workspace:* version: link:../core '@youversion/platform-react-ui': - specifier: 2.2.0 - version: 2.2.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + specifier: 2.4.0 + version: 2.4.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) expo: specifier: '>=56.0.0 <57.0.0' version: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) @@ -1523,9 +1523,28 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-accessible-icon@1.1.15': + resolution: {integrity: sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-accordion@1.2.12': resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} peerDependencies: @@ -1539,6 +1558,45 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-accordion@1.2.20': + resolution: {integrity: sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.23': + resolution: {integrity: sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.15': + resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -1552,6 +1610,45 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-aspect-ratio@1.1.15': + resolution: {integrity: sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.2.6': + resolution: {integrity: sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.11': + resolution: {integrity: sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collapsible@1.1.12': resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} peerDependencies: @@ -1565,6 +1662,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collapsible@1.1.20': + resolution: {integrity: sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.15': + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -1587,6 +1710,28 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.3.7': + resolution: {integrity: sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-context@1.1.2': resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} peerDependencies: @@ -1596,6 +1741,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -1609,6 +1763,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.1': resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: @@ -1618,6 +1785,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-direction@1.1.4': + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.1.11': resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} peerDependencies: @@ -1631,6 +1807,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.24': + resolution: {integrity: sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.3': resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: @@ -1640,6 +1842,28 @@ packages: '@types/react': optional: true + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-scope@1.1.7': resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} peerDependencies: @@ -1653,6 +1877,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-form@0.1.16': + resolution: {integrity: sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.23': + resolution: {integrity: sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-id@1.1.1': resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} peerDependencies: @@ -1662,8 +1912,382 @@ packages: '@types/react': optional: true - '@radix-ui/react-popover@1.1.15': - resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.15': + resolution: {integrity: sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.24': + resolution: {integrity: sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.24': + resolution: {integrity: sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.22': + resolution: {integrity: sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.1.16': + resolution: {integrity: sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.11': + resolution: {integrity: sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.23': + resolution: {integrity: sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.3.7': + resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.16': + resolution: {integrity: sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.4.7': + resolution: {integrity: sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.19': + resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.18': + resolution: {integrity: sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.3.7': + resolution: {integrity: sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.15': + resolution: {integrity: sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.4.7': + resolution: {integrity: sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.3.7': + resolution: {integrity: sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1675,8 +2299,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popper@1.2.8': - resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + '@radix-ui/react-tabs@1.1.21': + resolution: {integrity: sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1688,8 +2312,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-portal@1.1.9': - resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + '@radix-ui/react-toast@1.2.23': + resolution: {integrity: sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1701,8 +2325,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + '@radix-ui/react-toggle-group@1.1.19': + resolution: {integrity: sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1714,8 +2338,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + '@radix-ui/react-toggle@1.1.18': + resolution: {integrity: sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1727,8 +2351,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + '@radix-ui/react-toolbar@1.1.19': + resolution: {integrity: sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1740,8 +2364,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-separator@1.1.7': - resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + '@radix-ui/react-tooltip@1.2.16': + resolution: {integrity: sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1753,8 +2377,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1762,8 +2386,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-slot@1.2.4': - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1771,21 +2395,17 @@ packages: '@types/react': optional: true - '@radix-ui/react-tabs@1.1.13': - resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} peerDependencies: '@types/react': '*' - '@types/react-dom': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-use-callback-ref@1.1.1': - resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1793,8 +2413,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1802,8 +2422,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1820,6 +2440,24 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-escape-keydown@1.1.5': + resolution: {integrity: sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.1.1': resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} peerDependencies: @@ -1829,6 +2467,24 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.4': + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.1': resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} peerDependencies: @@ -1838,6 +2494,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-size@1.1.1': resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} peerDependencies: @@ -1847,9 +2512,34 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-size@1.1.4': + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.11': + resolution: {integrity: sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + '@react-native-masked-view/masked-view@0.3.2': resolution: {integrity: sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==} peerDependencies: @@ -2284,29 +2974,30 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} - '@youversion/platform-core@2.2.0': - resolution: {integrity: sha512-b+CitZyGWzSb+0z85T1p9NH3AEA/ML/M2B8DxMncptcx6yUyEcYwtry/t1p6CX42D252u1+SOrIN0vK0oATCWA==} + '@xstate/react@6.1.0': + resolution: {integrity: sha512-ep9F0jGTI63B/jE8GHdMpUqtuz7yRebNaKv8EMUaiSi29NOglywc2X2YSOV/ygbIK+LtmgZ0q9anoEA2iBSEOw==} peerDependencies: - linkedom: ^0.18.12 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + xstate: ^5.28.0 peerDependenciesMeta: - linkedom: + xstate: optional: true - '@youversion/platform-core@2.3.0': - resolution: {integrity: sha512-bsneE3s7jpoetWem+1gr+tZJe3Dryq7ywmdoKCPGpaLffP5GGYl0hS2hj+QAyHo0Jk9LwCGuJVtPVCDkLMPSHg==} + '@youversion/platform-core@2.4.0': + resolution: {integrity: sha512-2TV7rFxCKbigbnxt4TMcsOX+iQjWAyG2EClGpCqdmgTHAJbS/m454JVjvAINKd2uYxfVVnTHYX1BYyOPiSjfQg==} peerDependencies: linkedom: ^0.18.12 peerDependenciesMeta: linkedom: optional: true - '@youversion/platform-react-hooks@2.2.0': - resolution: {integrity: sha512-QrPe2g6Lg0IM1D2LSh2OFWO4f1DBlhXZtvpSRYTt36lPaaXkV89RxJEJYk3G0eJ1ZyrzkwuxYGvfQYJetSLTfA==} + '@youversion/platform-react-hooks@2.4.0': + resolution: {integrity: sha512-DNFOxTBtNoeOP6BlwPf7RhmruizSa/oNB9NVKexaNSUpXpFML+X0wjM4US2VLYPa28YQ0i2kdy2AOg6w/y8R+Q==} peerDependencies: react: '>=19.1.0 <20.0.0' - '@youversion/platform-react-ui@2.2.0': - resolution: {integrity: sha512-QAUQ0Yanhg25H2hgzGhqY6ObkKLmbwLhm9sa/I7U9YQVIzyJxtosk18jTy3mxy+B5ep8rlF776ZpZZscn1VdaA==} + '@youversion/platform-react-ui@2.4.0': + resolution: {integrity: sha512-HpRskk6oaHYTfR2txasWxUUa+HheAlzZZO0ceTZK6QLQzt9i3u5oXUT5ao0n7bqGdPb9VUfxZqgb658Ep8zCdg==} peerDependencies: react: '>=19.1.0 <20.0.0' react-dom: '>=19.1.0 <20.0.0' @@ -2582,6 +3273,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + better-result@2.9.2: + resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==} + big-integer@1.6.52: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} @@ -4977,6 +5671,19 @@ packages: queue@6.0.2: resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + radix-ui@1.6.7: + resolution: {integrity: sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -5816,6 +6523,15 @@ packages: '@types/react': optional: true + use-isomorphic-layout-effect@1.2.1: + resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + use-latest-callback@0.2.6: resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==} peerDependencies: @@ -5998,6 +6714,9 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xstate@5.32.4: + resolution: {integrity: sha512-E5WtDB8DBs2ZWliz2Ry9XfbSZTbBRcK/cwefBot04qQ/L5SLP16xpnTDU4/ZFXuXFhNxi7JP2RhuoGwBnM+S4A==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -8127,8 +8846,20 @@ snapshots: '@pkgr/core@0.3.6': {} + '@radix-ui/number@1.1.3': {} + '@radix-ui/primitive@1.1.3': {} + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-accessible-icon@1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-accordion@1.2.12(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -8145,6 +8876,42 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-accordion@1.2.20(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collapsible': 1.1.20(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-collection': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-alert-dialog@1.1.23(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dialog': 1.1.23(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-arrow@1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-arrow@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -8153,6 +8920,41 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-aspect-ratio@1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-avatar@1.2.6(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-checkbox@1.3.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-collapsible@1.1.12(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -8168,6 +8970,32 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-collapsible@1.1.20(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-collection@1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-collection@1.1.7(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) @@ -8202,6 +9030,24 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context-menu@2.3.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-menu': 2.1.24(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.3)': dependencies: react: 19.2.3 @@ -8214,6 +9060,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-context@1.2.2(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-dialog@1.1.15(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -8251,90 +9103,301 @@ snapshots: '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) aria-hidden: 1.2.6 react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.23(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-portal': 1.1.17(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + aria-hidden: 1.2.6 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-direction@1.1.4(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dropdown-menu@2.1.24(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-menu': 2.1.24(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.3 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-focus-scope@1.1.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.5) react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-focus-scope@1.1.7(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-focus-scope@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-form@0.1.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-label': 2.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-hover-card@1.1.23(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-popper': 1.3.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.17(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.3)': dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) react: 19.2.3 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-focus-scope@1.1.7(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-id@1.1.4(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-focus-scope@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-label@2.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.3)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) - react: 19.2.3 + '@radix-ui/react-menu@2.1.24(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-popper': 1.3.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.17(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.5) + aria-hidden: 1.2.6 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-menubar@1.1.24(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-menu': 2.1.24(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-navigation-menu@1.2.22(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-one-time-password-field@0.1.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-password-toggle-field@0.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.14)(react@19.2.5) react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) optionalDependencies: '@types/react': 19.2.14 @@ -8360,6 +9423,28 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-popover@1.1.23(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-popper': 1.3.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.17(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + aria-hidden: 1.2.6 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-popper@1.2.8(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -8377,6 +9462,32 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-popper@1.3.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-arrow': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/rect': 1.1.3 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-portal@1.1.17(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-portal@1.1.9(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -8395,6 +9506,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-presence@1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-presence@1.1.5(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) @@ -8413,6 +9532,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-primitive@2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-primitive@2.1.3(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) @@ -8429,6 +9556,31 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-progress@1.1.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-radio-group@1.4.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-roving-focus@1.1.11(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -8461,6 +9613,77 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-roving-focus@1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-scroll-area@1.2.18(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-select@2.3.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-popper': 1.3.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.17(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + aria-hidden: 1.2.6 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-separator@1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-separator@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -8469,6 +9692,24 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-slider@1.4.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) @@ -8498,6 +9739,26 @@ snapshots: '@types/react': 19.2.14 optional: true + '@radix-ui/react-slot@1.3.3(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-switch@1.3.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-tabs@1.1.13(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -8528,6 +9789,98 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-tabs@1.1.21(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-toast@1.2.23(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.17(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-toggle-group@1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toggle': 1.1.18(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-toggle@1.1.18(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-toolbar@1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-separator': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toggle-group': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-tooltip@1.2.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-popper': 1.3.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.17(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.3)': dependencies: react: 19.2.3 @@ -8540,6 +9893,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.3)': dependencies: '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.3) @@ -8556,6 +9915,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.3)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) @@ -8570,6 +9938,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.3)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) @@ -8584,6 +9959,19 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-escape-keydown@1.1.5(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.3)': dependencies: react: 19.2.3 @@ -8596,6 +9984,18 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-previous@1.1.4(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/rect': 1.1.1 @@ -8603,6 +10003,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) @@ -8610,8 +10017,25 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-size@1.1.4(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/rect@1.1.1': {} + '@radix-ui/rect@1.1.3': {} + '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)': dependencies: react: 19.2.3 @@ -9164,22 +10588,28 @@ snapshots: '@xmldom/xmldom@0.9.10': {} - '@youversion/platform-core@2.2.0': + '@xstate/react@6.1.0(@types/react@19.2.14)(react@19.2.5)(xstate@5.32.4)': dependencies: - zod: 4.1.12 + react: 19.2.5 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) + optionalDependencies: + xstate: 5.32.4 + transitivePeerDependencies: + - '@types/react' - '@youversion/platform-core@2.3.0': + '@youversion/platform-core@2.4.0': dependencies: zod: 4.1.12 - '@youversion/platform-react-hooks@2.2.0(react@19.2.5)': + '@youversion/platform-react-hooks@2.4.0(react@19.2.5)': dependencies: - '@youversion/platform-core': 2.2.0 + '@youversion/platform-core': 2.4.0 react: 19.2.5 transitivePeerDependencies: - linkedom - '@youversion/platform-react-ui@2.2.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3)': + '@youversion/platform-react-ui@2.4.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3)': dependencies: '@radix-ui/react-accordion': 1.2.12(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@radix-ui/react-dialog': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -9188,16 +10618,20 @@ snapshots: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) '@radix-ui/react-tabs': 1.1.13(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@youversion/platform-core': 2.2.0 - '@youversion/platform-react-hooks': 2.2.0(react@19.2.5) + '@xstate/react': 6.1.0(@types/react@19.2.14)(react@19.2.5)(xstate@5.32.4) + '@youversion/platform-core': 2.4.0 + '@youversion/platform-react-hooks': 2.4.0(react@19.2.5) + better-result: 2.9.2 class-variance-authority: 0.7.1 clsx: 2.1.1 i18next: 26.3.1(typescript@6.0.3) + radix-ui: 1.6.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) react-i18next: 17.0.8(i18next@26.3.1(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) tailwind-merge: 3.3.1 tw-animate-css: 1.4.0 + xstate: 5.32.4 transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -9604,6 +11038,8 @@ snapshots: dependencies: is-windows: 1.0.2 + better-result@2.9.2: {} + big-integer@1.6.52: {} binary-extensions@2.3.0: @@ -12758,6 +14194,68 @@ snapshots: dependencies: inherits: 2.0.4 + radix-ui@1.6.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-accessible-icon': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-accordion': 1.2.20(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-alert-dialog': 1.1.23(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-arrow': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-aspect-ratio': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-avatar': 1.2.6(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-checkbox': 1.3.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-collapsible': 1.1.20(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-collection': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context-menu': 2.3.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-dialog': 1.1.23(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-dropdown-menu': 2.1.24(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-focus-scope': 1.1.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-form': 0.1.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-hover-card': 1.1.23(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-label': 2.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-menu': 2.1.24(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-menubar': 1.1.24(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-navigation-menu': 1.2.22(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-one-time-password-field': 0.1.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-password-toggle-field': 0.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-popover': 1.1.23(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-popper': 1.3.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-portal': 1.1.17(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-progress': 1.1.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-radio-group': 1.4.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-scroll-area': 1.2.18(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-select': 2.3.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-separator': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slider': 1.4.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-switch': 1.3.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-tabs': 1.1.21(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toast': 1.2.23(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toggle': 1.1.18(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toggle-group': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-toolbar': 1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-tooltip': 1.2.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-escape-keydown': 1.1.5(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + range-parser@1.2.1: {} react-devtools-core@6.1.5: @@ -13876,6 +15374,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.5): + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + use-latest-callback@0.2.6(react@19.2.3): dependencies: react: 19.2.3 @@ -14069,6 +15573,8 @@ snapshots: xmlchars@2.2.0: {} + xstate@5.32.4: {} + y18n@5.0.8: {} yallist@3.1.1: {} From c548b21ad78bfbe021f981085068a8444c5d0332 Mon Sep 17 00:00:00 2001 From: Dustin Kelley <141975656+Dustin-Kelley@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:08:10 -0500 Subject: [PATCH 06/43] feat(core): wrap platform-core HighlightsClient with RN token auth (YPE-4169) (1/3) (#97) * chore: add .claude/ to .gitignore to exclude Claude-related files from version control * feat(core): wrap platform-core HighlightsClient with RN token auth Adopt @youversion/platform-core@2.3.0 so native can get/create/delete highlights with an explicit access token and typed Result failures (auth vs transient), without exporting the surface from the package index yet. Co-authored-by: Cursor * fix(example): request highlights as AuthPermission, not a scope The auth server drops unknown OIDC scopes; wire permissions:['highlights'] and keep createHighlightsApi off the package barrel (relative example import). Also restore main .gitignore (drop unrelated .claude ignore) and harden createHighlight failure-path tests. Co-authored-by: Cursor * revert(example): remove local highlights Profile harness from PR Dev-only simulator buttons and permissions wiring were for local testing, not part of YPE-4169. Co-authored-by: Cursor * chore: add changeset for internal highlights client wrapper Co-authored-by: Cursor * refactor(core): use descriptive Result generic names Rename single-letter type params to Value/Error for clearer intent. Co-authored-by: Cursor * test(core): cover 5xx paths for create and delete highlights Co-authored-by: Cursor * chore: update .gitignore to include .claude/ directory for exclusion --------- Co-authored-by: Cursor --- pnpm-lock.yaml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6db6e49c..f54c84ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,8 +130,13 @@ importers: packages/core: dependencies: '@youversion/platform-core': +<<<<<<< HEAD specifier: 2.4.0 version: 2.4.0 +======= + specifier: 2.3.0 + version: 2.3.0 +>>>>>>> 7b27845 (feat(core): wrap platform-core HighlightsClient with RN token auth (YPE-4169) (1/3) (#97)) expo: specifier: '>=56.0.0 <57.0.0' version: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) @@ -2991,8 +2996,21 @@ packages: linkedom: optional: true +<<<<<<< HEAD '@youversion/platform-react-hooks@2.4.0': resolution: {integrity: sha512-DNFOxTBtNoeOP6BlwPf7RhmruizSa/oNB9NVKexaNSUpXpFML+X0wjM4US2VLYPa28YQ0i2kdy2AOg6w/y8R+Q==} +======= + '@youversion/platform-core@2.3.0': + resolution: {integrity: sha512-bsneE3s7jpoetWem+1gr+tZJe3Dryq7ywmdoKCPGpaLffP5GGYl0hS2hj+QAyHo0Jk9LwCGuJVtPVCDkLMPSHg==} + peerDependencies: + linkedom: ^0.18.12 + peerDependenciesMeta: + linkedom: + optional: true + + '@youversion/platform-react-hooks@2.2.0': + resolution: {integrity: sha512-QrPe2g6Lg0IM1D2LSh2OFWO4f1DBlhXZtvpSRYTt36lPaaXkV89RxJEJYk3G0eJ1ZyrzkwuxYGvfQYJetSLTfA==} +>>>>>>> 7b27845 (feat(core): wrap platform-core HighlightsClient with RN token auth (YPE-4169) (1/3) (#97)) peerDependencies: react: '>=19.1.0 <20.0.0' @@ -10602,7 +10620,15 @@ snapshots: dependencies: zod: 4.1.12 +<<<<<<< HEAD '@youversion/platform-react-hooks@2.4.0(react@19.2.5)': +======= + '@youversion/platform-core@2.3.0': + dependencies: + zod: 4.1.12 + + '@youversion/platform-react-hooks@2.2.0(react@19.2.5)': +>>>>>>> 7b27845 (feat(core): wrap platform-core HighlightsClient with RN token auth (YPE-4169) (1/3) (#97)) dependencies: '@youversion/platform-core': 2.4.0 react: 19.2.5 From 4de195b27b2d023cb4ba3b8e933a89f5062839e5 Mon Sep 17 00:00:00 2001 From: Dustin Kelley <141975656+Dustin-Kelley@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:14:25 -0500 Subject: [PATCH 07/43] chore(deps): update Web SDK packages to 2.4.0 (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): update Web SDK packages to 2.4.0 Bumps `@youversion/platform-core` (core, 2.3.0) and `@youversion/platform-react-ui` (UI, 2.2.0) to 2.4.0, which also pulls `@youversion/platform-core` and `@youversion/platform-react-hooks` 2.4.0 into the UI subtree — one copy of each now resolves across the workspace. 2.4.0 carries the contracts the native highlights track is built against: BibleReader's controlled highlights mode (YPE-3705), the `ApiClient` fix that reads an empty-body 2xx (a successful highlight DELETE) as success, and the data-exchange primitives the just-in-time `highlights` permission grant needs. `platform-react-ui@2.4.0` requires `better-result`, which ships ESM only (`type: module`, `.mjs`, no CJS build), so two UI suites importing a value from the Web SDK barrel failed to parse. Allowing it through `transformIgnorePatterns` is not enough on its own: jest-expo's transform key is `\.[jt]sx?$`, which never matches `.mjs`, so the file reached the CJS runtime untransformed. The UI jest config moves from package.json to jest.config.js so the added `.mjs` entry can reuse the preset's own babel-jest options rather than duplicate its absolute paths. Co-Authored-By: Claude Opus 5 * fix(ui): keep the DOM reader in controlled highlights mode Web SDK 2.4.0 changed what an absent `highlights` prop means. It used to select a localStorage-backed demo — a no-op on native, where our localStorage is a per-WebView in-memory shim — and now selects the live, server-backed path. Combined with the access token `dom-apply` already hands the WebView, a color tap wrote a real highlight to the user's account, and a missing `highlights` permission could redirect the reader WebView to the hosted consent page. Passing `highlights={[]}` latches controlled mode at first mount, which makes the highlight slice a pure projection: no highlights API calls, no local store, no auth surface from the highlight path. That restores the invariant that native owns highlights (locked decision 1) until U1 (YPE-3710) wires the prop to real `useHighlights` data — presence is constant across both, so the mode never toggles. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- pnpm-lock.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f54c84ce..1942e097 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,7 @@ importers: packages/core: dependencies: '@youversion/platform-core': +<<<<<<< HEAD <<<<<<< HEAD specifier: 2.4.0 version: 2.4.0 @@ -137,6 +138,10 @@ importers: specifier: 2.3.0 version: 2.3.0 >>>>>>> 7b27845 (feat(core): wrap platform-core HighlightsClient with RN token auth (YPE-4169) (1/3) (#97)) +======= + specifier: 2.4.0 + version: 2.4.0 +>>>>>>> e36cf62 (chore(deps): update Web SDK packages to 2.4.0 (#103)) expo: specifier: '>=56.0.0 <57.0.0' version: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) @@ -2996,6 +3001,7 @@ packages: linkedom: optional: true +<<<<<<< HEAD <<<<<<< HEAD '@youversion/platform-react-hooks@2.4.0': resolution: {integrity: sha512-DNFOxTBtNoeOP6BlwPf7RhmruizSa/oNB9NVKexaNSUpXpFML+X0wjM4US2VLYPa28YQ0i2kdy2AOg6w/y8R+Q==} @@ -3011,6 +3017,10 @@ packages: '@youversion/platform-react-hooks@2.2.0': resolution: {integrity: sha512-QrPe2g6Lg0IM1D2LSh2OFWO4f1DBlhXZtvpSRYTt36lPaaXkV89RxJEJYk3G0eJ1ZyrzkwuxYGvfQYJetSLTfA==} >>>>>>> 7b27845 (feat(core): wrap platform-core HighlightsClient with RN token auth (YPE-4169) (1/3) (#97)) +======= + '@youversion/platform-react-hooks@2.4.0': + resolution: {integrity: sha512-DNFOxTBtNoeOP6BlwPf7RhmruizSa/oNB9NVKexaNSUpXpFML+X0wjM4US2VLYPa28YQ0i2kdy2AOg6w/y8R+Q==} +>>>>>>> e36cf62 (chore(deps): update Web SDK packages to 2.4.0 (#103)) peerDependencies: react: '>=19.1.0 <20.0.0' @@ -10620,6 +10630,7 @@ snapshots: dependencies: zod: 4.1.12 +<<<<<<< HEAD <<<<<<< HEAD '@youversion/platform-react-hooks@2.4.0(react@19.2.5)': ======= @@ -10630,6 +10641,10 @@ snapshots: '@youversion/platform-react-hooks@2.2.0(react@19.2.5)': >>>>>>> 7b27845 (feat(core): wrap platform-core HighlightsClient with RN token auth (YPE-4169) (1/3) (#97)) dependencies: +======= + '@youversion/platform-react-hooks@2.4.0(react@19.2.5)': + dependencies: +>>>>>>> e36cf62 (chore(deps): update Web SDK packages to 2.4.0 (#103)) '@youversion/platform-core': 2.4.0 react: 19.2.5 transitivePeerDependencies: From 0a7ba260024a70262b824c0a8bb5acd18a65a447 Mon Sep 17 00:00:00 2001 From: Dustin Kelley <141975656+Dustin-Kelley@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:35:33 -0500 Subject: [PATCH 08/43] chore(deps): update Web SDK packages to 2.5.0 (#116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): update Web SDK packages to 2.5.0 Bumps @youversion/platform-react-ui (packages/ui) and @youversion/platform-core (packages/core) from 2.4.0 to 2.5.0, pulling platform-core and platform-react-hooks 2.5.0 with them so a single copy of each resolves across the workspace. 2.5.0 swaps the reader's serif face from Source Serif 4 to Untitled Serif, which required two native-side changes: - reader-fonts mirrors the new UNTITLED_SERIF_FONT stack and carries it over the bridge as an `untitled-serif` token. The Web SDK's picker now emits that stack, and without a token for it encodeFontFamilyForDom passes the raw quoted string across the bridge — the exact input that corrupts @expo/dom-webview's prop injection on iOS and renders the reader blank (ADR 0009). SOURCE_SERIF_FONT stays, deprecated, so values persisted by earlier versions still encode to a known token. - The reader settings store defaults to Untitled Serif and migrates a persisted Source Serif value on read. The Web SDK runs that migration itself only when fontFamily is uncontrolled; we always pass it controlled, so the reader would have kept the deprecated stack and matched neither button in the picker. Also corrects the AGENTS.md cooldown section: pnpm 11.11 verifies the committed lockfile against minimumReleaseAge on every install, including --frozen-lockfile, so CI is not exempt as previously documented. Co-Authored-By: Claude Opus 5 * chore: exempt @youversion/* from the minimumReleaseAge cooldown The 3-day cooldown mitigates hijacked third-party releases by giving the ecosystem time to spot one. For the Web SDK packages we publish ourselves it provides little of that signal and blocks us from consuming our own work on release day — which is what held up the 2.5.0 bump. pnpm's minimumReleaseAgeExclude accepts scope globs, so '@youversion/*' covers platform-core, platform-react-hooks, and platform-react-ui. Verified against `pnpm install --frozen-lockfile`, the command CI runs, which now passes the lockfile policy check. The tradeoff is deliberate: a compromised YouVersion npm token would reach our builds with no waiting period, so these packages rely on publish-side controls rather than this cooldown. Every third-party dependency keeps the full 3 days. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .changeset/bump-web-sdk-2-5-0.md | 15 +++++++ AGENTS.md | 8 +++- packages/core/package.json | 2 +- packages/ui/package.json | 2 +- .../ui/src/lib/__tests__/reader-fonts.test.ts | 9 +++- packages/ui/src/lib/reader-fonts.ts | 20 ++++++++- .../bible-reader-settings-sheet.test.tsx | 8 ++-- .../__tests__/reader-settings-store.test.tsx | 42 +++++++++++++++++-- .../ui/src/stores/reader-settings-store.ts | 18 ++++++-- pnpm-lock.yaml | 36 +++++++++++----- pnpm-workspace.yaml | 9 ++++ 11 files changed, 142 insertions(+), 27 deletions(-) create mode 100644 .changeset/bump-web-sdk-2-5-0.md diff --git a/.changeset/bump-web-sdk-2-5-0.md b/.changeset/bump-web-sdk-2-5-0.md new file mode 100644 index 00000000..a965c466 --- /dev/null +++ b/.changeset/bump-web-sdk-2-5-0.md @@ -0,0 +1,15 @@ +--- +'@youversion/platform-react-native-expo-core': patch +'@youversion/platform-react-native-expo-ui': minor +--- + +Update the Web SDK dependencies to 2.5.0 — `@youversion/platform-core` (core, from 2.4.0) and `@youversion/platform-react-ui` (UI, from 2.4.0), which brings `@youversion/platform-core` and `@youversion/platform-react-hooks` 2.5.0 with it, so a single copy of each resolves across the workspace. + +**The Bible reader's default serif font changes from Source Serif 4 to Untitled Serif**, YouVersion's brand serif (YPE-1350, YPE-1910). The Web SDK now loads it from the YouVersion Fonts API, so a DOM component's WebView makes **a new outbound request** to `api.youversion.com` for the stylesheet plus woff2 fetches from `cdn.youversion.com`. There is no opt-out and no new prop. If those hosts are blocked, serif text falls back to Source Serif 4 with no layout break. + +Two native-side changes were required to keep the reader working across that swap: + +- `reader-fonts` now mirrors the new `UNTITLED_SERIF_FONT` stack (`'"Untitled Serif", "Source Serif 4", serif'`) and carries it over the native/DOM bridge as an `untitled-serif` token. Without this, selecting the serif font in reader settings would have sent the raw quoted stack across the bridge, which corrupts `@expo/dom-webview`'s prop injection on iOS and renders the reader blank (see `docs/adr/0009-bridge-safe-font-tokens.md`). `SOURCE_SERIF_FONT` is retained, deprecated, so values persisted by earlier versions still encode to a known token. +- The reader settings store defaults to Untitled Serif and migrates a persisted Source Serif value on read. The Web SDK performs this migration itself only when `fontFamily` is uncontrolled; we always pass it controlled, so the reader would otherwise have kept the deprecated stack and matched neither font button in the picker. + +Readers who had explicitly chosen Source Serif are migrated to Untitled Serif, matching the Web SDK. Any other `fontFamily` you pass or persist is left untouched. diff --git a/AGENTS.md b/AGENTS.md index 831bf603..d7c874aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,14 @@ YouVersion Platform React Native Expo SDK — wraps the React Web SDK (`@youvers ## Supply-Chain Protection -- **Cooldown**: `minimumReleaseAge: 4320` (3 days) in `pnpm-workspace.yaml` — resolution rejects package versions published less than 3 days ago (mitigates hijacked-release supply-chain attacks), failing with `ERR_PNPM_NO_MATURE_MATCHING_VERSION`. It applies at resolution time only, so `--frozen-lockfile` installs (CI) are unaffected; workspace packages (`workspace:*`) are inherently exempt. **`--force` does not override it** — for a genuinely urgent version use `pnpm add --config.minimumReleaseAge=0`, which lifts the cooldown for whatever that one command resolves. To exempt a package permanently rather than once, add it to `minimumReleaseAgeExclude`. +- **Cooldown**: `minimumReleaseAge: 4320` (3 days) in `pnpm-workspace.yaml` — package versions published less than 3 days ago are rejected (mitigates hijacked-release supply-chain attacks). Workspace packages (`workspace:*`) are inherently exempt. It is enforced at **two** points: + 1. **Resolution** — fails with `ERR_PNPM_NO_MATURE_MATCHING_VERSION`. **`--force` does not override it**; use `pnpm install --config.minimumReleaseAge=0`, which lifts the cooldown for whatever that one command resolves. + 2. **Lockfile verification** — every install re-checks the committed `pnpm-lock.yaml` against the policy and fails with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`. This runs on `--frozen-lockfile` too, so **CI is not exempt**, and neither is `pnpm exec` / turbo (their deps-status check shells out to `pnpm install`). Overriding at resolution is therefore *not* local-only: a lockfile carrying a too-new version reds CI until the package ages past the cutoff, at which point the same lockfile passes with no changes. + + `minimumReleaseAgeExclude` exempts packages permanently, and accepts scope globs. **`@youversion/*` is excluded** — the cooldown buys time for the ecosystem to spot a hijacked *third-party* release, but for the Web SDK we publish ourselves it only blocks us from consuming our own work on release day. Those packages lean on publish-side controls (2FA/trusted publishing) instead. Everything else keeps the full 3 days. - **Exact pins**: `dependencies` and `devDependencies` use exact versions (no `^`/`~`). This matters most in `packages/ui` and `packages/core` — their published manifests are resolved fresh on consumers' machines, where our lockfile offers no protection. `peerDependencies` stay as ranges by design (satisfied by the host app). - **Build scripts**: pnpm 11 blocks dependency postinstall scripts unless approved in `allowBuilds` (`pnpm-workspace.yaml`). If an install reports ignored builds, decide explicitly — prefer `false` when the package ships prebuilt binaries (e.g. `unrs-resolver`). -- **Version bumps**: when updating a pin, pick a version published ≥3 days ago (the cooldown enforces this at resolution time). Update cadence is defined separately. +- **Version bumps**: when updating a third-party pin, pick a version published ≥3 days ago — otherwise CI stays red until it ages past the cutoff (see the two enforcement points above). `@youversion/*` bumps are exempt and can land on release day. Update cadence is defined separately. - `expo install --fix` writes `~`-ranged versions back into `package.json` — after using it, re-pin the exact versions it chose. ## Release diff --git a/packages/core/package.json b/packages/core/package.json index 93a5aaa7..d404582f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -101,7 +101,7 @@ "jest-expo": "56.0.5" }, "dependencies": { - "@youversion/platform-core": "2.4.0", + "@youversion/platform-core": "2.5.0", "zod": "4.4.3" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index fd5921ea..46257cbf 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -56,7 +56,7 @@ "@radix-ui/react-use-controllable-state": "1.2.2", "@rn-primitives/portal": "1.4.0", "@youversion/platform-react-native-expo-core": "workspace:*", - "@youversion/platform-react-ui": "2.4.0", + "@youversion/platform-react-ui": "2.5.0", "expo-localization": "56.0.6", "i18next": "26.3.1", "react-i18next": "17.0.8", diff --git a/packages/ui/src/lib/__tests__/reader-fonts.test.ts b/packages/ui/src/lib/__tests__/reader-fonts.test.ts index f3814319..d302e4a8 100644 --- a/packages/ui/src/lib/__tests__/reader-fonts.test.ts +++ b/packages/ui/src/lib/__tests__/reader-fonts.test.ts @@ -4,27 +4,32 @@ import { FONT_FAMILY_TOKEN, INTER_FONT, SOURCE_SERIF_FONT, + UNTITLED_SERIF_FONT, } from '../reader-fonts' +const KNOWN_FONTS = [SOURCE_SERIF_FONT, UNTITLED_SERIF_FONT, INTER_FONT] + describe('reader-fonts bridge tokens', () => { it('encodes the canonical font stacks to quote-free tokens', () => { expect(encodeFontFamilyForDom(SOURCE_SERIF_FONT)).toBe(FONT_FAMILY_TOKEN.SOURCE_SERIF) + expect(encodeFontFamilyForDom(UNTITLED_SERIF_FONT)).toBe(FONT_FAMILY_TOKEN.UNTITLED_SERIF) expect(encodeFontFamilyForDom(INTER_FONT)).toBe(FONT_FAMILY_TOKEN.INTER) }) it('produces tokens that contain no double quotes (the bridge hazard)', () => { - for (const family of [SOURCE_SERIF_FONT, INTER_FONT]) { + for (const family of KNOWN_FONTS) { expect(encodeFontFamilyForDom(family)).not.toContain('"') } }) it('decodes tokens back to the exact Web SDK canonical stacks', () => { expect(decodeFontFamilyFromDom(FONT_FAMILY_TOKEN.SOURCE_SERIF)).toBe(SOURCE_SERIF_FONT) + expect(decodeFontFamilyFromDom(FONT_FAMILY_TOKEN.UNTITLED_SERIF)).toBe(UNTITLED_SERIF_FONT) expect(decodeFontFamilyFromDom(FONT_FAMILY_TOKEN.INTER)).toBe(INTER_FONT) }) it('round-trips known font families losslessly', () => { - for (const family of [SOURCE_SERIF_FONT, INTER_FONT]) { + for (const family of KNOWN_FONTS) { expect(decodeFontFamilyFromDom(encodeFontFamilyForDom(family))).toBe(family) } }) diff --git a/packages/ui/src/lib/reader-fonts.ts b/packages/ui/src/lib/reader-fonts.ts index cbd0ac18..8c1f9546 100644 --- a/packages/ui/src/lib/reader-fonts.ts +++ b/packages/ui/src/lib/reader-fonts.ts @@ -6,9 +6,24 @@ * pass these values into the DOM wrapper. */ export const INTER_FONT = '"Inter", sans-serif' as const +export const UNTITLED_SERIF_FONT = '"Untitled Serif", "Source Serif 4", serif' as const + +/** + * Superseded by {@link UNTITLED_SERIF_FONT} in Web SDK 2.5.0, which swapped the + * serif face to YouVersion's brand serif. Retained only so values persisted by + * earlier versions still round-trip as a known token (they contain `"` and so + * must not reach the bridge raw) and can be migrated — see + * `reader-settings-store`. + * + * @deprecated + */ export const SOURCE_SERIF_FONT = '"Source Serif 4", serif' as const -export type FontFamily = typeof INTER_FONT | typeof SOURCE_SERIF_FONT | (string & {}) +export type FontFamily = + | typeof INTER_FONT + | typeof UNTITLED_SERIF_FONT + | typeof SOURCE_SERIF_FONT + | (string & {}) /** * Quote-free identifiers used to carry the font family across the native <-> @@ -29,6 +44,7 @@ export type FontFamily = typeof INTER_FONT | typeof SOURCE_SERIF_FONT | (string */ export const FONT_FAMILY_TOKEN = { INTER: 'inter', + UNTITLED_SERIF: 'untitled-serif', SOURCE_SERIF: 'source-serif', } as const @@ -38,11 +54,13 @@ export type FontFamilyToken = const FONT_FAMILY_TO_TOKEN: Record = { [INTER_FONT]: FONT_FAMILY_TOKEN.INTER, + [UNTITLED_SERIF_FONT]: FONT_FAMILY_TOKEN.UNTITLED_SERIF, [SOURCE_SERIF_FONT]: FONT_FAMILY_TOKEN.SOURCE_SERIF, } const TOKEN_TO_FONT_FAMILY: Record = { [FONT_FAMILY_TOKEN.INTER]: INTER_FONT, + [FONT_FAMILY_TOKEN.UNTITLED_SERIF]: UNTITLED_SERIF_FONT, [FONT_FAMILY_TOKEN.SOURCE_SERIF]: SOURCE_SERIF_FONT, } diff --git a/packages/ui/src/native/__tests__/bible-reader-settings-sheet.test.tsx b/packages/ui/src/native/__tests__/bible-reader-settings-sheet.test.tsx index bae276b7..9e536f52 100644 --- a/packages/ui/src/native/__tests__/bible-reader-settings-sheet.test.tsx +++ b/packages/ui/src/native/__tests__/bible-reader-settings-sheet.test.tsx @@ -6,7 +6,7 @@ import type { ReactNode } from 'react' import { useShallow } from 'zustand/react/shallow' import { mmkvStorage } from '@youversion/platform-react-native-expo-core' -import { FONT_FAMILY_TOKEN, INTER_FONT, SOURCE_SERIF_FONT } from '../../lib/reader-fonts' +import { FONT_FAMILY_TOKEN, INTER_FONT, UNTITLED_SERIF_FONT } from '../../lib/reader-fonts' import { useReaderSettingsStore } from '../../stores/reader-settings-store' import { READER_LINE_SPACING } from '../../stores/types/reader-line-spacing' import { BibleReaderSettingsSheet } from '../bible-reader-settings-sheet' @@ -102,7 +102,7 @@ describe('BibleReaderSettingsSheet', () => { mmkvStorage.clearAll() useReaderSettingsStore.setState({ fontSize: BIBLE_READER_FONT.DEFAULT, - fontFamily: SOURCE_SERIF_FONT, + fontFamily: UNTITLED_SERIF_FONT, lineSpacing: READER_LINE_SPACING.DEFAULT, }) return useReaderSettingsStore.persist.rehydrate() @@ -124,8 +124,8 @@ describe('BibleReaderSettingsSheet', () => { expect(getByTestId('font-size').children).toContain(String(BIBLE_READER_FONT.DEFAULT)) // fontFamily crosses the bridge as a quote-free token (the canonical stack // contains a `"`, which @expo/dom-webview corrupts on iOS); the DOM - // component decodes it back to SOURCE_SERIF_FONT for the Web SDK. - expect(getByTestId('font-family').children).toContain(FONT_FAMILY_TOKEN.SOURCE_SERIF) + // component decodes it back to UNTITLED_SERIF_FONT for the Web SDK. + expect(getByTestId('font-family').children).toContain(FONT_FAMILY_TOKEN.UNTITLED_SERIF) expect(getByTestId('line-spacing').children).toContain(String(READER_LINE_SPACING.DEFAULT)) }) diff --git a/packages/ui/src/stores/__tests__/reader-settings-store.test.tsx b/packages/ui/src/stores/__tests__/reader-settings-store.test.tsx index 287226d2..9d1c61e0 100644 --- a/packages/ui/src/stores/__tests__/reader-settings-store.test.tsx +++ b/packages/ui/src/stores/__tests__/reader-settings-store.test.tsx @@ -4,7 +4,7 @@ import { useShallow } from 'zustand/react/shallow' import { mmkvStorage } from '@youversion/platform-react-native-expo-core' import { READER_SETTINGS_PERSIST_KEY } from '../../lib/constants' -import { INTER_FONT, SOURCE_SERIF_FONT } from '../../lib/reader-fonts' +import { INTER_FONT, SOURCE_SERIF_FONT, UNTITLED_SERIF_FONT } from '../../lib/reader-fonts' import { useReaderSettingsStore } from '../reader-settings-store' import { READER_LINE_SPACING } from '../types/reader-line-spacing' @@ -25,7 +25,7 @@ async function resetReaderSettingsStore() { mmkvStorage.clearAll() useReaderSettingsStore.setState({ fontSize: BIBLE_READER_FONT.DEFAULT, - fontFamily: SOURCE_SERIF_FONT, + fontFamily: UNTITLED_SERIF_FONT, lineSpacing: READER_LINE_SPACING.DEFAULT, }) await useReaderSettingsStore.persist.rehydrate() @@ -40,10 +40,46 @@ describe('useReaderSettingsStore', () => { const { result } = renderHook(() => useReaderSettingsSlice()) expect(result.current.fontSize).toBe(BIBLE_READER_FONT.DEFAULT) - expect(result.current.fontFamily).toBe(SOURCE_SERIF_FONT) + expect(result.current.fontFamily).toBe(UNTITLED_SERIF_FONT) expect(result.current.lineSpacing).toBe(READER_LINE_SPACING.DEFAULT) }) + it('migrates a persisted Source Serif stack to Untitled Serif on read', async () => { + mmkvStorage.set( + READER_SETTINGS_PERSIST_KEY, + JSON.stringify({ + state: { + fontSize: BIBLE_READER_FONT.DEFAULT, + fontFamily: SOURCE_SERIF_FONT, + lineSpacing: READER_LINE_SPACING.DEFAULT, + }, + version: 0, + }), + ) + await useReaderSettingsStore.persist.rehydrate() + + const { result } = renderHook(() => useReaderSettingsSlice()) + expect(result.current.fontFamily).toBe(UNTITLED_SERIF_FONT) + }) + + it('leaves a non-serif persisted font family untouched', async () => { + mmkvStorage.set( + READER_SETTINGS_PERSIST_KEY, + JSON.stringify({ + state: { + fontSize: BIBLE_READER_FONT.DEFAULT, + fontFamily: INTER_FONT, + lineSpacing: READER_LINE_SPACING.DEFAULT, + }, + version: 0, + }), + ) + await useReaderSettingsStore.persist.rehydrate() + + const { result } = renderHook(() => useReaderSettingsSlice()) + expect(result.current.fontFamily).toBe(INTER_FONT) + }) + it('persists font size + family across rerenders via MMKV', () => { const first = renderHook(() => useReaderSettingsSlice()) diff --git a/packages/ui/src/stores/reader-settings-store.ts b/packages/ui/src/stores/reader-settings-store.ts index 0e6e72a1..d24b89da 100644 --- a/packages/ui/src/stores/reader-settings-store.ts +++ b/packages/ui/src/stores/reader-settings-store.ts @@ -4,7 +4,7 @@ import { createJSONStorage, persist } from 'zustand/middleware' import { mmkvStorage } from '@youversion/platform-react-native-expo-core' import { READER_SETTINGS_PERSIST_KEY } from '../lib/constants' -import { SOURCE_SERIF_FONT, type FontFamily } from '../lib/reader-fonts' +import { SOURCE_SERIF_FONT, UNTITLED_SERIF_FONT, type FontFamily } from '../lib/reader-fonts' import { READER_LINE_SPACING } from './types/reader-line-spacing' /** MMKV-backed storage for zustand `persist` (sync; hydrates at store creation). */ @@ -40,6 +40,16 @@ const normalizeLineSpacing = (value: number | undefined): number => ? (value as number) : READER_LINE_SPACING.DEFAULT +/** + * Web SDK 2.5.0 replaced the Source Serif stack with Untitled Serif and migrates + * the old value on load — but only when `fontFamily` is uncontrolled. We always + * pass it controlled, so that migration never runs for us and we do it here + * instead. Without it the picker matches neither font button and shows no + * active state. + */ +const normalizeFontFamily = (value: FontFamily): FontFamily => + value === SOURCE_SERIF_FONT ? UNTITLED_SERIF_FONT : value + /** * Internal persisted reader settings for the native Bible reader. * Not part of the package public API. @@ -48,7 +58,7 @@ export const useReaderSettingsStore = create()( persist( (set) => ({ fontSize: BIBLE_READER_FONT.DEFAULT, - fontFamily: SOURCE_SERIF_FONT, + fontFamily: UNTITLED_SERIF_FONT, lineSpacing: READER_LINE_SPACING.DEFAULT, setFontSize: (size) => set({ fontSize: clampBibleReaderFontSize(size) }), setFontFamily: (fontFamily) => set({ fontFamily }), @@ -73,7 +83,9 @@ export const useReaderSettingsStore = create()( fontSize: clampBibleReaderFontSize( persistedReaderSlice.fontSize ?? currentState.fontSize, ), - fontFamily: persistedReaderSlice.fontFamily ?? currentState.fontFamily, + fontFamily: normalizeFontFamily( + persistedReaderSlice.fontFamily ?? currentState.fontFamily, + ), lineSpacing: normalizeLineSpacing( persistedReaderSlice.lineSpacing ?? currentState.lineSpacing, ), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1942e097..71284799 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,7 @@ importers: dependencies: '@youversion/platform-core': <<<<<<< HEAD +<<<<<<< HEAD <<<<<<< HEAD specifier: 2.4.0 version: 2.4.0 @@ -142,6 +143,10 @@ importers: specifier: 2.4.0 version: 2.4.0 >>>>>>> e36cf62 (chore(deps): update Web SDK packages to 2.4.0 (#103)) +======= + specifier: 2.5.0 + version: 2.5.0 +>>>>>>> 6ec4534 (chore(deps): update Web SDK packages to 2.5.0 (#116)) expo: specifier: '>=56.0.0 <57.0.0' version: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) @@ -207,8 +212,8 @@ importers: specifier: workspace:* version: link:../core '@youversion/platform-react-ui': - specifier: 2.4.0 - version: 2.4.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + specifier: 2.5.0 + version: 2.5.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) expo: specifier: '>=56.0.0 <57.0.0' version: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) @@ -2993,14 +2998,15 @@ packages: xstate: optional: true - '@youversion/platform-core@2.4.0': - resolution: {integrity: sha512-2TV7rFxCKbigbnxt4TMcsOX+iQjWAyG2EClGpCqdmgTHAJbS/m454JVjvAINKd2uYxfVVnTHYX1BYyOPiSjfQg==} + '@youversion/platform-core@2.5.0': + resolution: {integrity: sha512-hcmM0LQ+r00CkJBDAwh9yRGoxbucl9D4rLCJa6BaCOJ7c7+f9tQ61JaIIUO0/XqO71mZu+TCVDazXVlM9t5WLw==} peerDependencies: linkedom: ^0.18.12 peerDependenciesMeta: linkedom: optional: true +<<<<<<< HEAD <<<<<<< HEAD <<<<<<< HEAD '@youversion/platform-react-hooks@2.4.0': @@ -3021,11 +3027,15 @@ packages: '@youversion/platform-react-hooks@2.4.0': resolution: {integrity: sha512-DNFOxTBtNoeOP6BlwPf7RhmruizSa/oNB9NVKexaNSUpXpFML+X0wjM4US2VLYPa28YQ0i2kdy2AOg6w/y8R+Q==} >>>>>>> e36cf62 (chore(deps): update Web SDK packages to 2.4.0 (#103)) +======= + '@youversion/platform-react-hooks@2.5.0': + resolution: {integrity: sha512-wy/q31uQBHwJLdOYYsLCGBZHEGnWOfZOlXGYRP/Ln+RdfUnm1AcY1d35i+XGuxmnuzb9hFCocyU+21sQpGZstQ==} +>>>>>>> 6ec4534 (chore(deps): update Web SDK packages to 2.5.0 (#116)) peerDependencies: react: '>=19.1.0 <20.0.0' - '@youversion/platform-react-ui@2.4.0': - resolution: {integrity: sha512-HpRskk6oaHYTfR2txasWxUUa+HheAlzZZO0ceTZK6QLQzt9i3u5oXUT5ao0n7bqGdPb9VUfxZqgb658Ep8zCdg==} + '@youversion/platform-react-ui@2.5.0': + resolution: {integrity: sha512-KrT92Vs4C8X6lIAjmLj2XyjFdyaQ0jX3DLoYAF5NXCYUW1NX+xo/dHn9dXceyMPuaIiBX1c0tBPHUe/ySUrlPQ==} peerDependencies: react: '>=19.1.0 <20.0.0' react-dom: '>=19.1.0 <20.0.0' @@ -10626,10 +10636,11 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@youversion/platform-core@2.4.0': + '@youversion/platform-core@2.5.0': dependencies: zod: 4.1.12 +<<<<<<< HEAD <<<<<<< HEAD <<<<<<< HEAD '@youversion/platform-react-hooks@2.4.0(react@19.2.5)': @@ -10646,11 +10657,16 @@ snapshots: dependencies: >>>>>>> e36cf62 (chore(deps): update Web SDK packages to 2.4.0 (#103)) '@youversion/platform-core': 2.4.0 +======= + '@youversion/platform-react-hooks@2.5.0(react@19.2.5)': + dependencies: + '@youversion/platform-core': 2.5.0 +>>>>>>> 6ec4534 (chore(deps): update Web SDK packages to 2.5.0 (#116)) react: 19.2.5 transitivePeerDependencies: - linkedom - '@youversion/platform-react-ui@2.4.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3)': + '@youversion/platform-react-ui@2.5.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3)': dependencies: '@radix-ui/react-accordion': 1.2.12(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@radix-ui/react-dialog': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -10660,8 +10676,8 @@ snapshots: '@radix-ui/react-tabs': 1.1.13(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) '@xstate/react': 6.1.0(@types/react@19.2.14)(react@19.2.5)(xstate@5.32.4) - '@youversion/platform-core': 2.4.0 - '@youversion/platform-react-hooks': 2.4.0(react@19.2.5) + '@youversion/platform-core': 2.5.0 + '@youversion/platform-react-hooks': 2.5.0(react@19.2.5) better-result: 2.9.2 class-variance-authority: 0.7.1 clsx: 2.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b1ead5b4..bb49a192 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,15 @@ allowBuilds: minimumReleaseAge: 4320 # 3-day cooldown — supply-chain attack mitigation +# First-party YouVersion packages are exempt from the cooldown. The cooldown +# buys time for the ecosystem to spot a hijacked *third-party* release; for the +# Web SDK we publish ourselves, it only blocks us from consuming our own work on +# release day. The tradeoff is real and deliberate: a compromised YouVersion npm +# token would reach our builds with no waiting period, so these packages lean on +# publish-side controls (2FA/trusted publishing) rather than this cooldown. +minimumReleaseAgeExclude: + - '@youversion/*' + # Migrated from .npmrc — pnpm 11 only reads auth/registry settings from .npmrc, # all other settings must live here (https://pnpm.io/blog/releases/11.0). # Required for Expo DOM Components + pnpm compatibility. From 3f4713865e44d340e7ab6a4bd2cafe3fd4eb1512 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Tue, 4 Aug 2026 16:35:21 -0500 Subject: [PATCH 09/43] feat(core): granted_permissions read-back + per-user permission cache (YPE-3709) (1/3) (#112) * feat(core): granted_permissions read-back + per-user permission cache (YPE-3709) (1/3) Parse granted_permissions off the OAuth app redirect (before the /auth/callback hop, which drops it), cache it per user in MMKV, and expose grantedPermissions / hasPermission / invalidatePermissions on the auth context, seeded synchronously so the first render answers correctly. AuthPermission widens to an open union so server-side additions never read as denials. Lean reimplementation replacing PR #105: no sign-in epoch guard (the pre-existing race is filed in .claude/bugs/auth-session-commit-serialization.md with its structural fix), no isSameUser branching (the user-scoped cache read makes user switches self-healing), and ADR 0014 trimmed to the decision. Co-Authored-By: Claude Fable 5 * docs(auth): simplify comments in auth context and permission handling * chore(core): address review feedback on granted-permissions read-back Follow-ups from code review of #112. No behaviour change. - Cite the gateway OpenAPI spec for the three-state grant contract. The `null` / `[]` / populated model is the server's documented contract, not an inference, but no live denial has been measured (the YPE-3706 spike never ran). Note the dependency explicitly on the `else` branch that restores a cached grant, since that branch is only correct while a denial arrives as an empty value rather than an absent key. - Add a parity test for GRANTED_PERMISSIONS_KEY_PATTERN. The pattern restates one that lives inline in platform-core's parseGrantedPermissions; platform-core exports neither the pattern nor a presence-detecting helper, so the duplication cannot be removed from this side. The test drives both through their public API over 15 key spellings and fails if they drift. - Convert the new granted-permissions tests to userEvent per AGENTS.md. Pre-existing fireEvent calls elsewhere in the file are left alone. - Rewrite the changeset in plain language, and correct it: the AuthPermission open union does not stop unknown permissions reading as denials. That is done by grantedPermissions being typed readonly string[] | null and readGrantedPermissions returning unfiltered strings. The union only lets AuthConfig.permissions and hasPermission() accept unknown strings. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Fable 5 Co-authored-by: Cameron Pak --- .changeset/core-granted-permissions.md | 24 +++ AGENTS.md | 7 +- CONTEXT.md | 6 + docs/adr/0014-cached-grant-is-a-hint.md | 36 +++++ .../src/auth/__tests__/auth-provider.test.tsx | 149 +++++++++++++++++- .../granted-permissions-cache.test.ts | 102 ++++++++++++ .../__tests__/granted-permissions.test.ts | 115 ++++++++++++++ .../core/src/auth/__tests__/pkce-flow.test.ts | 46 +++++- .../src/auth/__tests__/use-yv-auth.test.tsx | 3 + packages/core/src/auth/auth-context.tsx | 19 ++- packages/core/src/auth/auth-provider.tsx | 63 +++++++- packages/core/src/auth/constants.ts | 1 + .../src/auth/granted-permissions-cache.ts | 74 +++++++++ packages/core/src/auth/granted-permissions.ts | 40 +++++ packages/core/src/auth/index.ts | 8 +- packages/core/src/auth/pkce-flow.ts | 13 +- packages/core/src/auth/types.ts | 18 ++- .../__tests__/use-highlights.test.tsx | 3 + packages/core/src/index.ts | 2 +- 19 files changed, 715 insertions(+), 14 deletions(-) create mode 100644 .changeset/core-granted-permissions.md create mode 100644 docs/adr/0014-cached-grant-is-a-hint.md create mode 100644 packages/core/src/auth/__tests__/granted-permissions-cache.test.ts create mode 100644 packages/core/src/auth/__tests__/granted-permissions.test.ts create mode 100644 packages/core/src/auth/granted-permissions-cache.ts create mode 100644 packages/core/src/auth/granted-permissions.ts diff --git a/.changeset/core-granted-permissions.md b/.changeset/core-granted-permissions.md new file mode 100644 index 00000000..7de51b75 --- /dev/null +++ b/.changeset/core-granted-permissions.md @@ -0,0 +1,24 @@ +--- +'@youversion/platform-react-native-expo-core': minor +--- + +The auth context now reports which permissions the user granted. `useYVAuth()` adds three members: + +- `grantedPermissions` lists the permissions the user granted. +- `hasPermission()` reports whether one permission is in that list. +- `invalidatePermissions()` clears the cached grant. + +`grantedPermissions` has three states: + +- `null` means the app never requested permissions. +- `[]` means the app requested permissions, and the user denied them. +- A populated list means the user granted those permissions. + +The SDK handles the grant as follows: + +- It reads the grant from the OAuth app redirect. +- It caches the grant per user in MMKV. +- It loads the cached grant on cold start. +- It clears the grant on sign-out. + +`AuthPermission` is now an open union (`KnownAuthPermission | (string & {})`). As a result, `AuthConfig.permissions` and `hasPermission()` accept a permission string that this SDK version does not know about. `grantedPermissions` is typed `readonly string[] | null`, so it keeps every value the server returns. diff --git a/AGENTS.md b/AGENTS.md index d7c874aa..8ced0cce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,14 +148,17 @@ Keep `apps/example/metro.config.js` minimal — just `getDefaultConfig(__dirname **UI** (`@youversion/platform-react-native-expo-ui`): `YouVersionProvider`, `BibleCard`, `BibleChapterPickerSheet`, `BibleReader`, `BibleReaderSettingsSheet`, `BibleTextView`, `BibleVersionPickerSheet`, `VerseOfTheDay`, and `YouVersionAuthButton` -**Core** (`@youversion/platform-react-native-expo-core`): `YouVersionProvider` (installation id + optional auth), `useYouVersion`, `useYVAuth`, `useHighlights`, `deriveServerColors`, `HIGHLIGHT_COLORS` / `isHighlightColor`, `mmkvStorage`, auth types (`AuthConfig`, `AuthPermission`, `AuthScope`, `YVUserInfo`), and highlights types (`Highlight`, `HighlightScope`, `ServerColors`, `HighlightWriteOutcome`, `HighlightsFetchError`, `UseHighlightsOptions`, `UseHighlightsResult`) +**Core** (`@youversion/platform-react-native-expo-core`): `YouVersionProvider` (installation id + optional auth), `useYouVersion`, `useYVAuth` (its value carries `grantedPermissions` / `hasPermission` / `invalidatePermissions` alongside the sign-in surface), `useHighlights`, `deriveServerColors`, `HIGHLIGHT_COLORS` / `isHighlightColor`, `mmkvStorage`, auth types (`AuthConfig`, `AuthPermission`, `KnownAuthPermission`, `AuthScope`, `YVUserInfo`), and highlights types (`Highlight`, `HighlightScope`, `ServerColors`, `HighlightWriteOutcome`, `HighlightsFetchError`, `UseHighlightsOptions`, `UseHighlightsResult`) UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider`. Import Bible components from UI; import `useYVAuth` from core. ## Auth (core) - Optional PKCE OAuth when `auth: { redirectUri, scopes?, permissions? }` is passed to core `YouVersionProvider` (forwarded by UI provider). -- On RN, `permissions` is configured on `YouVersionProvider`'s `auth` config (not on `YouVersionAuthButton` / `signIn()`), unlike web. The example app stays scopes-only until grant reporting lands (C3). +- On RN, `permissions` is configured on `YouVersionProvider`'s `auth` config (not on `YouVersionAuthButton` / `signIn()`), unlike web. The example app stays scopes-only until the permission flow lands (C3). +- **Requesting a permission is not being granted it.** `useYVAuth()` reads the grant back: `hasPermission(permission)` for a single check, `grantedPermissions` for the list, and `invalidatePermissions()` to drop a stale grant after a 401/403 so the next pre-flight re-prompts. Three states, and collapsing them loses "the user said no": `null` = nothing requested / unknown, `[]` = requested and denied, populated = granted. +- The grant rides only on the **app redirect** — the `/auth/callback` `Location` hop drops it — so `pkce-flow.ts` parses it from `result.url` before that hop, and a test in `__tests__/pkce-flow.test.ts` pins the ordering. It is then cached per user in MMKV (redirect parsing in `auth/granted-permissions.ts`, the cache in `auth/granted-permissions-cache.ts`), seeded synchronously in a `useState` initializer so it is correct on the first render, and purged in `clearAuthState`. `AuthPermission` is an open union and cached values are kept verbatim, not filtered — filtering would turn a server-side addition into a silent denial. +- **The cached grant is a hint, not an authority** ([ADR 0014](docs/adr/0014-cached-grant-is-a-hint.md)). `hasPermission` chooses UI and skips redundant prompts; the server enforces. Under MMKV failure a revoked grant can survive — clearing is best-effort by design, because it must never break sign-out — so a privileged action gates on the pre-flight, never on a cached `true`. Read the ADR before "fixing" `clearGrantedPermissions`. - `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/`). diff --git a/CONTEXT.md b/CONTEXT.md index 0d30b83d..a1a3c514 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -118,6 +118,10 @@ _Avoid_: Optimistic state (too vague — this is one specific layer), **Server C What an `apply` or `remove` resolves to: `ok` with the verses that landed, `noop` when there was nothing to write, or `error` carrying a `reason` (`not-signed-in` / `auth` / `invalid` / `transient`), a diagnostic `message`, and both `failedVerses` (reverted) and `succeededVerses` (landed — non-empty means a partial batch). The only channel a write failure reports on; the hook's `error` state is for fetches alone, so a failed write can never evict a fetch error that is still true. _Avoid_: Branching on `message` (generic outside development builds); routing write failures through the hook's `error`; a separate `partial` status (the two verse arrays already say it) +**Granted Permissions**: +What the user actually granted at sign-in, read off the OAuth **app redirect** and cached per user. A three-state signal, not a list: `null` = no `granted_permissions` key at all, so nothing was requested and nothing is known; `[]` = requested and **denied**; populated = granted. Requesting a permission (`AuthConfig.permissions`) is a separate thing from being granted it. Values the SDK does not recognize are kept verbatim rather than narrowed to the known permission union. +_Avoid_: Scopes (permissions travel as `requested_permissions[]`, never in `scope`); collapsing `[]` into `null` (it erases "the user said no"); "requested permissions" when you mean the grant + ## Relationships - A **React Web SDK Component** may expose reusable content that can be rendered by an **Expo DOM Component**. @@ -144,6 +148,8 @@ _Avoid_: Branching on `message` (generic outside development builds); routing wr - **Server Colors** are derived from **Cached Highlights** for a given **Highlight Scope**, never stored: entries whose version, book, or chapter does not match the scope are ignored, so stale data cannot mispaint. - A **Highlight Overlay** sits on top of **Server Colors** and is the only optimistic layer in the stack — the web reader's controlled `highlights` prop is pure projection. Each write claims the verses it paints, and a settling write only reverts verses it still owns. - A **Highlight Write Outcome** is the sole report of a write's fate, and is where C3's sign-in branch reads from. +- **Granted Permissions** are read only from the app redirect, never from the `/auth/callback` hop, which drops them. They are **Native-Owned State** cached per user in MMKV and purged with the rest of auth state on sign-out; a stale grant can be invalidated so the next pre-flight re-prompts. +- A permission pre-flight reads **Granted Permissions**; a **Highlight Write Outcome** of `reason: 'auth'` is the corrective path when that cache is wrong, not the primary signal. ## Example Dialogue diff --git a/docs/adr/0014-cached-grant-is-a-hint.md b/docs/adr/0014-cached-grant-is-a-hint.md new file mode 100644 index 00000000..cc35b102 --- /dev/null +++ b/docs/adr/0014-cached-grant-is-a-hint.md @@ -0,0 +1,36 @@ +# 14. The cached permission grant is a hint, not an authority + +Date: 2026-08-03 + +## Status + +Accepted + +## Context + +`granted_permissions` arrives on the OAuth app redirect and is cached per user in MMKV (YPE-3709), seeded synchronously in a `useState` initializer so `hasPermission` answers correctly on the first render after a cold start — the same pattern `userInfo` already follows. + +Invalidation is expressed as an _absence_ (`clearGrantedPermissions` removes the key) in a store that is permitted to fail silently — the clear cannot throw, because a storage failure must not break sign-out over a cache that only seeds one render. So a removal failure can leave a stale grant that reseeds on the next cold start. Making deletion more reliable cannot close that class (each mitigation narrows the window and invites the next finding); making the cache authoritative means moving the grant into the async token record and giving up the synchronous first-render seed the subtask exists to provide. + +## Decision + +The cached grant is a **hint**. It makes the first render correct in the common case; it is not an authorization decision. + +- `hasPermission` / `grantedPermissions` are advisory: use them to choose UI and skip a redundant prompt. +- The server is the enforcement point. A write's 401/403 stays authoritative and drives `invalidatePermissions`, so a stale hint is corrected rather than trusted twice. +- Privileged actions also gate on `isAuthenticated` / `isLoading`, since the seed precedes session validation. + +Clearing stays best-effort, with no mitigation layered on top: + +| Failure | Behavior | +| ------------- | ------------------------------------------------ | +| Normal clear | Entry removed | +| Removal fails | **Stale grant accepted — bounded by the server** | + +The second row is the accepted residual: it requires an MMKV removal to fail, and its worst outcome is a skipped prompt followed by a request the server denies. + +## Consequences + +The blast radius of a stale grant is a redundant request and a re-prompt, never access the user does not have. That holds only while the write path treats the server's 401/403 as authoritative and corrects the cache through `invalidatePermissions`; a future change acting on `hasPermission` without that corrective edge voids this ADR and reopens the authoritative-cache option. + +Reviewers will keep rediscovering the residual, because in isolation `clearGrantedPermissions` looks like a bug. It is a decision, recorded here so it can be disagreed with on the merits rather than re-patched. diff --git a/packages/core/src/auth/__tests__/auth-provider.test.tsx b/packages/core/src/auth/__tests__/auth-provider.test.tsx index 79bf2dc7..9b23635c 100644 --- a/packages/core/src/auth/__tests__/auth-provider.test.tsx +++ b/packages/core/src/auth/__tests__/auth-provider.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native' +import { act, fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native' import { useState } from 'react' import { AppState, Pressable, Text, View } from 'react-native' import AuthProvider from '../auth-provider' @@ -81,7 +81,14 @@ function AuthPeek() { {auth.accessToken ?? 'null'} {auth.userInfo ? JSON.stringify(auth.userInfo) : 'null'} {auth.error?.message ?? 'null'} + + {auth.grantedPermissions ? JSON.stringify(auth.grantedPermissions) : 'null'} + + {String(auth.hasPermission('highlights'))} {signInOutcome} + auth.invalidatePermissions()}> + invalidatePermissions + { @@ -504,3 +511,143 @@ describe('AuthProvider — AppState wiring', () => { expect(remove).toHaveBeenCalledTimes(1) }) }) + +describe('AuthProvider — granted permissions', () => { + beforeEach(() => { + mockLoadTokens.mockResolvedValue(noStoredTokens) + }) + + function arrangeSignIn(grantedPermissions: string[] | null, userInfo = adaUserInfo) { + mockSignInWithPKCE.mockResolvedValue({ + kind: 'success', + tokens: validTokens, + userInfo, + grantedPermissions, + }) + } + + async function renderAndSignIn(user: ReturnType) { + render( + + + , + ) + await waitFor(() => expect(getText('isLoading')).toBe('false')) + await user.press(screen.getByTestId('signIn')) + await waitFor(() => expect(getText('signInOutcome')).toBe('resolved')) + } + + it('sign-in with a grant makes hasPermission true and persists it per user', async () => { + const user = userEvent.setup() + arrangeSignIn(['highlights']) + await renderAndSignIn(user) + + expect(getText('hasHighlights')).toBe('true') + expect(JSON.parse(getText('grantedPermissions'))).toEqual(['highlights']) + expect(JSON.parse(mockMmkv.get(MMKV_AUTH_KEYS.grantedPermissions)!)).toEqual({ + userId: 'u1', + permissions: ['highlights'], + }) + }) + + it('seeds the grant synchronously from cache on a cold start', async () => { + mockMmkv.set(MMKV_AUTH_KEYS.cachedUserInfo, JSON.stringify(adaUserInfo)) + mockMmkv.set( + MMKV_AUTH_KEYS.grantedPermissions, + JSON.stringify({ userId: 'u1', permissions: ['highlights'] }), + ) + mockLoadTokens.mockResolvedValue({ + accessToken: 'a', + refreshToken: 'r', + expiryDate: new Date(Date.now() + 60 * 60 * 1000), + }) + + render( + + + , + ) + + // First render, before any effect settles — the whole point of the sync seed. + expect(getText('hasHighlights')).toBe('true') + await waitFor(() => expect(getText('isLoading')).toBe('false')) + expect(getText('hasHighlights')).toBe('true') + }) + + it('keeps a denied grant ([]) distinguishable from never-requested (null)', async () => { + const user = userEvent.setup() + arrangeSignIn([]) + await renderAndSignIn(user) + + expect(getText('hasHighlights')).toBe('false') + expect(getText('grantedPermissions')).toBe('[]') + }) + + it('a scopes-only re-sign-in (null grant) preserves the same user’s earlier grant', async () => { + // signIn on an already-signed-in user does not pass through clearAuthState, + // so a redirect that says nothing about permissions must not wipe the grant. + const user = userEvent.setup() + arrangeSignIn(['highlights']) + await renderAndSignIn(user) + expect(getText('hasHighlights')).toBe('true') + + arrangeSignIn(null) + await user.press(screen.getByTestId('signIn')) + await waitFor(() => expect(getText('signInOutcome')).toBe('resolved')) + + expect(getText('hasHighlights')).toBe('true') + expect(mockMmkv.has(MMKV_AUTH_KEYS.grantedPermissions)).toBe(true) + }) + + it('a scopes-only sign-in by a different user reads no grant', async () => { + const user = userEvent.setup() + arrangeSignIn(['highlights']) + await renderAndSignIn(user) + expect(getText('hasHighlights')).toBe('true') + + arrangeSignIn(null, { ...adaUserInfo, id: 'u2', name: 'Bea' }) + await user.press(screen.getByTestId('signIn')) + await waitFor(() => expect(getText('signInOutcome')).toBe('resolved')) + + expect(getText('hasHighlights')).toBe('false') + expect(getText('grantedPermissions')).toBe('null') + }) + + it('sign-out purges the cached grant and resets state to null', async () => { + const user = userEvent.setup() + arrangeSignIn(['highlights']) + await renderAndSignIn(user) + expect(getText('hasHighlights')).toBe('true') + + await user.press(screen.getByTestId('signOut')) + + await waitFor(() => expect(getText('isAuthenticated')).toBe('false')) + expect(getText('hasHighlights')).toBe('false') + expect(getText('grantedPermissions')).toBe('null') + expect(mockMmkv.has(MMKV_AUTH_KEYS.grantedPermissions)).toBe(false) + }) + + it('invalidatePermissions drops both the cache and the in-memory grant', async () => { + const user = userEvent.setup() + arrangeSignIn(['highlights']) + await renderAndSignIn(user) + expect(getText('hasHighlights')).toBe('true') + + await user.press(screen.getByTestId('invalidatePermissions')) + + expect(getText('hasHighlights')).toBe('false') + expect(getText('grantedPermissions')).toBe('null') + expect(mockMmkv.has(MMKV_AUTH_KEYS.grantedPermissions)).toBe(false) + }) + + it('keeps a granted permission outside the known union verbatim', async () => { + const user = userEvent.setup() + arrangeSignIn(['highlights', 'brand_new_permission']) + await renderAndSignIn(user) + + expect(JSON.parse(getText('grantedPermissions'))).toEqual([ + 'highlights', + 'brand_new_permission', + ]) + }) +}) diff --git a/packages/core/src/auth/__tests__/granted-permissions-cache.test.ts b/packages/core/src/auth/__tests__/granted-permissions-cache.test.ts new file mode 100644 index 00000000..6feada58 --- /dev/null +++ b/packages/core/src/auth/__tests__/granted-permissions-cache.test.ts @@ -0,0 +1,102 @@ +import { mmkvStorage } from '../../storage/mmkv-storage' +import { MMKV_AUTH_KEYS } from '../constants' +import { + clearGrantedPermissions, + loadCachedGrantedPermissions, + saveGrantedPermissions, +} from '../granted-permissions-cache' + +const mockMmkv = new Map() + +jest.mock('../../storage/mmkv-storage', () => ({ + mmkvStorage: { + set: jest.fn((k: string, v: string) => { + mockMmkv.set(k, v) + }), + getString: jest.fn((k: string) => mockMmkv.get(k)), + remove: jest.fn((k: string) => mockMmkv.delete(k)), + getAllKeys: jest.fn(() => Array.from(mockMmkv.keys())), + }, +})) + +beforeEach(() => { + mockMmkv.clear() + jest.clearAllMocks() +}) + +describe('granted permissions cache', () => { + it('round-trips a grant for the same user', () => { + saveGrantedPermissions('u1', ['highlights', 'bibles']) + expect(loadCachedGrantedPermissions('u1')).toEqual(['highlights', 'bibles']) + expect(mmkvStorage.set).toHaveBeenCalledWith( + MMKV_AUTH_KEYS.grantedPermissions, + JSON.stringify({ userId: 'u1', permissions: ['highlights', 'bibles'] }), + ) + }) + + it('round-trips an empty (denied) grant as [] rather than a miss', () => { + saveGrantedPermissions('u1', []) + expect(loadCachedGrantedPermissions('u1')).toEqual([]) + }) + + it('refuses to persist a grant for an unidentifiable user', () => { + saveGrantedPermissions(null, ['highlights']) + expect(mmkvStorage.set).not.toHaveBeenCalled() + expect(mockMmkv.has(MMKV_AUTH_KEYS.grantedPermissions)).toBe(false) + }) + + it('reads a miss for a null user id rather than matching another unidentified user', () => { + // A legacy entry from a build that did cache under a null id must not read + // back as a hit for whichever unidentifiable user signs in next. + mockMmkv.set( + MMKV_AUTH_KEYS.grantedPermissions, + JSON.stringify({ userId: null, permissions: ['highlights'] }), + ) + expect(loadCachedGrantedPermissions(null)).toBeNull() + expect(loadCachedGrantedPermissions('u1')).toBeNull() + }) + + it('reads a miss when nothing is cached', () => { + expect(loadCachedGrantedPermissions('u1')).toBeNull() + }) + + it('reads a miss when the cached grant belongs to a different user', () => { + saveGrantedPermissions('u1', ['highlights']) + expect(loadCachedGrantedPermissions('u2')).toBeNull() + expect(loadCachedGrantedPermissions(null)).toBeNull() + }) + + it('reads a miss for corrupt JSON without throwing', () => { + mockMmkv.set(MMKV_AUTH_KEYS.grantedPermissions, '{not json') + expect(() => loadCachedGrantedPermissions('u1')).not.toThrow() + expect(loadCachedGrantedPermissions('u1')).toBeNull() + }) + + it('reads a miss for a schema mismatch (wrong-typed payload)', () => { + mockMmkv.set( + MMKV_AUTH_KEYS.grantedPermissions, + JSON.stringify({ userId: 'u1', permissions: 'highlights' }), + ) + expect(loadCachedGrantedPermissions('u1')).toBeNull() + + mockMmkv.set(MMKV_AUTH_KEYS.grantedPermissions, JSON.stringify(null)) + expect(loadCachedGrantedPermissions('u1')).toBeNull() + }) + + it('reads a miss when the underlying storage read throws', () => { + ;(mmkvStorage.getString as jest.Mock).mockImplementationOnce(() => { + throw new Error('storage offline') + }) + expect(loadCachedGrantedPermissions('u1')).toBeNull() + }) + + it('clear removes only the granted-permissions key', () => { + saveGrantedPermissions('u1', ['highlights']) + mockMmkv.set(MMKV_AUTH_KEYS.cachedUserInfo, JSON.stringify({ id: 'u1' })) + + clearGrantedPermissions() + + expect(mockMmkv.has(MMKV_AUTH_KEYS.grantedPermissions)).toBe(false) + expect(mockMmkv.has(MMKV_AUTH_KEYS.cachedUserInfo)).toBe(true) + }) +}) diff --git a/packages/core/src/auth/__tests__/granted-permissions.test.ts b/packages/core/src/auth/__tests__/granted-permissions.test.ts new file mode 100644 index 00000000..6124d091 --- /dev/null +++ b/packages/core/src/auth/__tests__/granted-permissions.test.ts @@ -0,0 +1,115 @@ +import { parseGrantedPermissions } from '@youversion/platform-core' + +import { readGrantedPermissions } from '../granted-permissions' + +describe('readGrantedPermissions — presence detection', () => { + it('returns null when the redirect carries no granted_permissions key at all', () => { + const params = new URLSearchParams('state=STATE&code=AUTHCODE') + expect(readGrantedPermissions(params)).toBeNull() + }) + + it('returns null for a lookalike key that is not a granted_permissions spelling', () => { + const params = new URLSearchParams( + 'requested_permissions[]=highlights&granted_perms=highlights', + ) + expect(readGrantedPermissions(params)).toBeNull() + }) + + it('reads the bare granted_permissions spelling', () => { + const params = new URLSearchParams('state=STATE&granted_permissions=highlights') + expect(readGrantedPermissions(params)).toEqual(['highlights']) + }) + + it('reads the repeated granted_permissions[] spelling', () => { + const params = new URLSearchParams( + 'granted_permissions[]=highlights&granted_permissions[]=bibles', + ) + expect(readGrantedPermissions(params)).toEqual(['highlights', 'bibles']) + }) + + it('reads the indexed granted_permissions[n] spelling', () => { + const params = new URLSearchParams( + 'granted_permissions[0]=highlights&granted_permissions[1]=votd', + ) + expect(readGrantedPermissions(params)).toEqual(['highlights', 'votd']) + }) +}) + +describe('readGrantedPermissions — three-state semantics', () => { + it('distinguishes an empty value ("requested and denied") from an absent key ("unknown")', () => { + expect(readGrantedPermissions(new URLSearchParams('granted_permissions[]='))).toEqual([]) + expect(readGrantedPermissions(new URLSearchParams('granted_permissions='))).toEqual([]) + expect(readGrantedPermissions(new URLSearchParams(''))).toBeNull() + }) + + it('keeps a permission value outside the AuthPermission union instead of filtering it', () => { + // Filtering would turn a server-side addition into a silent denial. + const params = new URLSearchParams( + 'granted_permissions[]=highlights&granted_permissions[]=brand_new_permission', + ) + expect(readGrantedPermissions(params)).toEqual(['highlights', 'brand_new_permission']) + }) +}) + +/** + * Why this test exists. + * + * `readGrantedPermissions` detects key presence with its own + * `GRANTED_PERMISSIONS_KEY_PATTERN`, which duplicates a regex living inside + * platform-core's `parseGrantedPermissions`. platform-core exports neither the + * pattern nor a presence-detecting helper, so the duplication cannot be + * deleted. If platform-core later accepts a key spelling our copy does not, + * a real grant reads as `null` ("unknown") instead of "granted", and the app + * re-prompts for permission the user already gave. + * + * These cases compare the two sides through the public API — never the regex + * literal against itself — so the suite fails on upstream drift in either + * direction. + */ +describe('readGrantedPermissions — key-spelling parity with platform-core', () => { + // The probe value is non-empty, so `parseGrantedPermissions` returns a + // non-empty array exactly when upstream recognises the key spelling. + const PROBE_VALUE = 'x' + + const ON_MISMATCH = + 'platform-core changed which granted_permissions key spellings it accepts. ' + + 'Update GRANTED_PERMISSIONS_KEY_PATTERN in ' + + 'packages/core/src/auth/granted-permissions.ts to match, and add the new ' + + 'spelling to KEY_SPELLINGS below.' + + const KEY_SPELLINGS = [ + // Expected to be recognised by both sides today. + 'granted_permissions', + 'granted_permissions[]', + 'granted_permissions[0]', + 'granted_permissions[12]', + 'granted_permissions[00]', + // Expected to be recognised by neither side today. + 'granted_permissionsx', + 'granted_permissions[a]', + 'granted_permissions[-1]', + 'granted_permissions[0][0]', + 'granted_permissions[]extra', + 'xgranted_permissions', + 'granted_permission', + 'GRANTED_PERMISSIONS', + ' granted_permissions', + 'granted_permissions ', + ] + + it.each(KEY_SPELLINGS)( + 'our presence detection agrees with platform-core on "%s"', + (key: string) => { + const buildParams = () => new URLSearchParams([[key, PROBE_VALUE]]) + + const upstreamRecognises = parseGrantedPermissions(buildParams()).length > 0 + const weRecognise = readGrantedPermissions(buildParams()) !== null + + expect({ key, weRecognise, onMismatch: ON_MISMATCH }).toEqual({ + key, + weRecognise: upstreamRecognises, + onMismatch: ON_MISMATCH, + }) + }, + ) +}) diff --git a/packages/core/src/auth/__tests__/pkce-flow.test.ts b/packages/core/src/auth/__tests__/pkce-flow.test.ts index 1c22d562..ebeeecbf 100644 --- a/packages/core/src/auth/__tests__/pkce-flow.test.ts +++ b/packages/core/src/auth/__tests__/pkce-flow.test.ts @@ -55,12 +55,15 @@ function defaultProps(overrides: Partial[0]> = } } -function arrangeHappyPath() { +function arrangeHappyPath(redirectQuery = 'state=STATE') { mockGeneratePkce.mockResolvedValue(PKCE_FIXTURE) mockOpenAuthSession.mockResolvedValue({ type: 'success', - url: 'https://app/cb?state=STATE', + url: `https://app/cb?${redirectQuery}`, }) + // The /auth/callback hop deliberately carries no granted_permissions: on device + // its Location header drops them, so a test that read the grant from here would + // pass while the real flow reported "unknown". mockExpoFetch.mockResolvedValue({ status: 302, headers: { get: jest.fn(() => 'https://app/cb?code=AUTHCODE') }, @@ -312,6 +315,7 @@ describe('signInWithPKCE — happy path', () => { token_type: 'Bearer', }), userInfo: { id: 'u1', name: 'Ada', email: undefined, avatarUrl: undefined }, + grantedPermissions: null, }) expect(mockExchange).toHaveBeenCalledWith({ apiHost: 'api.example.com', @@ -322,3 +326,41 @@ describe('signInWithPKCE — happy path', () => { }) }) }) + +describe('signInWithPKCE — granted permissions read-back', () => { + async function grantFrom(redirectQuery: string) { + arrangeHappyPath(redirectQuery) + const result = await signInWithPKCE(defaultProps()) + if (result.kind !== 'success') { + throw new Error('expected a successful sign-in') + } + return result.grantedPermissions + } + + it('reads the grant from the app redirect even though the /auth/callback hop drops it', async () => { + // arrangeHappyPath's Location header carries only `code` — no + // granted_permissions. If parsing moved after obtainCodeFromCallback, this + // would read null. + const granted = await grantFrom('state=STATE&granted_permissions[]=highlights') + + expect(granted).toEqual(['highlights']) + // Guard the premise: the callback hop really did omit the param. + const callbackResponse = await mockExpoFetch.mock.results[0]?.value + expect(callbackResponse.headers.get('Location')).not.toContain('granted_permissions') + }) + + it('reads multiple granted permissions', async () => { + const granted = await grantFrom( + 'state=STATE&granted_permissions[]=highlights&granted_permissions[]=bibles', + ) + expect(granted).toEqual(['highlights', 'bibles']) + }) + + it('reports null when the redirect carries no granted_permissions param', async () => { + expect(await grantFrom('state=STATE')).toBeNull() + }) + + it('reports [] — requested and denied — when the param is present but empty', async () => { + expect(await grantFrom('state=STATE&granted_permissions[]=')).toEqual([]) + }) +}) diff --git a/packages/core/src/auth/__tests__/use-yv-auth.test.tsx b/packages/core/src/auth/__tests__/use-yv-auth.test.tsx index 764e171b..7e7adb77 100644 --- a/packages/core/src/auth/__tests__/use-yv-auth.test.tsx +++ b/packages/core/src/auth/__tests__/use-yv-auth.test.tsx @@ -20,6 +20,9 @@ describe('useYVAuth', () => { signOut: jest.fn(), refreshNow: jest.fn(), isLoading: false, + grantedPermissions: null, + hasPermission: jest.fn(() => false), + invalidatePermissions: jest.fn(), } const wrapper = ({ children }: { children: ReactNode }) => ( {children} diff --git a/packages/core/src/auth/auth-context.tsx b/packages/core/src/auth/auth-context.tsx index 045cbfd7..dd340215 100644 --- a/packages/core/src/auth/auth-context.tsx +++ b/packages/core/src/auth/auth-context.tsx @@ -1,5 +1,5 @@ import { createContext } from 'react' -import type { YVUserInfo } from './types' +import type { AuthPermission, YVUserInfo } from './types' export type AuthContextValue = { isAuthenticated: boolean @@ -10,6 +10,23 @@ export type AuthContextValue = { signOut: () => Promise refreshNow: () => Promise isLoading: boolean + /** + * Three-state grant: `null` = unknown / never requested, `[]` = requested and + * denied, populated = granted. Unrecognized values are kept verbatim so a + * server-side addition never reads as a denial. + * + * Seeded synchronously from a per-user cache, so it is populated on the first + * render while `isLoading` is still `true` — gate real work on + * `isAuthenticated` / `isLoading` too. + */ + grantedPermissions: readonly string[] | null + /** + * Whether `permission` is in {@link grantedPermissions}; false when unknown. + * Advisory — the server remains the enforcement point. + */ + hasPermission: (permission: AuthPermission) => boolean + /** Drop a stale cached grant (e.g. after a 401/403 write) so the next pre-flight re-prompts. */ + invalidatePermissions: () => void } export const AuthContext = createContext(null) diff --git a/packages/core/src/auth/auth-provider.tsx b/packages/core/src/auth/auth-provider.tsx index 51537d93..c2f12a7e 100644 --- a/packages/core/src/auth/auth-provider.tsx +++ b/packages/core/src/auth/auth-provider.tsx @@ -5,11 +5,16 @@ import { clearHighlightsCache } from '../highlights' import { mmkvStorage } from '../storage/mmkv-storage' import { AuthContext, type AuthContextValue } from './auth-context' import { MMKV_AUTH_KEYS, REFRESH_LEEWAY_SECONDS } from './constants' +import { + clearGrantedPermissions, + loadCachedGrantedPermissions, + saveGrantedPermissions, +} from './granted-permissions-cache' import { refreshTokens, TokenEndpointError } from './http' import { sanitizeAvatarUrl } from './id-token' import { signInWithPKCE } from './pkce-flow' import { loadTokens, saveTokens, type StoredTokens } from './token-storage' -import type { AuthConfig, YVUserInfo } from './types' +import type { AuthConfig, AuthPermission, YVUserInfo } from './types' type AuthProviderProps = { config: AuthConfig @@ -21,6 +26,12 @@ type AuthProviderProps = { export default function AuthProvider({ config, appKey, apiHost, children }: AuthProviderProps) { const [accessToken, setAccessToken] = useState(null) const [userInfo, setUserInfo] = useState(() => loadCachedUserInfo()) + // Seeded synchronously so hasPermission answers correctly on the first render + // after a cold start — the same pattern (and load-bearing coupling) as the + // userInfo initializer above, which useHighlights also depends on. + const [grantedPermissions, setGrantedPermissions] = useState(() => + loadCachedGrantedPermissions(userInfo?.id ?? null), + ) const [error, setError] = useState(null) const [isLoading, setIsLoading] = useState(true) @@ -40,8 +51,16 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth } }, []) + // Always both halves: the cached entry and the in-memory state hasPermission + // actually reads. + const invalidatePermissions = useCallback(() => { + clearGrantedPermissions() + setGrantedPermissions(null) + }, []) + const clearAuthState = useCallback(async () => { mmkvStorage.remove(MMKV_AUTH_KEYS.cachedUserInfo) + invalidatePermissions() clearHighlightsCache() expiryRef.current = null refreshTokenRef.current = null @@ -49,7 +68,7 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth setUserInfo(null) setError(null) await saveTokens({ accessToken: null, refreshToken: null, expiryDate: null }) - }, []) + }, [invalidatePermissions]) const refreshToken = useCallback( async (options?: { force?: boolean }) => { @@ -164,6 +183,25 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth }, result.userInfo, ) + + const nextUserId = result.userInfo.id ?? null + if (result.grantedPermissions != null) { + saveGrantedPermissions(nextUserId, result.grantedPermissions) + setGrantedPermissions(result.grantedPermissions) + } else { + // The redirect said nothing about permissions (scopes-only sign-in). + // Re-read the cache scoped to whoever just signed in: the same user + // keeps a grant from an earlier sign-in, a different user reads null — + // no explicit user-switch handling needed. + // + // This branch is only safe because a *denial* is not silent: the + // gateway spec says a denial sends `granted_permissions=` (empty), so + // it lands in the `if` above as `[]`, not here. See + // {@link readGrantedPermissions}. If the server ever omitted the key on + // denial instead, a denial would reach this branch and restore the + // user's previous grant. + setGrantedPermissions(loadCachedGrantedPermissions(nextUserId)) + } } catch (e) { const err = e instanceof Error ? e : new Error(String(e)) setError(err) @@ -177,6 +215,11 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth const refreshNow = useCallback(() => refreshToken({ force: true }), [refreshToken]) + const hasPermission = useCallback( + (permission: AuthPermission) => grantedPermissions?.includes(permission) ?? false, + [grantedPermissions], + ) + const value: AuthContextValue = useMemo( () => ({ isAuthenticated: accessToken !== null, @@ -187,8 +230,22 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth signOut, refreshNow, isLoading, + grantedPermissions, + hasPermission, + invalidatePermissions, }), - [accessToken, userInfo, error, signIn, signOut, refreshNow, isLoading], + [ + accessToken, + userInfo, + error, + signIn, + signOut, + refreshNow, + isLoading, + grantedPermissions, + hasPermission, + invalidatePermissions, + ], ) return {children} diff --git a/packages/core/src/auth/constants.ts b/packages/core/src/auth/constants.ts index b0336220..d6fdb672 100644 --- a/packages/core/src/auth/constants.ts +++ b/packages/core/src/auth/constants.ts @@ -6,6 +6,7 @@ export const SECURE_STORAGE_KEYS = { export const MMKV_AUTH_KEYS = { expiryDateISO: 'yvp.expiryDate', cachedUserInfo: 'yvp.userInfo', + grantedPermissions: 'yvp.grantedPermissions', } as const export const DEFAULT_SCOPES = ['profile', 'email'] as const diff --git a/packages/core/src/auth/granted-permissions-cache.ts b/packages/core/src/auth/granted-permissions-cache.ts new file mode 100644 index 00000000..7df9d7c9 --- /dev/null +++ b/packages/core/src/auth/granted-permissions-cache.ts @@ -0,0 +1,74 @@ +import { z } from 'zod' +import { mmkvStorage } from '../storage/mmkv-storage' +import { MMKV_AUTH_KEYS } from './constants' + +// Single key, self-scoped by userId — there is only ever one signed-in user +// (mirrors cachedUserInfo). The stored userId makes a previous user's entry +// read as a miss instead of leaking across a re-sign-in. +const cachedGrantSchema = z.object({ + userId: z.string(), + permissions: z.array(z.string()), +}) + +/** + * Synchronous MMKV read of the cached grant for `userId`. Returns `null` on a + * miss, corrupt JSON, or a userId mismatch; never throws. Mirrors + * `loadCachedUserInfo` so the provider can seed state in a `useState` + * initializer and answer `hasPermission` on the first render. + * + * A `null` userId is always a miss: `YVUserInfo.id` is optional, and two + * unidentifiable users must not read each other's grant. + */ +export function loadCachedGrantedPermissions(userId: string | null): string[] | null { + if (userId === null) { + return null + } + try { + const raw = mmkvStorage.getString(MMKV_AUTH_KEYS.grantedPermissions) + if (raw == null) { + return null + } + const parsed = cachedGrantSchema.safeParse(JSON.parse(raw)) + if (!parsed.success || parsed.data.userId !== userId) { + return null + } + return parsed.data.permissions + } catch { + return null + } +} + +/** + * Persists a grant for `userId`. A `null` userId is refused — an entry no user + * can be identified by would match every unidentifiable user; the grant then + * lives only in provider state for the session. Never throws: the cache only + * seeds the first render, so a storage failure must not fail a sign-in whose + * session already committed. + */ +export function saveGrantedPermissions( + userId: string | null, + permissions: readonly string[], +): void { + if (userId === null) { + return + } + try { + mmkvStorage.set(MMKV_AUTH_KEYS.grantedPermissions, JSON.stringify({ userId, permissions })) + } catch { + // Cache write failed; the in-memory grant remains authoritative. + } +} + +/** + * Removes the cached grant. Best-effort by design: the cached grant is a hint, + * the server is the enforcement point, and a survived entry costs at most a + * skipped prompt and a request the server denies. See + * `docs/adr/0014-cached-grant-is-a-hint.md` before hardening this. + */ +export function clearGrantedPermissions(): void { + try { + mmkvStorage.remove(MMKV_AUTH_KEYS.grantedPermissions) + } catch { + // Cache removal failed; callers still drop the in-memory grant. + } +} diff --git a/packages/core/src/auth/granted-permissions.ts b/packages/core/src/auth/granted-permissions.ts new file mode 100644 index 00000000..81b653e0 --- /dev/null +++ b/packages/core/src/auth/granted-permissions.ts @@ -0,0 +1,40 @@ +import { parseGrantedPermissions } from '@youversion/platform-core' + +// Deliberately restates the key spellings platform-core accepts: it keeps the +// pattern inline in parseGrantedPermissions and exports neither it nor a +// presence-detecting helper, and we must detect presence ourselves because +// parseGrantedPermissions collapses "absent" and "empty" into []. +// A parity test in __tests__/granted-permissions.test.ts fails if the two drift. +const GRANTED_PERMISSIONS_KEY_PATTERN = /^granted_permissions(?:\[\d*\])?$/ + +/** + * Reads the permission grant off the OAuth app redirect, preserving the + * three-state signal `parseGrantedPermissions` alone cannot express: + * `null` = no key at all (nothing requested / unknown), `[]` = requested and + * denied, populated = granted. + * + * These three states are the gateway's documented contract, not an inference: + * the API gateway OpenAPI spec (`youversion-platform-developer-hub`, + * `devdocs/apis/openapi.yaml`, `info.description`) states that a granted + * callback carries a comma-separated list, that "when permissions were + * requested but none were granted, the callback includes `granted_permissions=` + * with an empty value", and that the key is omitted when none were requested. + * Not yet confirmed against a live denial — the YPE-3706 spike never ran. + * + * Values are not narrowed to known permissions — filtering an unrecognized + * value would silently turn "granted" into "denied" when the server adds a + * permission this SDK version predates. + */ +export function readGrantedPermissions(params: URLSearchParams): string[] | null { + let present = false + for (const key of params.keys()) { + if (GRANTED_PERMISSIONS_KEY_PATTERN.test(key)) { + present = true + break + } + } + if (!present) { + return null + } + return parseGrantedPermissions(params) +} diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index 246ff5e4..49ac2e9b 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -1,2 +1,8 @@ -export type { AuthConfig, AuthPermission, AuthScope, YVUserInfo } from './types' +export type { + AuthConfig, + AuthPermission, + AuthScope, + KnownAuthPermission, + YVUserInfo, +} from './types' export { useYVAuth, useYVAuthOptional } from './use-yv-auth' diff --git a/packages/core/src/auth/pkce-flow.ts b/packages/core/src/auth/pkce-flow.ts index 56616e37..808924bb 100644 --- a/packages/core/src/auth/pkce-flow.ts +++ b/packages/core/src/auth/pkce-flow.ts @@ -2,6 +2,7 @@ import * as WebBrowser from 'expo-web-browser' import { fetch } from 'expo/fetch' import { getOrSetInstallationId } from '../installation-id' import { DEFAULT_SCOPES } from './constants' +import { readGrantedPermissions } from './granted-permissions' import { exchangeCodeForTokens, type TokenResponse } from './http' import { decodeIdToken, deriveUserInfo } from './id-token' import { generatePKCEParameters } from './pkce' @@ -12,6 +13,12 @@ export type SignInResult = kind: 'success' tokens: TokenResponse userInfo: YVUserInfo + /** + * What the user granted, read off the app redirect. `null` = the redirect + * carried no `granted_permissions` key (nothing requested / unknown), + * `[]` = requested and denied. See {@link readGrantedPermissions}. + */ + grantedPermissions: string[] | null } | { kind: 'cancel' } @@ -81,6 +88,10 @@ export async function signInWithPKCE({ throw new Error('State mismatch - possible CSRF attack') } + // Read the grant off the *app redirect*, before the /auth/callback hop below: + // that hop's Location header drops `granted_permissions`. + const grantedPermissions = readGrantedPermissions(returnedParams) + const code = await obtainCodeFromCallback({ apiHost, callBackParams: returnedParams }) const tokens = await exchangeCodeForTokens({ @@ -98,7 +109,7 @@ export async function signInWithPKCE({ throw new Error('Nonce mismatch - possible id_token replay') } - return { kind: 'success', tokens, userInfo: deriveUserInfo(tokens.id_token) } + return { kind: 'success', tokens, userInfo: deriveUserInfo(tokens.id_token), grantedPermissions } } async function obtainCodeFromCallback({ diff --git a/packages/core/src/auth/types.ts b/packages/core/src/auth/types.ts index c0ad04e2..9cef268c 100644 --- a/packages/core/src/auth/types.ts +++ b/packages/core/src/auth/types.ts @@ -2,6 +2,14 @@ import { DEFAULT_SCOPES } from './constants' export type AuthScope = (typeof DEFAULT_SCOPES)[number] +/** The permissions this SDK version knows about. See {@link AuthPermission}. */ +export type KnownAuthPermission = + | 'bibles' + | 'highlights' + | 'votd' + | 'demographics' + | 'bible_activity' + /** * A YouVersion Platform permission. * @@ -9,8 +17,13 @@ export type AuthScope = (typeof DEFAULT_SCOPES)[number] * `requested_permissions[]` query param, separate from `scope` — the auth server * silently drops unknown values from `scope`, so passing a permission there grants * nothing. + * + * Deliberately an open union: the server can mint permissions this SDK version + * does not know about, and `granted_permissions` echoes them back verbatim. + * `(string & {})` keeps {@link KnownAuthPermission} in autocomplete while + * accepting any string. */ -export type AuthPermission = 'bibles' | 'highlights' | 'votd' | 'demographics' | 'bible_activity' +export type AuthPermission = KnownAuthPermission | (string & {}) export type AuthConfig = { redirectUri: string @@ -18,7 +31,8 @@ export type AuthConfig = { /** * {@link AuthPermission}s to request at sign-in. Requesting one is not the same * as being granted it — the user can deny on the consent screen and sign-in - * still succeeds; reading back what was granted is not yet supported. + * still succeeds. Read the actual grant back via `grantedPermissions` / + * `hasPermission` on `useYVAuth()`. */ permissions?: readonly AuthPermission[] } diff --git a/packages/core/src/highlights/__tests__/use-highlights.test.tsx b/packages/core/src/highlights/__tests__/use-highlights.test.tsx index d0c7f8a3..65c1c0fe 100644 --- a/packages/core/src/highlights/__tests__/use-highlights.test.tsx +++ b/packages/core/src/highlights/__tests__/use-highlights.test.tsx @@ -117,6 +117,9 @@ function authValue(overrides: Partial): AuthContextValue { signOut: jest.fn(async () => undefined), refreshNow, isLoading: false, + grantedPermissions: null, + hasPermission: jest.fn(() => false), + invalidatePermissions: jest.fn(), ...overrides, } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 818c2094..13a767b0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,7 +3,7 @@ export type { YouVersionContextValue } from './youversion-context' export { default as YouVersionProvider } from './youversion-provider' export { useYVAuth, useYVAuthOptional } from './auth' -export type { AuthConfig, AuthPermission, AuthScope, YVUserInfo } from './auth' +export type { AuthConfig, AuthPermission, AuthScope, KnownAuthPermission, YVUserInfo } from './auth' export { deriveServerColors, HIGHLIGHT_COLORS, isHighlightColor, useHighlights } from './highlights' export type { From cc3c1b06fc26e609001b21d97ba0e2e69740ecac Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Tue, 4 Aug 2026 18:34:15 -0500 Subject: [PATCH 10/43] feat(core): just-in-time data-exchange permission grant (YPE-3709) (2/3) (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): just-in-time data-exchange permission grant (YPE-3709) (2/3) Restacked onto the lean subtask-1 redo (PR #112). Content is PR #106's, with three adaptations: grant-cache imports follow the module split (granted-permissions-cache.ts), mergeGrantedPermissions lands in the cache module with its tests, and the id-less-user test now pins the redo's semantic — the grant is honored in memory but never persisted under a null userId. The data-exchange ADR is renumbered 0014 → 0015 (0014 is the cached-grant-is-a-hint ADR from subtask 1). Co-Authored-By: Claude Fable 5 * refactor(auth): replace epoch with sessionId in identity management * docs(auth): enhance initiator guard and clarify identity management * refactor(auth): review fixes for the data-exchange grant Five fixes from review of PR #113. No change to the grant flow's shape. - Split "another request is already running" out of `transient` into its own `in-progress` reason. `transient` is the reason callers retry on immediately, and an immediate retry lands back in the same branch while the consent page is still open. `in-progress` says the actionable thing. - Refresh the access token before minting. Refresh was otherwise driven only by bootstrap and the AppState `active` handler, so a long foreground session could carry an expired token into the mint. That 401s, and every mint 401 reads as `not-permitted` — telling the user their app key is misconfigured when the token was merely stale. The token is read from a new ref, falling back to the render closure so a session cleared mid-flow still reaches the initiator guard and reports `user-changed` rather than changing contract. - Correct AGENTS.md and README.md on concurrency. Both claimed overlapping callers get the same outcome; a differing permission set never did, and a test already pinned the real behaviour. - Fix stale docs: the `getCurrentUserId` JSDoc (renamed to `getCurrentIdentity`) and CONTRIBUTING.md's `Linking.createURL` example, which no longer matches the example app's explicit `{ scheme }` form. - Extract `toMessage(caught)` to `src/error-message.ts`, replacing three copies of the `instanceof Error` ternary. typecheck, lint, and prettier pass; core is 306 tests across 20 suites. Co-Authored-By: Claude Opus 5 * fix(auth): data exchange returns to the app's redirectUri An approved permission grant was silently discarded on Android. Verified on device (Pixel 6 Pro API 34, real app key): the user consents, the server confirms the grant, and `requestPermissions` resolves `cancel` with nothing written to the cache. `requestDataExchange` passed a hardcoded `youversionauth://callback` to `openAuthSessionAsync`, on the premise that the return scheme is SDK-owned and unrelated to the app's OAuth `redirectUri`. The hosted consent page in fact returns to whatever callback URL is registered for the app key, so the auth session waited on a URL that never arrived, reported `dismiss`, and the grant was lost. Every outcome — granted, denied, and error alike — collapsed to `cancel`. An app key has exactly one callback URL (confirmed with the API team), and sign-in already owns it, so a separate SDK-owned return URL cannot exist alongside it: registering one instead breaks sign-in with `invalid_request: redirect_uri does not match registered callback URL`. `requestDataExchange` now takes `redirectUri` and the provider passes `config.redirectUri`, so both browser round-trips share one URL — which is what Swift (`Users+SignIn.swift`, `DataExchangeSession.swift`) and Kotlin (`DEFAULT_AUTH_CALLBACK`) already do. `DATA_EXCHANGE_RETURN_URL` is deleted. The example app and docs use `youversionauth://callback` as that single value, matching the native SDKs, with `"scheme": "youversionauth"` in `app.json`. The `scheme` array, the "register youversionauth in addition to your own scheme" instruction, and the `Linking.createURL` ordering hazard all go away with the second scheme. ADR 0015 is rewritten rather than deleted — it records the measurements, the one-callback-URL constraint, and why the original parity claim was wrong. Verified end to end after the change: sign-in and the grant both return through `youversionauth://callback`, and `hasPermission('highlights')` flips to true. typecheck, lint, and prettier pass; core is 306 tests across 20 suites. Co-Authored-By: Claude Opus 5 * docs: align CONTEXT.md and wording with the redirectUri model `d3df9d1` changed where data exchange returns but left the ubiquitous language behind. CONTEXT.md still described the return URL as "the hardcoded, SDK-owned `youversionauth://callback`", and its _Avoid_ line told readers not to call it a redirect URI "because the app's OAuth `redirectUri` is a different, app-owned thing" — which is now exactly backwards. They are the same URL. Also drops "SDK-owned" from the README and the example app's comment. The value is a convention shared with Swift and Kotlin, but calling it SDK-owned is what led to treating it as separate from `redirectUri` in the first place. Audited the rest of docs/adr: no other ADR references the return URL, the redirect, or data exchange, so nothing else went stale. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Fable 5 Co-authored-by: Cameron Pak --- .changeset/core-data-exchange-grant.md | 5 + AGENTS.md | 13 +- CONTEXT.md | 5 + CONTRIBUTING.md | 4 +- README.md | 50 ++- apps/example/app.json | 2 +- apps/example/app/_layout.tsx | 14 +- docs/adr/0015-data-exchange-return-scheme.md | 52 +++ packages/core/README.md | 22 + .../src/auth/__tests__/auth-provider.test.tsx | 413 +++++++++++++++++- .../auth/__tests__/data-exchange-api.test.ts | 139 ++++++ .../src/auth/__tests__/data-exchange.test.ts | 290 ++++++++++++ .../granted-permissions-cache.test.ts | 31 ++ .../src/auth/__tests__/use-yv-auth.test.tsx | 1 + packages/core/src/auth/auth-context.tsx | 9 + packages/core/src/auth/auth-provider.tsx | 203 ++++++++- packages/core/src/auth/data-exchange-api.ts | 81 ++++ packages/core/src/auth/data-exchange.ts | 240 ++++++++++ .../src/auth/granted-permissions-cache.ts | 17 + packages/core/src/auth/index.ts | 1 + packages/core/src/error-message.ts | 11 + .../__tests__/use-highlights.test.tsx | 1 + packages/core/src/index.ts | 10 +- 23 files changed, 1593 insertions(+), 21 deletions(-) create mode 100644 .changeset/core-data-exchange-grant.md create mode 100644 docs/adr/0015-data-exchange-return-scheme.md create mode 100644 packages/core/src/auth/__tests__/data-exchange-api.test.ts create mode 100644 packages/core/src/auth/__tests__/data-exchange.test.ts create mode 100644 packages/core/src/auth/data-exchange-api.ts create mode 100644 packages/core/src/auth/data-exchange.ts create mode 100644 packages/core/src/error-message.ts diff --git a/.changeset/core-data-exchange-grant.md b/.changeset/core-data-exchange-grant.md new file mode 100644 index 00000000..aeb8e461 --- /dev/null +++ b/.changeset/core-data-exchange-grant.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-native-expo-core': patch +--- + +A signed-in user can now grant a YouVersion Platform permission on the spot, instead of signing out and back in to be asked again. `useYVAuth()` gains `requestPermissions(permissions)`, which mints a data-exchange token, runs YouVersion's hosted consent page in an auth session, and merges what the user granted into the cached grant — so `hasPermission` answers true on the next render. It resolves to a typed `DataExchangeOutcome` rather than throwing: `granted` (with the permissions the server actually reported, which may be fewer than were asked for), `cancel`, or `failure` carrying a `reason` of `not-signed-in`, `not-permitted` (this app key is not enabled for data exchange — a 401 from the mint, deliberately distinct from a flaky network), `user-changed`, `in-progress` (another request already holds the flow — wait for it to settle rather than retrying straight away), or `transient`. The access token is refreshed before minting, so an expired token cannot masquerade as a misconfigured app key. 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 any 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. The consent page returns to your `redirectUri` — the same callback URL sign-in uses, because an app key has exactly one — so data exchange needs no setup beyond what sign-in already required. If the two disagree the return never reaches the SDK and the outcome is `cancel`, indistinguishable from a decline. diff --git a/AGENTS.md b/AGENTS.md index 8ced0cce..2540149a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,7 +148,7 @@ Keep `apps/example/metro.config.js` minimal — just `getDefaultConfig(__dirname **UI** (`@youversion/platform-react-native-expo-ui`): `YouVersionProvider`, `BibleCard`, `BibleChapterPickerSheet`, `BibleReader`, `BibleReaderSettingsSheet`, `BibleTextView`, `BibleVersionPickerSheet`, `VerseOfTheDay`, and `YouVersionAuthButton` -**Core** (`@youversion/platform-react-native-expo-core`): `YouVersionProvider` (installation id + optional auth), `useYouVersion`, `useYVAuth` (its value carries `grantedPermissions` / `hasPermission` / `invalidatePermissions` alongside the sign-in surface), `useHighlights`, `deriveServerColors`, `HIGHLIGHT_COLORS` / `isHighlightColor`, `mmkvStorage`, auth types (`AuthConfig`, `AuthPermission`, `KnownAuthPermission`, `AuthScope`, `YVUserInfo`), and highlights types (`Highlight`, `HighlightScope`, `ServerColors`, `HighlightWriteOutcome`, `HighlightsFetchError`, `UseHighlightsOptions`, `UseHighlightsResult`) +**Core** (`@youversion/platform-react-native-expo-core`): `YouVersionProvider` (installation id + optional auth), `useYouVersion`, `useYVAuth` (its value carries `grantedPermissions` / `hasPermission` / `invalidatePermissions` / `requestPermissions` alongside the sign-in surface), `useHighlights`, `deriveServerColors`, `HIGHLIGHT_COLORS` / `isHighlightColor`, `mmkvStorage`, auth types (`AuthConfig`, `AuthPermission`, `KnownAuthPermission`, `AuthScope`, `DataExchangeOutcome`, `DataExchangeFailureReason`, `YVUserInfo`), and highlights types (`Highlight`, `HighlightScope`, `ServerColors`, `HighlightWriteOutcome`, `HighlightsFetchError`, `UseHighlightsOptions`, `UseHighlightsResult`) UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider`. Import Bible components from UI; import `useYVAuth` from core. @@ -158,6 +158,17 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider - On RN, `permissions` is configured on `YouVersionProvider`'s `auth` config (not on `YouVersionAuthButton` / `signIn()`), unlike web. The example app stays scopes-only until the permission flow lands (C3). - **Requesting a permission is not being granted it.** `useYVAuth()` reads the grant back: `hasPermission(permission)` for a single check, `grantedPermissions` for the list, and `invalidatePermissions()` to drop a stale grant after a 401/403 so the next pre-flight re-prompts. Three states, and collapsing them loses "the user said no": `null` = nothing requested / unknown, `[]` = requested and denied, populated = granted. - The grant rides only on the **app redirect** — the `/auth/callback` `Location` hop drops it — so `pkce-flow.ts` parses it from `result.url` before that hop, and a test in `__tests__/pkce-flow.test.ts` pins the ordering. It is then cached per user in MMKV (redirect parsing in `auth/granted-permissions.ts`, the cache in `auth/granted-permissions-cache.ts`), seeded synchronously in a `useState` initializer so it is correct on the first render, and purged in `clearAuthState`. `AuthPermission` is an open union and cached values are kept verbatim, not filtered — filtering would turn a server-side addition into a silent denial. +- `useYVAuth().requestPermissions(permissions)` is the **just-in-time grant** (data exchange): a signed-in user grants a permission on the spot, no sign-out. Mint (`POST /data-exchange/token`, 201) → hosted consent in an auth session → parse the return → merge into the grant cache. Resolves to a `DataExchangeOutcome` (`granted` / `cancel` / `failure` with `reason: 'not-signed-in' | 'not-permitted' | 'user-changed' | 'in-progress' | 'transient'`) and never throws. Permission-generic — nothing highlights-specific lives in `auth/data-exchange.ts`. + - The grant **merges**, never replaces: a `highlights`-only consent must not erase a previously granted `votd`. `cancel` and `failure` never touch the cache. + - An **initiator guard** fails closed: an `AuthIdentity` (`{ sessionId, userId }`) is captured before minting and re-read after the browser returns; any difference discards the grant (`reason: 'user-changed'`). `sessionId` is a local counter compared only for equality, not a server-issued value; it moves only in `setIdentity` (sign-in and sign-out), so a token-only `setAuthState` leaves it alone and a mid-flow refresh passes. `userId` alone cannot carry the guard because `null` means both "signed out" and "signed in with no `sub`". A same-session id-less user passes deliberately — failing closed there locks those users out of the flow entirely. + - **The guard is a backstop, not a defence against user action** — worth knowing before you either delete it as dead weight or trust it as a security boundary. Neither platform lets the user reach the app while the consent page is up (iOS is a modal sheet; on Android foregrounding resolves the auth session as `dismiss` first, ending the flow). The paths that _can_ land mid-flow are not user-driven — a revoked token tripping `clearAuthState`, or app code calling `signOut` from async work — and all of them end signed out, where `saveGrantedPermissions` already refuses the null `userId`. What the guard actually buys: a truthful **outcome** (never `granted` for a user who has left, which is what consumers branch on) and a `requestDataExchange` that is correct on its own terms instead of depending on a null check in `granted-permissions-cache.ts` that nothing links to it. + - `status: 'granted'` reports what the server granted, which may not be everything asked for. Check the returned list (or `hasPermission`) for the permission you needed. + - **Never throws is load-bearing and easy to break.** Every doc for this flow tells consumers not to `try`/`catch`, so each `await` that can reject needs a guard returning a `transient` failure: the mint (in `data-exchange-api.ts`), `WebBrowser.openAuthSessionAsync` (which rejects on a session already open, a missing native module, or no Android activity for the intent), and `getOrSetInstallationId()` in the provider. Tests pin all three. + - **One flow at a time, and what happens to the loser depends on what it asked for.** `requestPermissions` holds a single in-flight promise in a ref, keyed by the requested permission set, so a double-tap does not mint a second token, open a second auth session, or race the first to write the grant cache. An overlapping call for the **same** set (order-insensitive) shares that promise and gets the same outcome. An overlapping call for a **different** set cannot — the open consent page never mentions its permissions, so handing it that outcome would report `granted` for something the user was never shown. It gets `{ status: 'failure', reason: 'in-progress' }` instead. The lock releases as the promise settles. + - **`in-progress` is deliberately not `transient`.** `transient` is the reason callers retry on immediately, and an immediate retry lands back in the same branch while the consent page is still open — a spin, not a recovery. `in-progress` says the only actionable thing: wait for the running flow. Tests pin both branches. + - **The return URL is the app's `redirectUri`, not an SDK-owned constant** ([ADR 0015](docs/adr/0015-data-exchange-return-scheme.md)). An app key has exactly one registered callback URL and sign-in already owns it, so data exchange reuses it. Verified on device: with the app's URI registered the server returns `?data_exchange_status=granted&granted_permissions=...`; register a different URI and sign-in fails with `invalid_request: redirect_uri does not match registered callback URL`. + - **A `redirectUri` that disagrees with the registered callback URL fails silently.** The consent page opens, the user consents, and the return never matches, so `openAuthSessionAsync` reports `dismiss` and the outcome is `cancel` — identical to a decline, with the grant discarded. This is the first thing to check when grants "don't stick". + - The example app and docs use `youversionauth://callback`, matching Swift (`Users+SignIn.swift`, `DataExchangeSession.swift`) and Kotlin (`DEFAULT_AUTH_CALLBACK`). Android must register the `youversionauth` scheme in `app.json` to route it; that scheme is shared across every app integrating the SDK, which is the accepted tradeoff on all three platforms. - **The cached grant is a hint, not an authority** ([ADR 0014](docs/adr/0014-cached-grant-is-a-hint.md)). `hasPermission` chooses UI and skips redundant prompts; the server enforces. Under MMKV failure a revoked grant can survive — clearing is best-effort by design, because it must never break sign-out — so a privileged action gates on the pre-flight, never on a cached `true`. Read the ADR before "fixing" `clearGrantedPermissions`. - `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. diff --git a/CONTEXT.md b/CONTEXT.md index a1a3c514..d5120bad 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -122,6 +122,10 @@ _Avoid_: Branching on `message` (generic outside development builds); routing wr What the user actually granted at sign-in, read off the OAuth **app redirect** and cached per user. A three-state signal, not a list: `null` = no `granted_permissions` key at all, so nothing was requested and nothing is known; `[]` = requested and **denied**; populated = granted. Requesting a permission (`AuthConfig.permissions`) is a separate thing from being granted it. Values the SDK does not recognize are kept verbatim rather than narrowed to the known permission union. _Avoid_: Scopes (permissions travel as `requested_permissions[]`, never in `scope`); collapsing `[]` into `null` (it erases "the user said no"); "requested permissions" when you mean the grant +**Data Exchange**: +YouVersion's just-in-time permission grant: a signed-in user grants a permission on the spot through a hosted consent page, without signing out. Mint a short-lived token, run the consent page in an auth session, parse the return, and **merge** the result into **Granted Permissions**. Resolves to a granted / cancel / failure outcome and never throws. The consent page returns to the app's `redirectUri` — the same callback URL sign-in uses, because an app key has exactly one — see [ADR 0015](docs/adr/0015-data-exchange-return-scheme.md). +_Avoid_: Treating the return URL as a separate, SDK-owned thing from the app's OAuth `redirectUri` (one app key, one callback URL, both flows share it); replacing the cached grant with what one consent reported; "re-authenticating" (the user never signs out) + ## Relationships - A **React Web SDK Component** may expose reusable content that can be rendered by an **Expo DOM Component**. @@ -150,6 +154,7 @@ _Avoid_: Scopes (permissions travel as `requested_permissions[]`, never in `scop - A **Highlight Write Outcome** is the sole report of a write's fate, and is where C3's sign-in branch reads from. - **Granted Permissions** are read only from the app redirect, never from the `/auth/callback` hop, which drops them. They are **Native-Owned State** cached per user in MMKV and purged with the rest of auth state on sign-out; a stale grant can be invalidated so the next pre-flight re-prompts. - A permission pre-flight reads **Granted Permissions**; a **Highlight Write Outcome** of `reason: 'auth'` is the corrective path when that cache is wrong, not the primary signal. +- **Data Exchange** is the other way to obtain **Granted Permissions** — the one that does not require a new sign-in. It writes into the same per-user cache, merging rather than replacing, and only ever on a granted return. ## Example Dialogue diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 552f4334..fe23ea2f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,9 @@ pnpm install Set `EXPO_PUBLIC_YOUVERSION_APP_KEY` in your environment or an `.env` file before starting the example app. -For auth flows, register the example redirect URI (`Linking.createURL('callback')` for scheme `yvp-rn-example`) in the YouVersion Platform console. The sample app wires this in `apps/example/app/_layout.tsx` and handles the redirect in `apps/example/app/callback.tsx`. +For auth flows, register `youversionauth://callback` as the callback URI for your app key in the YouVersion Platform console. The sample app declares it in `apps/example/app/_layout.tsx` and handles the redirect in `apps/example/app/callback.tsx`; `app.json` carries the matching `"scheme": "youversionauth"` so Android can route it. + +An app key has exactly one callback URI, and both browser round-trips — sign-in and the data-exchange permission grant — come back through it. If the value in `_layout.tsx` and the console entry disagree, sign-in fails with `invalid_request` and permission grants silently report `cancel`. Build the dev client the first time: diff --git a/README.md b/README.md index 19f513ac..2f909540 100644 --- a/README.md +++ b/README.md @@ -175,18 +175,26 @@ function VotdScreen() { Authentication is optional. Pass an `auth` config to `YouVersionProvider` to enable it. After the user signs in, the browser redirects back to your app at the `redirectUri` you configure below, so your app needs a route at that path to receive the redirect and finish sign-in. With Expo Router, that means a screen whose path matches the redirect (e.g. `app/callback.tsx`); the example app's implementation is a copyable reference: [`apps/example/app/callback.tsx`](./apps/example/app/callback.tsx). -The `redirectUri` is where the browser sends the user back after sign-in. `Linking.createURL('callback')` (from `expo-linking` — install it with `npx expo install expo-linking`) builds it from your app's URL scheme: in a dev build it produces `://callback`, where `` is the `scheme` in your `app.json`. The example app's scheme is `yvp-rn-example`, so its redirect URI is `yvp-rn-example://callback`. Register that exact URI as a Callback URI for your app key in the [YouVersion Platform](https://platform.youversion.com/) console. +The `redirectUri` is where the browser sends the user back after sign-in. Use `youversionauth://callback`, the callback URL the [Swift](https://github.com/youversion/platform-sdk-swift) and [Kotlin](https://github.com/youversion/platform-sdk-kotlin) SDKs use for the same purpose, and register that exact URI as the Callback URI for your app key in the [YouVersion Platform](https://platform.youversion.com/) console. -Choose a scheme unique to your app: on Android, multiple apps registering the same scheme triggers the system disambiguation dialog (an app chooser), and on iOS there is no defined process for which app gets the scheme — the OS silently picks one. +Two things have to line up, and they are the usual source of trouble: + +1. **`redirectUri` must equal the Callback URI registered for your app key.** An app key has exactly one. If they disagree, sign-in fails with `invalid_request: redirect_uri does not match registered callback URL`. +2. **Android must be able to route it.** Add the scheme to `app.json` and rebuild the dev client (`npx expo prebuild --clean` — this is a native change): + + ```json + { "expo": { "scheme": "youversionauth" } } + ``` + + iOS needs nothing extra. Because this scheme is shared by every app integrating the SDK, Android may show an app chooser if more than one is installed — the same tradeoff the Kotlin SDK's sample app makes. ```tsx import { YouVersionProvider } from '@youversion/platform-react-native-expo-ui' -import * as Linking from 'expo-linking' import { GestureHandlerRootView } from 'react-native-gesture-handler' export default function RootLayout() { const appKey = process.env.EXPO_PUBLIC_YOUVERSION_APP_KEY - const redirectUri = Linking.createURL('callback') + const redirectUri = 'youversionauth://callback' if (!appKey) return null @@ -203,7 +211,39 @@ export default function RootLayout() { } ``` -`permissions` lists YouVersion Platform permissions (`'bibles'`, `'highlights'`, `'votd'`, `'demographics'`, `'bible_activity'`) to ask for on the consent screen — these are not OIDC scopes, so keep them out of `scopes`. Today this only _requests_ the permission; whether it was granted is not exposed yet (coming in a follow-up). +`permissions` lists YouVersion Platform permissions (`'bibles'`, `'highlights'`, `'votd'`, `'demographics'`, `'bible_activity'`) to ask for on the consent screen — these are not OIDC scopes, so keep them out of `scopes`. + +Requesting a permission is not the same as being granted it: the user can decline and sign-in still succeeds. Read back what they actually granted from `useYVAuth()` — `hasPermission('highlights')` for one check, or `grantedPermissions` for the whole list (`null` when nothing was requested or nothing is known yet, `[]` when the user declined). The grant is cached per user and survives a cold start. + +#### Asking for a permission later + +A user who signed in before your app needed a permission — or who declined at the time — does not have to sign out to grant it. `requestPermissions` opens YouVersion's consent page and merges the result into the cached grant: + +```tsx +const { hasPermission, requestPermissions } = useYVAuth() + +async function ensureHighlights() { + if (hasPermission('highlights')) return true + + const outcome = await requestPermissions(['highlights']) + if (outcome.status === 'granted') return outcome.grantedPermissions.includes('highlights') + if (outcome.status === 'failure' && outcome.reason === 'not-permitted') { + // This app key is not enabled for the permission — a console setting, not a user choice. + } + return false +} +``` + +It resolves rather than throwing: `{ status: 'granted', grantedPermissions }`, `{ status: 'cancel' }`, or `{ status: 'failure', reason, message }` where `reason` is `'not-signed-in' | 'not-permitted' | 'user-changed' | 'in-progress' | 'transient'`. A granted permission makes `hasPermission` true on the next render. + +Only one request runs at a time. Calling it again for the **same** permissions while a consent page is open returns the in-flight request rather than opening a second one, so a double-tap on one button needs no guarding from you. + +A second call for **different** permissions cannot share that answer — the open consent page never mentioned them. It resolves to `{ status: 'failure', reason: 'in-progress' }`. Wait for the running request to settle before asking again; retrying straight away just hits the same branch. + +> [!IMPORTANT] +> **This flow returns to your `redirectUri`** — the same callback URL sign-in uses. An app key has exactly one registered callback URL, and both browser round-trips come back through it. Nothing extra to register for data exchange beyond what sign-in already needs. +> +> If your `redirectUri` disagrees with the callback URL registered for your app key, the consent page opens, the user consents, and the return never reaches the SDK — reported as `{ status: 'cancel' }`, indistinguishable from a decline. Verify the two match before assuming users are declining. For sign-in UI, drop in `YouVersionAuthButton` — it renders the branded Sign in with YouVersion button and handles sign-in/sign-out for you: diff --git a/apps/example/app.json b/apps/example/app.json index bd5b33af..5e863583 100644 --- a/apps/example/app.json +++ b/apps/example/app.json @@ -3,7 +3,7 @@ "name": "SampleApp", "slug": "example", "version": "1.0.0", - "scheme": "yvp-rn-example", + "scheme": "youversionauth", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", diff --git a/apps/example/app/_layout.tsx b/apps/example/app/_layout.tsx index 6ea275d9..ada45588 100644 --- a/apps/example/app/_layout.tsx +++ b/apps/example/app/_layout.tsx @@ -1,12 +1,22 @@ import { YouVersionProvider } from '@youversion/platform-react-native-expo-ui' -import * as Linking from 'expo-linking' import { Stack } from 'expo-router' import { GestureHandlerRootView } from 'react-native-gesture-handler' import MissingAppKey from './_components/missing-app-key' +/** + * The app's one callback URL, using the value the Swift and Kotlin SDKs use + * (`DEFAULT_AUTH_CALLBACK` in Kotlin's `YouVersionPlatformConfiguration`). + * + * An app key has exactly one registered callback URL, and both flows that come + * back through the browser — sign-in and the data-exchange permission grant — + * use it. Register this exact value in the YouVersion Platform console, and + * register the `youversionauth` scheme in `app.json` so Android can route it. + */ +const REDIRECT_URI = 'youversionauth://callback' + export default function RootLayout() { const appKey = process.env.EXPO_PUBLIC_YOUVERSION_APP_KEY - const redirectUri = Linking.createURL('callback') + const redirectUri = REDIRECT_URI return ( diff --git a/docs/adr/0015-data-exchange-return-scheme.md b/docs/adr/0015-data-exchange-return-scheme.md new file mode 100644 index 00000000..b252eb9d --- /dev/null +++ b/docs/adr/0015-data-exchange-return-scheme.md @@ -0,0 +1,52 @@ +# 15. Data exchange returns to the app's `redirectUri`, because an app key has one callback URL + +Date: 2026-07-29 +Revised: 2026-08-04 — the original decision was disproven on device; see Context. + +## Status + +Accepted (supersedes the original "SDK-owned return scheme" decision of the same number) + +## Context + +Data exchange (YPE-3709 subtask 2) is YouVersion's just-in-time permission grant: a signed-in user who has not granted a permission is sent to a hosted consent page and returns with the grant, without signing out. On native the flow is `WebBrowser.openAuthSessionAsync(consentUrl, returnUrl)`. + +This ADR originally set `returnUrl` to a hardcoded, SDK-owned `youversionauth://callback`, described as "emphatically **not** the app's OAuth `redirectUri`", and required Android consumers to add a second `scheme` entry to `app.json`. That was wrong, and the way it was wrong is worth recording. + +**What was measured** (Android emulator, Pixel 6 Pro API 34, real app key, 2026-08-04): + +| App key's registered callback URL | Consent page returned to | `requestPermissions` outcome | +| --------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------- | +| `yvp-rn-example://callback` | `yvp-rn-example://callback?data_exchange_status=granted&granted_permissions=highlights` | `cancel` | +| both registered | `yvp-rn-example://callback?...` | `cancel` | +| `youversionauth://callback` | `youversionauth://callback?...` | `granted` | + +The hosted page returns to **whatever callback URL is registered for the app key**, not to a fixed scheme. With the SDK watching a different URL, the return never matched, `openAuthSessionAsync` reported `dismiss`, and a real approved grant was discarded as `cancel` — indistinguishable from the user declining. + +**The constraint that settles it:** an app key has exactly one callback URL (confirmed with the API team). Sign-in already owns it. So a separate SDK-owned return URL cannot be registered alongside it, and registering one _instead_ breaks sign-in with `invalid_request: redirect_uri does not match registered callback URL` — also measured. + +The original "matches the Swift SDK's `callbackURLScheme`" claim was uncited. Swift does use that string, but for **both** flows off one URL, which is the part that was missed: + +```swift +// Users+SignIn.swift AND DataExchangeSession.swift +let redirectURL = URL(string: "youversionauth://callback")! +... callbackURLScheme: redirectURL.scheme! +``` + +Kotlin does the same (`DEFAULT_AUTH_CALLBACK = "youversionauth://callback"` in `YouVersionPlatformConfiguration`, used as `redirectUri` for sign-in, with `android:scheme="youversionauth"` in its sample app). + +## Decision + +`requestDataExchange` takes `redirectUri` and hands it to `openAuthSessionAsync`. The provider passes `config.redirectUri` — the same value sign-in uses. There is no SDK-owned return constant; `DATA_EXCHANGE_RETURN_URL` is deleted. + +The example app and docs use `youversionauth://callback` as that single `redirectUri`, matching Swift and Kotlin, with `"scheme": "youversionauth"` in `app.json` so Android can route it. + +`buildDataExchangeUrl(token, appKey, apiHost)` still takes no redirect parameter — the server reads the callback URL off the app key rather than off the request. That part of the original reasoning held. + +## Consequences + +- **Data exchange needs no consumer setup of its own.** Whatever sign-in already required is sufficient. The `app.json` `scheme` array, the "register `youversionauth` in addition to your own scheme" instruction, and the `Linking.createURL` ordering hazard all disappear with the second scheme. +- **`redirectUri` disagreeing with the registered callback URL fails silently**, and is now the single point where that can happen. The consent page opens, the user consents, and the outcome is `cancel` with the grant discarded. Consumers cannot distinguish it from a decline, so the docs name it as the first thing to check. This is the same silent-cancel cost the original ADR accepted, relocated to a place where sign-in fails loudly for the same misconfiguration — which makes it far easier to catch. +- **The scheme is shared across every app that integrates the SDK.** `youversionauth` is deliberately common, so two SDK-integrating apps on one Android device both register it and the OS shows an app chooser. Consumers who prefer their own scheme can use it for `redirectUri` instead — the SDK no longer cares which — at the cost of diverging from Swift and Kotlin. +- **Consumers who followed the previous instruction must remove the extra scheme entry** and rebuild. Leaving it registered is inert rather than harmful. +- **A config plugin is still unnecessary**, now for a stronger reason than before: there is no SDK-specific scheme to inject. diff --git a/packages/core/README.md b/packages/core/README.md index 8bd12722..95dedd29 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -52,6 +52,28 @@ export default function App() { } ``` +### Permissions + +`auth.permissions` asks for YouVersion Platform permissions (e.g. `'highlights'`) at sign-in; the user can decline. Read the grant back with `useYVAuth()`: `hasPermission(permission)`, or `grantedPermissions` for the list (`null` = nothing requested or nothing known yet, `[]` = declined). + +To ask an already signed-in user — without making them sign out — call `requestPermissions`: + +```tsx +const { hasPermission, requestPermissions } = useYVAuth() + +if (!hasPermission('highlights')) { + const outcome = await requestPermissions(['highlights']) + // { status: 'granted', grantedPermissions } | { status: 'cancel' } + // | { status: 'failure', reason: 'not-signed-in' | 'not-permitted' | 'user-changed' | 'in-progress' | 'transient', message } +} +``` + +A granted permission makes `hasPermission` true on the next render, and merges into the cached grant rather than replacing it. + +The flow returns to your `redirectUri` — the same callback URL sign-in uses, because an app key has exactly one. Nothing extra to register beyond sign-in's own setup. + +If `redirectUri` disagrees with the callback URL registered for your app key, the return never reaches the SDK and the outcome is `cancel`, indistinguishable from a decline. + ### Highlights `useHighlights` gives you a chapter's highlights, cached locally so they paint on the first frame, and write functions that apply optimistically and roll back on failure. diff --git a/packages/core/src/auth/__tests__/auth-provider.test.tsx b/packages/core/src/auth/__tests__/auth-provider.test.tsx index 9b23635c..073d06a8 100644 --- a/packages/core/src/auth/__tests__/auth-provider.test.tsx +++ b/packages/core/src/auth/__tests__/auth-provider.test.tsx @@ -1,12 +1,16 @@ import { act, fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { AppState, Pressable, Text, View } from 'react-native' +import { getOrSetInstallationId } from '../../installation-id' +import type { AuthContextValue } from '../auth-context' import AuthProvider from '../auth-provider' import { MMKV_AUTH_KEYS } from '../constants' +import { requestDataExchange } from '../data-exchange' +import { createDataExchangeApi } from '../data-exchange-api' import { refreshTokens, TokenEndpointError, type TokenResponse } from '../http' import { signInWithPKCE } from '../pkce-flow' import { loadTokens, saveTokens } from '../token-storage' -import type { AuthConfig } from '../types' +import type { AuthConfig, AuthPermission } from '../types' import { useYVAuth } from '../use-yv-auth' const mockMmkv = new Map() @@ -36,10 +40,28 @@ jest.mock('../pkce-flow', () => ({ signInWithPKCE: jest.fn(), })) +jest.mock('../../installation-id', () => ({ + getOrSetInstallationId: jest.fn(() => Promise.resolve('inst-1')), +})) + +// The grant flow itself is covered in data-exchange.test.ts; here we only care +// about what the provider hands it and what it does with the outcome — so no +// browser or API client is ever constructed. +jest.mock('../data-exchange', () => ({ + requestDataExchange: jest.fn(), +})) + +jest.mock('../data-exchange-api', () => ({ + createDataExchangeApi: jest.fn(() => ({ mintToken: jest.fn() })), +})) + const mockLoadTokens = loadTokens as jest.Mock const mockSaveTokens = saveTokens as jest.Mock const mockRefreshTokens = refreshTokens as jest.Mock const mockSignInWithPKCE = signInWithPKCE as jest.Mock +const mockRequestDataExchange = requestDataExchange as jest.Mock +const mockCreateDataExchangeApi = createDataExchangeApi as jest.Mock +const mockGetOrSetInstallationId = getOrSetInstallationId as jest.Mock const mockAppStateAddEventListener = jest.spyOn(AppState, 'addEventListener') const defaultConfig: AuthConfig = { redirectUri: 'https://app/cb' } @@ -70,9 +92,28 @@ const validTokens = { const adaUserInfo = { id: 'u1', name: 'Ada', email: undefined, avatarUrl: undefined } +/** + * Latest context value, so a test can drive `requestPermissions` with its own + * permission list and hold the promise — the rendered button is fixed to + * `['highlights']`, which cannot express two callers asking for different things. + */ +let latestAuth: AuthContextValue | null = null + +function requestPermissionsFromContext(permissions: readonly AuthPermission[]) { + if (latestAuth === null) { + throw new Error('AuthPeek has not rendered yet') + } + return latestAuth.requestPermissions(permissions) +} + function AuthPeek() { const auth = useYVAuth() const [signInOutcome, setSignInOutcome] = useState('idle') + const [permissionOutcome, setPermissionOutcome] = useState('idle') + + useEffect(() => { + latestAuth = auth + }) return ( @@ -86,6 +127,15 @@ function AuthPeek() { {String(auth.hasPermission('highlights'))} {signInOutcome} + {permissionOutcome} + { + setPermissionOutcome(JSON.stringify(await auth.requestPermissions(['highlights']))) + }} + > + requestPermissions + auth.invalidatePermissions()}> invalidatePermissions @@ -121,6 +171,7 @@ function fireAppStateChange(state: string) { beforeEach(() => { mockMmkv.clear() + latestAuth = null jest.clearAllMocks() mockAppStateAddEventListener.mockImplementation(() => ({ remove: jest.fn() })) }) @@ -651,3 +702,361 @@ describe('AuthProvider — granted permissions', () => { ]) }) }) + +describe('AuthProvider — requestPermissions', () => { + function renderProvider() { + return render( + + + , + ) + } + + async function signInWithGrant(grantedPermissions: string[] | null) { + mockLoadTokens.mockResolvedValue(noStoredTokens) + mockSignInWithPKCE.mockResolvedValue({ + kind: 'success', + tokens: validTokens, + userInfo: adaUserInfo, + grantedPermissions, + }) + renderProvider() + await waitFor(() => expect(getText('isLoading')).toBe('false')) + fireEvent.press(screen.getByTestId('signIn')) + await waitFor(() => expect(getText('signInOutcome')).toBe('resolved')) + } + + async function pressRequestPermissions() { + fireEvent.press(screen.getByTestId('requestPermissions')) + await waitFor(() => expect(getText('permissionOutcome')).not.toBe('idle')) + return JSON.parse(getText('permissionOutcome')) + } + + it('fails with not-signed-in without minting a token when there is no access token', async () => { + mockLoadTokens.mockResolvedValue(noStoredTokens) + renderProvider() + await waitFor(() => expect(getText('isLoading')).toBe('false')) + + expect(await pressRequestPermissions()).toMatchObject({ + status: 'failure', + reason: 'not-signed-in', + }) + // A mint on a signed-out user would 401, and that 401 reads as + // "not permitted", which is a different and wrong story. + expect(mockCreateDataExchangeApi).not.toHaveBeenCalled() + expect(mockRequestDataExchange).not.toHaveBeenCalled() + }) + + it('flips hasPermission on the next render when the grant lands', async () => { + // Signed in having declined highlights: the exact state this flow exists for. + await signInWithGrant([]) + expect(getText('hasHighlights')).toBe('false') + + mockRequestDataExchange.mockResolvedValue({ + status: 'granted', + grantedPermissions: ['highlights'], + }) + + expect(await pressRequestPermissions()).toEqual({ + status: 'granted', + grantedPermissions: ['highlights'], + }) + expect(getText('hasHighlights')).toBe('true') + }) + + it('merges the new grant into the existing one rather than replacing it', async () => { + await signInWithGrant(['votd']) + + mockRequestDataExchange.mockResolvedValue({ + status: 'granted', + grantedPermissions: ['highlights'], + }) + await pressRequestPermissions() + + expect(JSON.parse(getText('grantedPermissions'))).toEqual(['votd', 'highlights']) + }) + + it('hands the flow the current token, the initiator id, and the requested permissions', async () => { + await signInWithGrant(null) + + mockRequestDataExchange.mockResolvedValue({ status: 'cancel' }) + await pressRequestPermissions() + + expect(mockCreateDataExchangeApi).toHaveBeenCalledWith({ + appKey: 'appkey', + apiHost: 'api.example.com', + installationId: 'inst-1', + }) + expect(mockRequestDataExchange).toHaveBeenCalledWith( + expect.objectContaining({ + appKey: 'appkey', + apiHost: 'api.example.com', + accessToken: 'new-access', + initiator: expect.objectContaining({ userId: 'u1' }), + permissions: ['highlights'], + }), + ) + }) + + it('gives the flow a getCurrentIdentity that sees a sign-out that happened mid-flow', async () => { + await signInWithGrant(['highlights']) + + mockRequestDataExchange.mockResolvedValue({ status: 'cancel' }) + await pressRequestPermissions() + + const { initiator, getCurrentIdentity } = mockRequestDataExchange.mock.calls[0][0] as { + initiator: { sessionId: number; userId: string | null } + getCurrentIdentity: () => { sessionId: number; userId: string | null } + } + expect(getCurrentIdentity()).toEqual(initiator) + + fireEvent.press(screen.getByTestId('signOut')) + await waitFor(() => expect(getText('isAuthenticated')).toBe('false')) + + // Reading a captured closure would still say 'u1' here, and the initiator + // guard would wave a grant through for a user who has left. + expect(getCurrentIdentity().userId).toBeNull() + expect(getCurrentIdentity().sessionId).not.toBe(initiator.sessionId) + }) + + it('captures the initiator before awaiting the installation id, not after', async () => { + await signInWithGrant(null) + + let releaseInstallationId = () => {} + mockGetOrSetInstallationId.mockReturnValueOnce( + new Promise((resolve) => { + releaseInstallationId = () => resolve('inst-1') + }), + ) + mockRequestDataExchange.mockResolvedValue({ status: 'cancel' }) + + const pending = requestPermissionsFromContext(['highlights']) + + // Sign out while the installation id is still resolving. The mint will + // still use this render's token, so the initiator has to be the user that + // token belongs to — read it afterwards and the guard compares the + // replacement identity against itself, passes, and files the grant under + // whoever is signed in now. + fireEvent.press(screen.getByTestId('signOut')) + await waitFor(() => expect(getText('isAuthenticated')).toBe('false')) + + await act(async () => { + releaseInstallationId() + await pending + }) + + const { initiator, accessToken } = mockRequestDataExchange.mock.calls[0][0] as { + initiator: { sessionId: number; userId: string | null } + accessToken: string + } + expect(accessToken).toBe('new-access') + expect(initiator.userId).toBe('u1') + }) + + it('moves the session id on sign-out but not on a token refresh', async () => { + await signInWithGrant(null) + + mockRequestDataExchange.mockResolvedValue({ status: 'cancel' }) + await pressRequestPermissions() + const { getCurrentIdentity } = mockRequestDataExchange.mock.calls[0][0] as { + getCurrentIdentity: () => { sessionId: number; userId: string | null } + } + const atSignIn = getCurrentIdentity().sessionId + + // A refresh is the same person with a new token — the guard must not fire. + mockRefreshTokens.mockResolvedValue(validTokens) + await act(async () => { + fireAppStateChange('active') + }) + expect(getCurrentIdentity().sessionId).toBe(atSignIn) + + fireEvent.press(screen.getByTestId('signOut')) + await waitFor(() => expect(getText('isAuthenticated')).toBe('false')) + expect(getCurrentIdentity().sessionId).not.toBe(atSignIn) + }) + + it('leaves the grant untouched on a cancel', async () => { + await signInWithGrant(['votd']) + + mockRequestDataExchange.mockResolvedValue({ status: 'cancel' }) + expect(await pressRequestPermissions()).toEqual({ status: 'cancel' }) + + expect(JSON.parse(getText('grantedPermissions'))).toEqual(['votd']) + }) + + it('leaves the grant untouched when the return is granted but empty', async () => { + await signInWithGrant(null) + + mockRequestDataExchange.mockResolvedValue({ status: 'granted', grantedPermissions: [] }) + await pressRequestPermissions() + + expect(getText('grantedPermissions')).toBe('null') + }) + + it('resolves to a transient failure when reading the installation id rejects', async () => { + await signInWithGrant(null) + + // Native state read, so it can genuinely fail. `requestPermissions` is + // documented to resolve rather than throw, and a consumer following that + // contract has no catch to land in. + mockGetOrSetInstallationId.mockRejectedValueOnce(new Error('no installation id')) + + expect(await pressRequestPermissions()).toEqual({ + status: 'failure', + reason: 'transient', + message: 'no installation id', + }) + expect(mockRequestDataExchange).not.toHaveBeenCalled() + }) + + it('shares one in-flight flow across overlapping calls instead of opening a second session', async () => { + await signInWithGrant(null) + + // A double-tap. Left unguarded this mints a second token and opens a second + // auth session — which on Android rejects outright ("WebBrowser is already + // open"), and either way races the first to write the grant cache. + let settle = (_outcome: unknown) => {} + mockRequestDataExchange.mockReturnValue( + new Promise((resolve) => { + settle = resolve + }), + ) + + await act(async () => { + fireEvent.press(screen.getByTestId('requestPermissions')) + }) + expect(mockRequestDataExchange).toHaveBeenCalledTimes(1) + + // Second tap while the first flow is still awaiting the browser. + await act(async () => { + fireEvent.press(screen.getByTestId('requestPermissions')) + }) + expect(mockRequestDataExchange).toHaveBeenCalledTimes(1) + expect(mockCreateDataExchangeApi).toHaveBeenCalledTimes(1) + + await act(async () => { + settle({ status: 'granted', grantedPermissions: ['highlights'] }) + }) + expect(getText('hasHighlights')).toBe('true') + + // The lock releases with the promise, so the next gesture is a fresh flow. + mockRequestDataExchange.mockResolvedValue({ status: 'cancel' }) + await pressRequestPermissions() + expect(mockRequestDataExchange).toHaveBeenCalledTimes(2) + }) + + it('refuses a concurrent request for different permissions instead of handing it the wrong outcome', async () => { + await signInWithGrant(null) + + let settle = (_outcome: unknown) => {} + mockRequestDataExchange.mockReturnValue( + new Promise((resolve) => { + settle = resolve + }), + ) + + // First caller asks for highlights and is still in the consent page. + const first = requestPermissionsFromContext(['highlights']) + await act(async () => {}) + expect(mockRequestDataExchange).toHaveBeenCalledTimes(1) + + // A second caller asks for something else. Sharing the first promise would + // report `granted` for a consent page that never mentioned votd, and leave + // the caller no reason to retry. + // + // The reason is `in-progress`, not `transient`: `transient` invites an + // immediate retry, which would land right back here while the consent page + // is still open. + const second = await act(async () => requestPermissionsFromContext(['votd'])) + expect(second).toMatchObject({ status: 'failure', reason: 'in-progress' }) + expect(mockRequestDataExchange).toHaveBeenCalledTimes(1) + + await act(async () => { + settle({ status: 'granted', grantedPermissions: ['highlights'] }) + }) + expect(await first).toEqual({ status: 'granted', grantedPermissions: ['highlights'] }) + }) + + it('shares the flow when a concurrent request asks for the same permissions in a different order', async () => { + await signInWithGrant(null) + + let settle = (_outcome: unknown) => {} + mockRequestDataExchange.mockReturnValue( + new Promise((resolve) => { + settle = resolve + }), + ) + + const first = requestPermissionsFromContext(['highlights', 'votd']) + await act(async () => {}) + const second = requestPermissionsFromContext(['votd', 'highlights']) + await act(async () => {}) + + // Same consent, so it is a double-tap rather than a competing request. + expect(mockRequestDataExchange).toHaveBeenCalledTimes(1) + + await act(async () => { + settle({ status: 'granted', grantedPermissions: ['highlights', 'votd'] }) + }) + expect(await second).toEqual(await first) + }) + + /** + * Bootstraps into a signed-in state whose access token is already at its + * expiry, so the next `refreshToken()` actually refreshes instead of + * short-circuiting on the leeway check. + */ + async function signInWithStaleToken() { + mockLoadTokens.mockResolvedValue({ + accessToken: 'stored-access', + refreshToken: 'stored-refresh', + expiryDate: new Date(Date.now() - 1000), + }) + mockRefreshTokens.mockResolvedValueOnce({ + ...validTokens, + access_token: 'stale-access', + expires_in: '0', + }) + renderProvider() + await waitFor(() => expect(getText('isLoading')).toBe('false')) + expect(mockRefreshTokens).toHaveBeenCalledTimes(1) + } + + it('refreshes an expired access token before minting, and mints with the new one', async () => { + await signInWithStaleToken() + + mockRefreshTokens.mockResolvedValueOnce({ ...validTokens, access_token: 'fresh-access' }) + mockRequestDataExchange.mockResolvedValue({ + status: 'granted', + grantedPermissions: ['highlights'], + }) + + await pressRequestPermissions() + + // Without this refresh the mint carries the expired token, 401s, and + // `data-exchange-api.ts` reports every mint 401 as `not-permitted` — telling + // the user their app key is misconfigured when the token was merely stale. + expect(mockRefreshTokens).toHaveBeenCalledTimes(2) + expect(mockRequestDataExchange).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: 'fresh-access' }), + ) + }) + + it('still mints with this render token when the pre-mint refresh clears the session', async () => { + await signInWithStaleToken() + + // A revoked refresh trips `clearAuthState`, emptying the token ref. The + // flow must not change story here: it mints with the token this render + // captured and lets the initiator guard discard the grant as + // `user-changed`. Bailing to `not-signed-in` instead would be a different + // contract than the one the guard and its docs describe. + mockRefreshTokens.mockRejectedValueOnce(new TokenEndpointError(401, 'invalid_grant')) + mockRequestDataExchange.mockResolvedValue({ status: 'cancel' }) + + await pressRequestPermissions() + + expect(mockRequestDataExchange).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: 'stale-access' }), + ) + }) +}) diff --git a/packages/core/src/auth/__tests__/data-exchange-api.test.ts b/packages/core/src/auth/__tests__/data-exchange-api.test.ts new file mode 100644 index 00000000..ac156890 --- /dev/null +++ b/packages/core/src/auth/__tests__/data-exchange-api.test.ts @@ -0,0 +1,139 @@ +import { DataExchangeClient } from '@youversion/platform-core' + +import { createDataExchangeApi } from '../data-exchange-api' + +const mockFetch = jest.fn() + +beforeEach(() => { + mockFetch.mockReset() + global.fetch = mockFetch as unknown as typeof fetch +}) + +function jsonResponse(body: unknown, status = 201): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: String(status), + headers: { get: (name: string) => (name === 'content-type' ? 'application/json' : null) }, + json: () => Promise.resolve(body), + text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)), + } as unknown as Response +} + +function errorResponse(status: number, body = ''): Response { + return { + ok: false, + status, + statusText: String(status), + headers: { get: () => null }, + json: () => Promise.resolve(null), + text: () => Promise.resolve(body), + } as unknown as Response +} + +const api = () => + createDataExchangeApi({ + appKey: 'appkey', + apiHost: 'api.example.com', + installationId: 'inst-1', + }) + +describe('createDataExchangeApi — mintToken', () => { + it('POSTs the requested permissions to /data-exchange/token and returns the minted token', async () => { + mockFetch.mockResolvedValue(jsonResponse({ token: 'dx-token' })) + + const result = await api().mintToken('tok', ['highlights']) + + expect(result).toEqual({ ok: true, value: 'dx-token' }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.example.com/data-exchange/token?app-key=appkey') + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual({ requested_permissions: ['highlights'] }) + const headers = init.headers as Record + expect(headers.Authorization).toBe('Bearer tok') + expect(headers['X-YVP-App-Key']).toBe('appkey') + expect(headers['X-YVP-Installation-Id']).toBe('inst-1') + }) + + it('falls back to the default API host when none is configured', async () => { + mockFetch.mockResolvedValue(jsonResponse({ token: 'dx-token' })) + + await createDataExchangeApi({ appKey: 'appkey', installationId: 'inst-1' }).mintToken('tok', [ + 'highlights', + ]) + + const [url] = mockFetch.mock.calls[0] as [string] + expect(url).toBe('https://api.youversion.com/data-exchange/token?app-key=appkey') + }) + + // The token must travel as the explicit `lat` argument. Omitted, platform-core + // resolves it from the ambient browser configuration, which is not this + // provider's token — the same rule createHighlightsApi follows. + it('passes the access token to the client as an explicit lat argument', async () => { + const updateToken = jest + .spyOn(DataExchangeClient.prototype, 'updateToken') + .mockResolvedValue('dx-token') + + try { + await api().mintToken('tok', ['highlights', 'votd']) + expect(updateToken).toHaveBeenCalledWith(['highlights', 'votd'], 'tok') + } finally { + updateToken.mockRestore() + } + }) + + it('reports a 401 as not-permitted rather than a generic failure', async () => { + mockFetch.mockResolvedValue(errorResponse(401)) + + const result = await api().mintToken('tok', ['highlights']) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe('not-permitted') + expect(result.error.message).toBeTruthy() + }) + + it('reports 5xx and network errors as transient', async () => { + mockFetch.mockResolvedValue(errorResponse(500)) + const serverError = await api().mintToken('tok', ['highlights']) + expect(serverError.ok).toBe(false) + if (serverError.ok) return + expect(serverError.error).toMatchObject({ kind: 'transient', status: 500 }) + + mockFetch.mockRejectedValue(new TypeError('Network request failed')) + const networkError = await api().mintToken('tok', ['highlights']) + expect(networkError.ok).toBe(false) + if (networkError.ok || networkError.error.kind !== 'transient') { + throw new Error('expected a transient failure') + } + expect(networkError.error.status).toBeUndefined() + }) + + it('reports a malformed 2xx payload as transient', async () => { + mockFetch.mockResolvedValue(jsonResponse({ not_a_token: true })) + + const result = await api().mintToken('tok', ['highlights']) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe('transient') + expect(result.error.message).toMatch(/Unexpected data exchange token response/) + }) + + it('reports a non-Error throw as transient without stringifying to [object Object]', async () => { + const updateToken = jest + .spyOn(DataExchangeClient.prototype, 'updateToken') + .mockRejectedValue('boom') + + try { + const result = await api().mintToken('tok', ['highlights']) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error).toEqual({ kind: 'transient', message: 'boom' }) + } finally { + updateToken.mockRestore() + } + }) +}) diff --git a/packages/core/src/auth/__tests__/data-exchange.test.ts b/packages/core/src/auth/__tests__/data-exchange.test.ts new file mode 100644 index 00000000..042cd096 --- /dev/null +++ b/packages/core/src/auth/__tests__/data-exchange.test.ts @@ -0,0 +1,290 @@ +import * as WebBrowser from 'expo-web-browser' + +import { MMKV_AUTH_KEYS } from '../constants' +import { requestDataExchange, type RequestDataExchangeArgs } from '../data-exchange' +import type { DataExchangeApi } from '../data-exchange-api' +import { saveGrantedPermissions } from '../granted-permissions-cache' + +const mockMmkv = new Map() + +jest.mock('../../storage/mmkv-storage', () => ({ + mmkvStorage: { + set: jest.fn((k: string, v: string) => { + mockMmkv.set(k, v) + }), + getString: jest.fn((k: string) => mockMmkv.get(k)), + remove: jest.fn((k: string) => mockMmkv.delete(k)), + getAllKeys: jest.fn(() => Array.from(mockMmkv.keys())), + }, +})) + +jest.mock('expo-web-browser', () => ({ + openAuthSessionAsync: jest.fn(), +})) + +const mockOpenAuthSession = WebBrowser.openAuthSessionAsync as jest.Mock +const mintToken = jest.fn, unknown[]>() + +beforeEach(() => { + mockMmkv.clear() + jest.clearAllMocks() + mintToken.mockResolvedValue({ ok: true, value: 'dx-token' }) +}) + +/** The app's registered callback URL, which the consent page returns to. */ +const TEST_REDIRECT_URI = 'yvp-rn-example://callback' + +function run(overrides: Partial = {}) { + return requestDataExchange({ + api: { mintToken } as DataExchangeApi, + appKey: 'appkey', + apiHost: 'api.example.com', + accessToken: 'tok', + redirectUri: TEST_REDIRECT_URI, + initiator: { sessionId: 1, userId: 'u1' }, + permissions: ['highlights'], + getCurrentIdentity: () => ({ sessionId: 1, userId: 'u1' }), + ...overrides, + }) +} + +function arriveWith(search: string) { + mockOpenAuthSession.mockResolvedValue({ + type: 'success', + url: `${TEST_REDIRECT_URI}?${search}`, + }) +} + +function cachedGrant(): unknown { + const raw = mockMmkv.get(MMKV_AUTH_KEYS.grantedPermissions) + return raw === undefined ? undefined : JSON.parse(raw) +} + +describe('requestDataExchange — happy path', () => { + it("mints, opens the hosted consent page against the app's registered callback URL, and reports the grant", async () => { + arriveWith('data_exchange_status=granted&granted_permissions[]=highlights') + + const outcome = await run() + + expect(outcome).toEqual({ status: 'granted', grantedPermissions: ['highlights'] }) + expect(mintToken).toHaveBeenCalledWith('tok', ['highlights']) + + const [url, returnUrl] = mockOpenAuthSession.mock.calls[0] as [string, string] + // An app key has one callback URL and OAuth already owns it, so the auth + // session must watch the app's `redirectUri`. Watching anything else means + // the return never matches and real grants are discarded as `cancel`. + expect(returnUrl).toBe(TEST_REDIRECT_URI) + const parsed = new URL(url) + expect(parsed.origin + parsed.pathname).toBe('https://api.example.com/data-exchange') + expect(parsed.searchParams.get('token')).toBe('dx-token') + expect(parsed.searchParams.get('app_key')).toBe('appkey') + expect(parsed.searchParams.get('x-yvp-app-key')).toBe('appkey') + }) + + it('merges the grant into the cache instead of replacing it', async () => { + // A permission granted at sign-in must survive a later, narrower consent. + saveGrantedPermissions('u1', ['votd']) + arriveWith('data_exchange_status=granted&granted_permissions[]=highlights') + + const outcome = await run() + + expect(outcome).toEqual({ status: 'granted', grantedPermissions: ['highlights'] }) + expect(cachedGrant()).toEqual({ userId: 'u1', permissions: ['votd', 'highlights'] }) + }) + + it('does not duplicate a permission that was already cached', async () => { + saveGrantedPermissions('u1', ['highlights']) + arriveWith( + 'data_exchange_status=granted&granted_permissions[]=highlights&granted_permissions[]=votd', + ) + + await run() + + expect(cachedGrant()).toEqual({ userId: 'u1', permissions: ['highlights', 'votd'] }) + }) + + it('writes nothing when a granted return carries no permissions', async () => { + // An empty list is not a denial: storing [] would fabricate one, and the + // cache's absent state is what "unknown" means. + arriveWith('data_exchange_status=granted&granted_permissions[]=') + + const outcome = await run() + + expect(outcome).toEqual({ status: 'granted', grantedPermissions: [] }) + expect(cachedGrant()).toBeUndefined() + }) +}) + +describe('requestDataExchange — cancel', () => { + it('treats a dismissed session as a cancel and leaves the cache alone', async () => { + // This is also what Android reports when the return never matches + // `redirectUri`: the session hangs, then reports `dismiss`. + saveGrantedPermissions('u1', ['votd']) + mockOpenAuthSession.mockResolvedValue({ type: 'dismiss' }) + + expect(await run()).toEqual({ status: 'cancel' }) + expect(cachedGrant()).toEqual({ userId: 'u1', permissions: ['votd'] }) + }) + + it('treats a cancel status on the return as a cancel and leaves the cache alone', async () => { + saveGrantedPermissions('u1', ['votd']) + arriveWith('data_exchange_status=cancel') + + expect(await run()).toEqual({ status: 'cancel' }) + expect(cachedGrant()).toEqual({ userId: 'u1', permissions: ['votd'] }) + }) +}) + +describe('requestDataExchange — failure', () => { + it('reports a mint 401 as not-permitted and never opens the browser', async () => { + mintToken.mockResolvedValue({ + ok: false, + error: { kind: 'not-permitted', message: 'Request failed with status 401' }, + }) + + const outcome = await run() + + expect(outcome).toEqual({ + status: 'failure', + reason: 'not-permitted', + message: 'Request failed with status 401', + }) + expect(mockOpenAuthSession).not.toHaveBeenCalled() + }) + + it('reports a transient mint failure as transient', async () => { + mintToken.mockResolvedValue({ + ok: false, + error: { kind: 'transient', status: 500, message: 'Request failed with status 500' }, + }) + + expect(await run()).toEqual({ + status: 'failure', + reason: 'transient', + message: 'Request failed with status 500', + }) + }) + + it('reports an unknown data_exchange_status as a failure and leaves the cache alone', async () => { + saveGrantedPermissions('u1', ['votd']) + arriveWith('data_exchange_status=something_new&granted_permissions[]=highlights') + + const outcome = await run() + + expect(outcome).toMatchObject({ status: 'failure', reason: 'transient' }) + expect(cachedGrant()).toEqual({ userId: 'u1', permissions: ['votd'] }) + }) + + it('reports a return that is not a data-exchange callback as a failure', async () => { + arriveWith('code=AUTHCODE') + + expect(await run()).toMatchObject({ status: 'failure', reason: 'transient' }) + expect(cachedGrant()).toBeUndefined() + }) + + it('reports an unparseable return URL as a failure instead of throwing', async () => { + mockOpenAuthSession.mockResolvedValue({ type: 'success', url: 'not a url' }) + + await expect(run()).resolves.toMatchObject({ status: 'failure', reason: 'transient' }) + }) + + it('reports a rejecting auth session as a failure instead of throwing', async () => { + // `openAuthSessionAsync` rejects on conditions a user can reach: a session + // already open (a double-tap), a missing native module, or an Android build + // with no activity able to handle the intent. This flow promises an outcome, + // and every doc for it tells consumers not to try/catch. + saveGrantedPermissions('u1', ['votd']) + mockOpenAuthSession.mockRejectedValue( + new Error('WebBrowser is already open, only one can be open at a time'), + ) + + await expect(run()).resolves.toEqual({ + status: 'failure', + reason: 'transient', + message: 'WebBrowser is already open, only one can be open at a time', + }) + expect(cachedGrant()).toEqual({ userId: 'u1', permissions: ['votd'] }) + }) + + it('reports a non-Error rejection from the auth session as a failure', async () => { + mockOpenAuthSession.mockRejectedValue('exploded') + + await expect(run()).resolves.toEqual({ + status: 'failure', + reason: 'transient', + message: 'exploded', + }) + }) +}) + +describe('requestDataExchange — initiator guard', () => { + it('discards the grant and fails when the signed-in user changed mid-flow', async () => { + saveGrantedPermissions('u1', ['votd']) + arriveWith('data_exchange_status=granted&granted_permissions[]=highlights') + + const outcome = await run({ getCurrentIdentity: () => ({ sessionId: 2, userId: 'u2' }) }) + + expect(outcome).toMatchObject({ status: 'failure', reason: 'user-changed' }) + // Neither user's cache was written: u1's entry is untouched, and nothing + // was recorded for u2. + expect(cachedGrant()).toEqual({ userId: 'u1', permissions: ['votd'] }) + }) + + it('fails when the user signed out mid-flow', async () => { + arriveWith('data_exchange_status=granted&granted_permissions[]=highlights') + + const outcome = await run({ getCurrentIdentity: () => ({ sessionId: 2, userId: null }) }) + + expect(outcome).toMatchObject({ status: 'failure', reason: 'user-changed' }) + expect(cachedGrant()).toBeUndefined() + }) + + it('accepts a grant for a signed-in user with no id, rather than failing closed forever', async () => { + // `userInfo.id` comes from the id_token's `sub` and can legitimately be + // absent; treating that as a mismatch would make the flow unusable. + arriveWith('data_exchange_status=granted&granted_permissions[]=highlights') + + const outcome = await run({ + initiator: { sessionId: 1, userId: null }, + getCurrentIdentity: () => ({ sessionId: 1, userId: null }), + }) + + expect(outcome).toEqual({ status: 'granted', grantedPermissions: ['highlights'] }) + // Granted, but not persisted: the cache refuses a null userId (an entry no + // user can be identified by would be read back by every id-less user — see + // granted-permissions-cache.ts). The provider still merges the outcome into + // in-memory state, so hasPermission is true for the rest of the session and + // the next cold start re-prompts rather than trusting an unattributable entry. + expect(cachedGrant()).toBeUndefined() + }) + + it('discards an id-less user’s grant when the session changed mid-flow', async () => { + // The case the session id exists to catch: comparing ids alone, `null === null` + // waves this through and the caller is told `granted` for a user who has + // left. Nothing would be persisted either way — the cache refuses a null + // userId — but the outcome itself must not lie. + arriveWith('data_exchange_status=granted&granted_permissions[]=highlights') + + const outcome = await run({ + initiator: { sessionId: 1, userId: null }, + getCurrentIdentity: () => ({ sessionId: 3, userId: null }), + }) + + expect(outcome).toMatchObject({ status: 'failure', reason: 'user-changed' }) + expect(cachedGrant()).toBeUndefined() + }) + + it('accepts a grant when only the token changed mid-flow', async () => { + // A refresh issues a new token for the same person and leaves the session + // id alone, so it must not fail the guard. + arriveWith('data_exchange_status=granted&granted_permissions[]=highlights') + + const outcome = await run({ + initiator: { sessionId: 4, userId: 'u1' }, + getCurrentIdentity: () => ({ sessionId: 4, userId: 'u1' }), + }) + + expect(outcome).toEqual({ status: 'granted', grantedPermissions: ['highlights'] }) + expect(cachedGrant()).toEqual({ userId: 'u1', permissions: ['highlights'] }) + }) +}) diff --git a/packages/core/src/auth/__tests__/granted-permissions-cache.test.ts b/packages/core/src/auth/__tests__/granted-permissions-cache.test.ts index 6feada58..5f34375f 100644 --- a/packages/core/src/auth/__tests__/granted-permissions-cache.test.ts +++ b/packages/core/src/auth/__tests__/granted-permissions-cache.test.ts @@ -3,6 +3,7 @@ import { MMKV_AUTH_KEYS } from '../constants' import { clearGrantedPermissions, loadCachedGrantedPermissions, + mergeGrantedPermissions, saveGrantedPermissions, } from '../granted-permissions-cache' @@ -24,6 +25,36 @@ beforeEach(() => { jest.clearAllMocks() }) +describe('mergeGrantedPermissions', () => { + it('unions a newly granted permission onto an existing grant', () => { + // A just-in-time consent reports only what it asked for; replacing would + // erase what the user granted at sign-in. + expect(mergeGrantedPermissions(['votd'], ['highlights'])).toEqual(['votd', 'highlights']) + }) + + it('de-duplicates without reordering the existing grant', () => { + expect(mergeGrantedPermissions(['votd', 'highlights'], ['highlights', 'bibles'])).toEqual([ + 'votd', + 'highlights', + 'bibles', + ]) + }) + + it('handles either side being empty', () => { + expect(mergeGrantedPermissions([], ['highlights'])).toEqual(['highlights']) + expect(mergeGrantedPermissions(['highlights'], [])).toEqual(['highlights']) + expect(mergeGrantedPermissions([], [])).toEqual([]) + }) + + it('does not mutate its inputs', () => { + const existing = ['votd'] + const granted = ['highlights'] + mergeGrantedPermissions(existing, granted) + expect(existing).toEqual(['votd']) + expect(granted).toEqual(['highlights']) + }) +}) + describe('granted permissions cache', () => { it('round-trips a grant for the same user', () => { saveGrantedPermissions('u1', ['highlights', 'bibles']) diff --git a/packages/core/src/auth/__tests__/use-yv-auth.test.tsx b/packages/core/src/auth/__tests__/use-yv-auth.test.tsx index 7e7adb77..bb1d400a 100644 --- a/packages/core/src/auth/__tests__/use-yv-auth.test.tsx +++ b/packages/core/src/auth/__tests__/use-yv-auth.test.tsx @@ -23,6 +23,7 @@ describe('useYVAuth', () => { grantedPermissions: null, hasPermission: jest.fn(() => false), invalidatePermissions: jest.fn(), + requestPermissions: jest.fn(), } const wrapper = ({ children }: { children: ReactNode }) => ( {children} diff --git a/packages/core/src/auth/auth-context.tsx b/packages/core/src/auth/auth-context.tsx index dd340215..850514fd 100644 --- a/packages/core/src/auth/auth-context.tsx +++ b/packages/core/src/auth/auth-context.tsx @@ -1,4 +1,5 @@ import { createContext } from 'react' +import type { DataExchangeOutcome } from './data-exchange' import type { AuthPermission, YVUserInfo } from './types' export type AuthContextValue = { @@ -27,6 +28,14 @@ export type AuthContextValue = { hasPermission: (permission: AuthPermission) => boolean /** Drop a stale cached grant (e.g. after a 401/403 write) so the next pre-flight re-prompts. */ invalidatePermissions: () => void + /** + * Asks an already signed-in user to grant `permissions` on the spot, via + * YouVersion's hosted consent page — no sign-out required. A `granted` + * outcome merges into {@link grantedPermissions}, so `hasPermission` answers + * true on the next render. Fails immediately with `not-signed-in` when there + * is no token. + */ + requestPermissions: (permissions: readonly AuthPermission[]) => Promise } export const AuthContext = createContext(null) diff --git a/packages/core/src/auth/auth-provider.tsx b/packages/core/src/auth/auth-provider.tsx index c2f12a7e..1ad3e500 100644 --- a/packages/core/src/auth/auth-provider.tsx +++ b/packages/core/src/auth/auth-provider.tsx @@ -1,13 +1,18 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { AppState, type AppStateStatus } from 'react-native' import { z } from 'zod' +import { toMessage } from '../error-message' import { clearHighlightsCache } from '../highlights' +import { getOrSetInstallationId } from '../installation-id' import { mmkvStorage } from '../storage/mmkv-storage' import { AuthContext, type AuthContextValue } from './auth-context' import { MMKV_AUTH_KEYS, REFRESH_LEEWAY_SECONDS } from './constants' +import { requestDataExchange, type AuthIdentity, type DataExchangeOutcome } from './data-exchange' +import { createDataExchangeApi } from './data-exchange-api' import { clearGrantedPermissions, loadCachedGrantedPermissions, + mergeGrantedPermissions, saveGrantedPermissions, } from './granted-permissions-cache' import { refreshTokens, TokenEndpointError } from './http' @@ -38,19 +43,67 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth const expiryRef = useRef(null) const refreshTokenRef = useRef(null) const isRefreshingRef = useRef(false) + // The access token as a ref, alongside the state, for the one read that has to + // see past the render closure: data exchange refreshes before minting, and the + // token it must send is the one that refresh just wrote, not the one this + // render captured. + const accessTokenRef = useRef(null) - const setAuthState = useCallback(async (tokens: StoredTokens, user?: YVUserInfo) => { - await saveTokens(tokens) - expiryRef.current = tokens.expiryDate - refreshTokenRef.current = tokens.refreshToken - setAccessToken(tokens.accessToken) + // Latest identity, for a read that has to outlive a render: the data-exchange + // initiator guard needs who is signed in *now*, once the browser comes back, + // not who was captured in the closure when the flow started. + // + // The session id counts identity transitions — sign-in and sign-out, never a + // token refresh — so the guard can tell "signed out" from "signed in without + // an id", which a null `userInfo.id` alone cannot. Both are written together + // by `setIdentity` so they can never disagree; an effect would leave a window + // where the session id has moved and the id has not. + const userInfoRef = useRef(userInfo) + const sessionIdRef = useRef(0) + + const setIdentity = useCallback((user: YVUserInfo | null) => { + userInfoRef.current = user + sessionIdRef.current += 1 + setUserInfo(user) if (user) { - setUserInfo(user) mmkvStorage.set(MMKV_AUTH_KEYS.cachedUserInfo, JSON.stringify(user)) } }, []) + const getCurrentIdentity = useCallback( + (): AuthIdentity => ({ + sessionId: sessionIdRef.current, + userId: userInfoRef.current?.id ?? null, + }), + [], + ) + + // The single in-flight data-exchange request, keyed by what it asked for so + // only an identical request is allowed to share its outcome. + const inFlightRequestRef = useRef<{ + key: string + promise: Promise + } | null>(null) + + const setAuthState = useCallback( + async (tokens: StoredTokens, user?: YVUserInfo) => { + await saveTokens(tokens) + expiryRef.current = tokens.expiryDate + refreshTokenRef.current = tokens.refreshToken + accessTokenRef.current = tokens.accessToken + setAccessToken(tokens.accessToken) + + if (user) { + // Identity, not just tokens — start a new session. A call without `user` + // is a token refresh for the same person and must leave the session id + // alone, or a refresh landing mid-flow would fail the initiator guard. + setIdentity(user) + } + }, + [setIdentity], + ) + // Always both halves: the cached entry and the in-memory state hasPermission // actually reads. const invalidatePermissions = useCallback(() => { @@ -64,11 +117,12 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth clearHighlightsCache() expiryRef.current = null refreshTokenRef.current = null + accessTokenRef.current = null setAccessToken(null) - setUserInfo(null) + setIdentity(null) setError(null) await saveTokens({ accessToken: null, refreshToken: null, expiryDate: null }) - }, [invalidatePermissions]) + }, [invalidatePermissions, setIdentity]) const refreshToken = useCallback( async (options?: { force?: boolean }) => { @@ -220,6 +274,128 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth [grantedPermissions], ) + const requestPermissions = useCallback( + (permissions: readonly AuthPermission[]): Promise => { + // No token means no mint attempt: the endpoint would 401, and that 401 + // would read as "this app may not run data exchange" rather than the + // truth, which is that nobody is signed in. + if (accessToken === null) { + return Promise.resolve({ + status: 'failure', + reason: 'not-signed-in', + message: 'Not signed in — requesting a permission requires an authenticated user.', + }) + } + + // One flow at a time: a second concurrent request must not mint another + // token or open another auth session (on Android the second + // `openAuthSessionAsync` rejects outright). What happens to it depends on + // whether it is asking for the same thing. + // + // Same permissions — a double-tap — shares the in-flight promise, because + // both callers genuinely want that one answer. A *different* set may not: + // the open consent page never mentions its permissions, so handing it that + // outcome would report `granted` for something the user was never shown, + // with nothing telling the caller to try again. + // + // It gets `in-progress`, not `transient`. `transient` is the reason a + // caller retries on immediately, and an immediate retry lands right back + // here while the consent page is still open — a spin, not a recovery. + // `in-progress` says the one thing that is actually actionable: wait for + // the flow that is already running. + const key = permissionKey(permissions) + const inFlight = inFlightRequestRef.current + if (inFlight !== null) { + return inFlight.key === key + ? inFlight.promise + : Promise.resolve({ + status: 'failure', + reason: 'in-progress', + message: + 'Another permission request is already in progress; retry once it has finished.', + }) + } + + // Snapshot the initiator here, in the same synchronous block that read + // `accessToken`, not later inside `run`. The two must describe one moment: + // the token comes from this render, so reading the identity after an await + // would let a sign-out landing in between pair the previous session's + // token with the replacement identity — the guard would then compare the + // new identity against itself, pass, and file the grant under whoever is + // signed in now (or under the shared null identity). + const initiator = getCurrentIdentity() + + const run = async (): Promise => { + // Mint with a token the server will still take. Refresh is otherwise + // driven only by bootstrap and the AppState `active` handler, so a long + // foreground session can carry an expired token straight into the mint. + // That 401s, and `data-exchange-api.ts` reads every mint 401 as + // `not-permitted` — which the docs tell consumers is an app-key setting, + // not a user problem. The user would be dead-ended by a stale token and + // told to check the console. Refreshing first keeps the 401 honest. + // + // This is the no-op path in the common case: `refreshToken` returns + // immediately unless the expiry is inside the leeway window. + await refreshToken() + + // Prefer the ref over the closure: refresh may have just replaced the + // token. Fall back to the closure token when the ref is null, which + // means the session was cleared while this flow was starting — a + // sign-out, or a refresh that found the token revoked. + // + // Falling back rather than bailing keeps the initiator guard's story + // intact: the mint uses the token this render captured, the guard + // re-reads identity after the browser returns, and a session that moved + // mid-flow reports `user-changed`. Bailing here would report + // `not-signed-in` instead, which is a different contract than the one + // documented, and one subtask 3 has not been written against. + const freshAccessToken = accessTokenRef.current ?? accessToken + + // Built per call rather than memoized: the installation id is async, and + // this runs at most once per user gesture. It reads native state and can + // reject, which would escape as a throw from a flow documented to + // resolve — so it is guarded like the browser call inside the flow. + let installationId: string + try { + installationId = await getOrSetInstallationId() + } catch (caught) { + return { + status: 'failure', + reason: 'transient', + message: toMessage(caught), + } + } + + const outcome = await requestDataExchange({ + api: createDataExchangeApi({ appKey, apiHost, installationId }), + appKey, + apiHost, + accessToken: freshAccessToken, + redirectUri: config.redirectUri, + initiator, + permissions, + getCurrentIdentity, + }) + + // The flow already merged the grant into MMKV; mirror that merge into + // state with the same helper so hasPermission flips without a remount. + if (outcome.status === 'granted' && outcome.grantedPermissions.length > 0) { + const granted = outcome.grantedPermissions + setGrantedPermissions((prev) => mergeGrantedPermissions(prev ?? [], granted)) + } + + return outcome + } + + const pending = run().finally(() => { + inFlightRequestRef.current = null + }) + inFlightRequestRef.current = { key, promise: pending } + return pending + }, + [accessToken, apiHost, appKey, config.redirectUri, getCurrentIdentity, refreshToken], + ) + const value: AuthContextValue = useMemo( () => ({ isAuthenticated: accessToken !== null, @@ -233,6 +409,7 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth grantedPermissions, hasPermission, invalidatePermissions, + requestPermissions, }), [ accessToken, @@ -245,12 +422,22 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth grantedPermissions, hasPermission, invalidatePermissions, + requestPermissions, ], ) return {children} } +/** + * Identity of a permission request, for deciding whether a concurrent caller is + * asking for the same thing. Order- and duplicate-insensitive: `['a','b']` and + * `['b','a','b']` request the same consent, so they may share one flow. + */ +function permissionKey(permissions: readonly AuthPermission[]): string { + return [...new Set(permissions)].sort().join(',') +} + // Validate untrusted cached JSON instead of blindly casting it to YVUserInfo. // The cache can predate the current schema, be hand-tampered, or be corrupt, so // each identity field falls back to undefined if it isn't a string rather than diff --git a/packages/core/src/auth/data-exchange-api.ts b/packages/core/src/auth/data-exchange-api.ts new file mode 100644 index 00000000..bbc2c33d --- /dev/null +++ b/packages/core/src/auth/data-exchange-api.ts @@ -0,0 +1,81 @@ +import { ApiClient, DataExchangeClient } from '@youversion/platform-core' + +import { DEFAULT_API_HOST } from '../constants' +import { toMessage } from '../error-message' +import { err, ok, type Result } from '../result' + +/** + * The mint's 401 is "this app may not run data exchange", not "retry later", so + * it gets its own kind — callers surface it differently from a flaky network. + * Everything else (network, 5xx, schema failure) collapses to `transient`. + */ +export type DataExchangeError = + | { kind: 'not-permitted'; message: string } + | { kind: 'transient'; status?: number; message: string } + +export type DataExchangeApiResult = Result + +export type CreateDataExchangeApiConfig = { + appKey: string + installationId: string + apiHost?: string + timeout?: number +} + +export type DataExchangeApi = { + /** + * Mints a short-lived data-exchange token for `permissions`. + * `POST /data-exchange/token?app-key=`, expecting `201 { token }`. + */ + mintToken: ( + accessToken: string, + permissions: readonly string[], + ) => Promise> +} + +export function createDataExchangeApi(config: CreateDataExchangeApiConfig): DataExchangeApi { + const client = new DataExchangeClient( + new ApiClient({ + appKey: config.appKey, + apiHost: config.apiHost ?? DEFAULT_API_HOST, + installationId: config.installationId, + timeout: config.timeout, + }), + ) + + return { + async mintToken(accessToken, permissions) { + try { + // `lat` is always passed explicitly. Left off, platform-core falls back + // to the ambient browser configuration, which on RN is either empty (a + // throw) or — worse, once anything else populates it — a token that is + // not the one this provider is signed in with. + return ok(await client.updateToken([...permissions], accessToken)) + } catch (caught) { + return err(toDataExchangeError(caught)) + } + }, + } +} + +function toDataExchangeError(caught: unknown): DataExchangeError { + const status = extractStatus(caught) + const message = toMessage(caught) + + if (status === 401) { + return { kind: 'not-permitted', message } + } + + return status === undefined + ? { kind: 'transient', message } + : { kind: 'transient', status, message } +} + +/** Pulls an HTTP status off a thrown ApiClient error. Twin of the one in `highlights/api.ts`. */ +function extractStatus(error: unknown): number | undefined { + if (typeof error === 'object' && error !== null && 'status' in error) { + const status = (error as { status?: unknown }).status + return typeof status === 'number' ? status : undefined + } + return undefined +} diff --git a/packages/core/src/auth/data-exchange.ts b/packages/core/src/auth/data-exchange.ts new file mode 100644 index 00000000..9c49e026 --- /dev/null +++ b/packages/core/src/auth/data-exchange.ts @@ -0,0 +1,240 @@ +import { buildDataExchangeUrl, parseDataExchangeCallback } from '@youversion/platform-core' +import * as WebBrowser from 'expo-web-browser' + +import { toMessage } from '../error-message' +import type { DataExchangeApi, DataExchangeError } from './data-exchange-api' +import { + loadCachedGrantedPermissions, + mergeGrantedPermissions, + saveGrantedPermissions, +} from './granted-permissions-cache' +import type { AuthPermission } from './types' + +/** + * The consent page returns to the app's **registered callback URL** — the same + * `redirectUri` sign-in uses — so that is what the auth session must watch for. + * + * This is not a preference. An app key has exactly one callback URL, and OAuth + * already owns it, so a separate SDK-owned return scheme cannot be registered + * alongside it. Verified on Android against a real app key: with the app's own + * URI registered the server returns + * `?data_exchange_status=granted&granted_permissions=...`; with a + * different URI registered instead, sign-in fails with `invalid_request: + * redirect_uri does not match registered callback URL`. + * + * `buildDataExchangeUrl` takes no redirect param because the server reads the + * callback URL off the app key rather than off this request. + * + * The auth session intercepts the return before the app's callback route sees + * it, so a data-exchange return does not reach sign-in handling. Outside an open + * session the two are still told apart by their query: sign-in carries `code`, + * data exchange carries `data_exchange_status`. + */ + +/** + * Why a request failed. The distinction that matters to a caller is whether + * retrying can help, and when. + * + * - `not-signed-in` / `not-permitted` — retrying changes nothing. Sign the user + * in, or fix the app key in the console. + * - `user-changed` — the grant was discarded because the signed-in user moved. + * Ask again as the new user. + * - `in-progress` — another request holds the flow. Retry once it settles, not + * before: a retry now hits this same branch, because only one consent page can + * be open at a time. + * - `transient` — a network blip, a 5xx, a schema failure. Retry immediately. + */ +export type DataExchangeFailureReason = + | 'not-signed-in' + | 'not-permitted' + | 'user-changed' + | 'in-progress' + | 'transient' + +/** + * What a permission request resolved to. + * + * `granted` carries the permissions the server reported, which is not + * necessarily everything that was asked for — check the list rather than the + * status when you need one specific permission. + */ +export type DataExchangeOutcome = + | { status: 'granted'; grantedPermissions: string[] } + | { status: 'cancel' } + | { status: 'failure'; reason: DataExchangeFailureReason; message: string } + +/** + * Who is signed in, for the initiator guard. + * + * `userId` comes from the id_token's `sub` and can legitimately be absent, so + * `null` means both "signed in, no id" and "signed out". `sessionId` + * disambiguates them: a local counter, compared only for equality and + * meaningless as a value, that changes on every sign-in and sign-out and *only* + * on those. It deliberately does not change on a token refresh — a new token + * for the same person must not fail the flow. + */ +export type AuthIdentity = { + sessionId: number + userId: string | null +} + +export type RequestDataExchangeArgs = { + api: DataExchangeApi + appKey: string + apiHost: string + accessToken: string + /** + * The app's registered callback URL — the same `redirectUri` sign-in uses. + * The consent page returns here, so the auth session watches for it. + */ + redirectUri: string + /** The initiator: identity captured by the caller *before* invoking. */ + initiator: AuthIdentity + permissions: readonly AuthPermission[] + /** Re-reads the current identity after the browser round-trip (initiator guard). */ + getCurrentIdentity: () => AuthIdentity +} + +/** + * Just-in-time permission grant: mint a data-exchange token, run the hosted + * consent page in an auth session, and merge what the user granted into the + * cached grant. Permission-generic — nothing here knows about highlights. + * + * Never throws, and never touches the grant cache except on a `granted` return. + * `api` and `getCurrentIdentity` are injected so the flow is unit-testable + * without React and without constructing an `ApiClient` on every call site. + */ +export async function requestDataExchange({ + api, + appKey, + apiHost, + accessToken, + redirectUri, + initiator, + permissions, + getCurrentIdentity, +}: RequestDataExchangeArgs): Promise { + const minted = await api.mintToken(accessToken, permissions) + if (!minted.ok) { + return mintFailure(minted.error) + } + + // `openAuthSessionAsync` rejects on conditions a caller can actually hit, not + // just on programmer error: a session already open (a double-tap — "WebBrowser + // is already open, only one can be open at a time"), a missing native module, + // or an Android build with no activity able to handle the intent. This + // function promises an outcome, so none of them may escape. + let session: WebBrowser.WebBrowserAuthSessionResult + try { + session = await WebBrowser.openAuthSessionAsync( + buildDataExchangeUrl(minted.value, appKey, apiHost), + redirectUri, + ) + } catch (caught) { + return { + status: 'failure', + reason: 'transient', + message: toMessage(caught), + } + } + + // Anything that is not a redirect back to us is a cancel: the user dismissed + // the sheet, or the return never matched `redirectUri` and the session hung + // until dismissal. Neither is an error we can act on beyond letting the user + // retry. A mismatch here is the failure mode to watch — it looks exactly like + // a decline, so a `redirectUri` that disagrees with the app key's registered + // callback URL silently discards real grants. + if (session.type !== 'success') { + return { status: 'cancel' } + } + + const callback = parseCallback(session.url) + if (callback === null) { + return { + status: 'failure', + reason: 'transient', + message: 'Data exchange returned a URL that is not a data-exchange callback.', + } + } + if (callback.status === 'cancel') { + return { status: 'cancel' } + } + if (callback.status === 'failure') { + return { + status: 'failure', + reason: 'transient', + message: 'Data exchange reported a failed permission grant.', + } + } + + // Initiator guard, fail closed: if the signed-in user is not the one who + // started this flow, discard the grant and let them ask again. + // + // Be honest about what this is worth. It is a cheap backstop, not a defence + // against anything a user can do — neither platform lets them reach the app + // while the consent page is up. iOS presents a modal sheet; on Android the + // Custom Tab is a separate task the user *can* leave, but foregrounding the + // app resolves the auth session as `dismiss` first, so the flow is already + // over. What can still land mid-flow is not user-driven: a revoked refresh + // token tripping `clearAuthState`, or app code calling `signOut` from a timer + // or push handler. Both end signed out, and a signed-out write is already a + // no-op — `saveGrantedPermissions` refuses a null userId. + // + // So the guard earns its keep on two narrower points. The reported outcome + // must never say `granted` for a user who has left, since that is the API + // consumers branch on. And this function stays correct on its own terms + // rather than by depending on a null check in granted-permissions-cache.ts + // that nothing here links to — the coupling that breaks the day someone adds + // a device-scoped fallback to the cache. + // + // Compare identity, not tokens: a mid-flow refresh issues a new token for the + // same person and must pass. A same-session id-less user passes too, which is + // deliberate — `sub` can legitimately be absent, and failing closed there + // would make the flow permanently unusable for those users. + const current = getCurrentIdentity() + if (current.sessionId !== initiator.sessionId || current.userId !== initiator.userId) { + return { + status: 'failure', + reason: 'user-changed', + message: 'The signed-in user changed during the permission grant; the grant was discarded.', + } + } + + // Merge, never replace: this consent only reports the permissions it asked + // for. An empty grant is not written at all — the cache's absent state means + // "unknown", and storing `[]` would record a denial the server never sent. + if (callback.grantedPermissions.length > 0) { + saveGrantedPermissions( + current.userId, + mergeGrantedPermissions( + loadCachedGrantedPermissions(current.userId) ?? [], + callback.grantedPermissions, + ), + ) + } + + return { status: 'granted', grantedPermissions: callback.grantedPermissions } +} + +function mintFailure(error: DataExchangeError): DataExchangeOutcome { + return { + status: 'failure', + reason: error.kind === 'not-permitted' ? 'not-permitted' : 'transient', + message: error.message, + } +} + +/** + * `parseDataExchangeCallback` is the pure parser; `handleDataExchangeCallback` + * is the browser entry point (it reads `window.location` and writes web's own + * permission cache), so it is deliberately not used here. + */ +function parseCallback(url: string): ReturnType { + try { + return parseDataExchangeCallback(new URL(url).search) + } catch { + // A success result always carries our return URL, so this is unreachable in + // practice — but a throw here would escape an otherwise total function. + return null + } +} diff --git a/packages/core/src/auth/granted-permissions-cache.ts b/packages/core/src/auth/granted-permissions-cache.ts index 7df9d7c9..c4b45a35 100644 --- a/packages/core/src/auth/granted-permissions-cache.ts +++ b/packages/core/src/auth/granted-permissions-cache.ts @@ -59,6 +59,23 @@ export function saveGrantedPermissions( } } +/** + * Order-preserving union of an existing grant and a newly reported one. + * + * A just-in-time data-exchange consent reports only the permissions *that flow* + * asked for, so writing it through would erase anything granted earlier at + * sign-in: a `highlights`-only consent must not drop a previously granted + * `votd`. Existing entries keep their order so the cached list stays stable + * across merges (a reordered list is a pointless MMKV write and a confusing + * diff when debugging). + */ +export function mergeGrantedPermissions( + existing: readonly string[], + granted: readonly string[], +): string[] { + return [...new Set([...existing, ...granted])] +} + /** * Removes the cached grant. Best-effort by design: the cached grant is a hint, * the server is the enforcement point, and a survived entry costs at most a diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index 49ac2e9b..c97bfcbc 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -1,3 +1,4 @@ +export type { DataExchangeFailureReason, DataExchangeOutcome } from './data-exchange' export type { AuthConfig, AuthPermission, diff --git a/packages/core/src/error-message.ts b/packages/core/src/error-message.ts new file mode 100644 index 00000000..a81a1ead --- /dev/null +++ b/packages/core/src/error-message.ts @@ -0,0 +1,11 @@ +/** + * Reads a message off an unknown throw. + * + * `catch` binds `unknown`, and every non-throwing flow in this package has to + * turn that into a `message` string for its failure outcome. Doing it inline + * each time drifts: one site drops the `String` fallback, another stringifies an + * Error to `[object Object]`. One helper, one behaviour. + */ +export function toMessage(caught: unknown): string { + return caught instanceof Error ? caught.message : String(caught) +} diff --git a/packages/core/src/highlights/__tests__/use-highlights.test.tsx b/packages/core/src/highlights/__tests__/use-highlights.test.tsx index 65c1c0fe..e52b7a23 100644 --- a/packages/core/src/highlights/__tests__/use-highlights.test.tsx +++ b/packages/core/src/highlights/__tests__/use-highlights.test.tsx @@ -120,6 +120,7 @@ function authValue(overrides: Partial): AuthContextValue { grantedPermissions: null, hasPermission: jest.fn(() => false), invalidatePermissions: jest.fn(), + requestPermissions: jest.fn(), ...overrides, } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 13a767b0..dd06feb3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,7 +3,15 @@ export type { YouVersionContextValue } from './youversion-context' export { default as YouVersionProvider } from './youversion-provider' export { useYVAuth, useYVAuthOptional } from './auth' -export type { AuthConfig, AuthPermission, AuthScope, KnownAuthPermission, YVUserInfo } from './auth' +export type { + AuthConfig, + AuthPermission, + AuthScope, + DataExchangeFailureReason, + DataExchangeOutcome, + KnownAuthPermission, + YVUserInfo, +} from './auth' export { deriveServerColors, HIGHLIGHT_COLORS, isHighlightColor, useHighlights } from './highlights' export type { From 96b0846093d735c811f6629fe08154c480c7e815 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Wed, 5 Aug 2026 09:34:59 -0500 Subject: [PATCH 11/43] feat(core): guarded highlight permission flow (YPE-3709) (3/3) (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): guarded highlight permission flow (YPE-3709) (3/3) Restacked onto the rewritten subtask 1 (#105) and subtask 2 (#106) branches. Content is PR #107's, applied without semantic changes; only the AGENTS.md exports line needed a hand-merge with the redo's additions (KnownAuthPermission, granted-permissions-cache split). Co-Authored-By: Claude Fable 5 * fix(core): map the in-progress failure reason in toWriteReason Rebase integration, not new behavior. #113 split `in-progress` out of `transient` in `DataExchangeFailureReason` after this branch was cut, so `toWriteReason`'s exhaustive switch stopped covering the union and the build failed with TS2366. `in-progress` joins `transient`: a write refused for holding the consent flow open is retryable, just not this instant, and `HighlightWriteReason` has no finer bucket. The flow already reports an overlapping tap as `transient` for the same reason. Co-Authored-By: Claude Opus 5 * fix(core): join an in-flight token refresh instead of skipping it `refreshToken` tracked its in-flight request with a boolean, so a second caller returned immediately rather than waiting — resolving on the very token the refresh existed to replace. The trigger is ordinary, not exotic: the app foregrounds, 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. For the highlight permission flow that 401 classifies as `auth`, `auth` reads as a stale grant, and the user is asked to grant a permission they already granted. Hold the request as a promise and hand it to the second caller, matching how `inFlightRequestRef` already shares an in-flight data exchange in this same file. `ensureFreshToken` now means what its name says, so its "does not guarantee a fresh token" caveat goes away rather than needing somewhere to live. Co-Authored-By: Claude Opus 5 * fix(core): scope the pending highlight to the chapter it was tapped in `PendingHighlight.scope` was written twice and read nowhere, so the field documented an invariant the code did not hold. The render-time RESET and the generation token only protect a flow that already exists. Two windows open before one does, and both replay through `highlightsRef`, which follows the reader: 1. Reader on JHN.3, permission cached, user taps verses 16-18. 2. The write goes out bound to JHN.3. Correct so far. 3. Reader moves to JHN.4. RESET runs, but there is no flow and no waiting caller, so the generation token is not bumped. 4. The write comes back `reason: 'auth'`. A corrective flow opens, capturing JHN.4 as the scope and keeping verses 16-18. 5. Consent is granted, and the highlight lands on JHN.4:16-18 — text the user never selected. The pre-flight `ensureFreshToken()` round-trip is the same class, and its window is widest exactly when a refresh is actually due. Claim the scope before each await and compare it before replaying. The write-refused case still drops the stale grant, because that part was right; it just stops re-prompting for a passage the reader has left, and resolves the caller with the write's own outcome. The abandoned-tap case resolves `noop`, like every other abandonment. Both windows are regression-tested, and the `useHighlights` mock now derives its scope from the options it was rendered with — a fixed scope let the leak through unnoticed. Also shares one copy of `NOT_SIGNED_IN_MESSAGE` between the write path and the flow that wraps it, rather than two that can drift. Co-Authored-By: Claude Opus 5 * docs: record the permission flow decisions in ADR 0016 Three decisions of ADR weight landed in AGENTS.md: the pre-flight branch point over reason-first, the exactly-once re-prompt bound, and the in-memory pending highlight. Adjacent decisions of the same weight got ADRs 0013, 0014, and 0015, and AGENTS.md itself says `docs/adr/` is where architectural decisions live. Move them there, with the alternative each one rejects and the residual each one accepts, and leave AGENTS.md pointing at it. Records the scope guard as load-bearing, and drops three references to `.claude/bugs/auth-provider-expired-access-token.md` — `.claude/` is gitignored and no such file exists, so one of them shipped in the published `.d.ts` telling consumers to read something they cannot see. Co-Authored-By: Claude Opus 5 * perf: paint a highlight on tap, not after the token refresh `useHighlightPermissionFlow.apply` awaited `ensureFreshToken()` before it reached the code that paints. Whenever a refresh was actually due — the access token at or inside its 60-second leeway, or a refresh already running from the `AppState` foreground listener — the user tapped a colour and watched nothing happen for a full token round-trip. The refresh moves into `useHighlights.runWrite`, next to the existing `waitForAuthSettled()`. The token is still current when the request goes out, which is what keeps a 401 from being misread as a stale permission grant, but the optimistic claim now paints on tap. `remove` and direct `useHighlights` consumers pick up the same freshness guarantee, which previously only `apply` had. `apply` is synchronous up to its branch, so the guard comparing `pending.scope` across the pre-flight await is gone — the window it covered cannot open. The dev harness stopped gating its swatches on the full round-trip, which was hiding the optimistic paint entirely, and now reports writes in flight and the tap-to-settle time so the remaining latency can be attributed on device. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Fable 5 Co-authored-by: Cameron Pak --- .changeset/core-highlight-permission-flow.md | 13 + .changeset/core-refresh-single-flight.md | 7 + .changeset/highlight-paint-before-refresh.md | 7 + AGENTS.md | 15 +- CONTEXT.md | 10 + apps/example/app/(tabs)/_layout.tsx | 6 + apps/example/app/(tabs)/highlight-flow.tsx | 305 +++++++ docs/adr/0016-highlight-permission-flow.md | 73 ++ .../src/auth/__tests__/auth-provider.test.tsx | 52 ++ .../src/auth/__tests__/use-yv-auth.test.tsx | 1 + packages/core/src/auth/auth-context.tsx | 20 + packages/core/src/auth/auth-provider.tsx | 76 +- .../__tests__/permission-flow.test.ts | 374 +++++++++ .../use-highlight-permission-flow.test.tsx | 782 ++++++++++++++++++ .../__tests__/use-highlights.test.tsx | 39 + packages/core/src/highlights/constants.ts | 8 + packages/core/src/highlights/index.ts | 9 + .../core/src/highlights/permission-flow.ts | 239 ++++++ .../use-highlight-permission-flow.ts | 520 ++++++++++++ .../core/src/highlights/use-highlights.ts | 22 +- packages/core/src/index.ts | 11 +- 21 files changed, 2561 insertions(+), 28 deletions(-) create mode 100644 .changeset/core-highlight-permission-flow.md create mode 100644 .changeset/core-refresh-single-flight.md create mode 100644 .changeset/highlight-paint-before-refresh.md create mode 100644 apps/example/app/(tabs)/highlight-flow.tsx create mode 100644 docs/adr/0016-highlight-permission-flow.md create mode 100644 packages/core/src/highlights/__tests__/permission-flow.test.ts create mode 100644 packages/core/src/highlights/__tests__/use-highlight-permission-flow.test.tsx create mode 100644 packages/core/src/highlights/permission-flow.ts create mode 100644 packages/core/src/highlights/use-highlight-permission-flow.ts diff --git a/.changeset/core-highlight-permission-flow.md b/.changeset/core-highlight-permission-flow.md new file mode 100644 index 00000000..edab8cd6 --- /dev/null +++ b/.changeset/core-highlight-permission-flow.md @@ -0,0 +1,13 @@ +--- +'@youversion/platform-react-native-expo-core': minor +--- + +A user who taps a highlight color before they are signed in, or before they have granted the `highlights` permission, now gets their highlight instead of losing it. Add `useHighlightPermissionFlow({ versionId, book, chapter })`, which composes `useHighlights` with the auth context and guards `apply` behind the missing step: it holds the pending highlight in memory, runs sign-in and/or the just-in-time consent grant, and applies the highlight on the way back. `remove` is unwrapped and passes straight through — a user with visible highlights already has the grant. + +The hook returns the underlying `useHighlights` result untouched (render `highlights` from it as before), plus `isConfirming` to drive a consent prompt, `confirm()` / `decline()` to answer it, and `flowError` for the one thing worth a toast. `apply` resolves with the write's own `HighlightWriteOutcome` when a write was issued, `noop` when the user abandoned the flow, and an `error` when the flow itself failed — so a cancel or a decline never reads as something going wrong. + +The branch point is a **pre-flight permission read, not a write's 401/403**: branching on the failure reason would burn a failed round-trip before every first highlight. A write refused with `reason: 'auth'` anyway means the cached grant was stale, so the grant is invalidated and the user is re-prompted — **exactly once**, never in a loop. Every dismissal path discards the pending highlight cleanly, and a grant that comes back without `highlights` does not write. The pending highlight carries the passage it was tapped in, so nothing resumed after the reader changes chapters can paint verses onto text the user never selected — not a browser round-trip landing late, and not a write refused while they were still on the previous chapter. + +Also adds `ensureFreshToken()` to the auth context: the leeway-gated refresh, made public and awaited before the permission read. Without it an expired token 401s, the 401 reads as `auth`, and `auth` reads as "stale grant" — so an expired token would present to the user as a request to grant a permission they already granted. It is cheap enough to await on every user gesture, unlike `refreshNow()`, which always hits the token endpoint. + +Requires `auth` on `YouVersionProvider` and the `highlights` permission (a permission, never a scope). With no `auth` configured the flow behaves exactly as signed out, and says so once in development. The localized consent sheet ships separately, once its strings land in the SDK's generated locale files. diff --git a/.changeset/core-refresh-single-flight.md b/.changeset/core-refresh-single-flight.md new file mode 100644 index 00000000..d04f785f --- /dev/null +++ b/.changeset/core-refresh-single-flight.md @@ -0,0 +1,7 @@ +--- +'@youversion/platform-react-native-expo-core': patch +--- + +Fix a token refresh already in flight being skipped rather than joined. `refreshToken` tracked its in-flight request with a boolean, so a second caller returned immediately instead of waiting, resolving on the very token the refresh existed to replace. It now holds the request as a promise and hands it to the second caller, matching how in-flight data-exchange requests are already shared. + +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. `refreshNow()` and the new `ensureFreshToken()` both benefit, so awaiting either now means the token is the current one. diff --git a/.changeset/highlight-paint-before-refresh.md b/.changeset/highlight-paint-before-refresh.md new file mode 100644 index 00000000..48e77dbf --- /dev/null +++ b/.changeset/highlight-paint-before-refresh.md @@ -0,0 +1,7 @@ +--- +'@youversion/platform-react-native-expo-core': patch +--- + +Fix a highlight not painting until the token refresh in front of it finished. `useHighlightPermissionFlow.apply` awaited `ensureFreshToken()` before it reached the code that paints, so whenever a refresh was actually due — the access token at or inside its 60-second leeway, or a refresh already running from the `AppState` foreground listener — the user tapped a colour and watched nothing happen for a full token round-trip. + +The refresh moved into `useHighlights.runWrite`, next to the existing auth-settled wait. The token is still current when the request goes out, which is the property that keeps a 401 from being misread as a stale permission grant, but the optimistic claim now paints on tap. `remove` and any direct `useHighlights` consumer pick up the same freshness guarantee, which previously only `apply` had. diff --git a/AGENTS.md b/AGENTS.md index 2540149a..6ba32f42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,7 +148,7 @@ Keep `apps/example/metro.config.js` minimal — just `getDefaultConfig(__dirname **UI** (`@youversion/platform-react-native-expo-ui`): `YouVersionProvider`, `BibleCard`, `BibleChapterPickerSheet`, `BibleReader`, `BibleReaderSettingsSheet`, `BibleTextView`, `BibleVersionPickerSheet`, `VerseOfTheDay`, and `YouVersionAuthButton` -**Core** (`@youversion/platform-react-native-expo-core`): `YouVersionProvider` (installation id + optional auth), `useYouVersion`, `useYVAuth` (its value carries `grantedPermissions` / `hasPermission` / `invalidatePermissions` / `requestPermissions` alongside the sign-in surface), `useHighlights`, `deriveServerColors`, `HIGHLIGHT_COLORS` / `isHighlightColor`, `mmkvStorage`, auth types (`AuthConfig`, `AuthPermission`, `KnownAuthPermission`, `AuthScope`, `DataExchangeOutcome`, `DataExchangeFailureReason`, `YVUserInfo`), and highlights types (`Highlight`, `HighlightScope`, `ServerColors`, `HighlightWriteOutcome`, `HighlightsFetchError`, `UseHighlightsOptions`, `UseHighlightsResult`) +**Core** (`@youversion/platform-react-native-expo-core`): `YouVersionProvider` (installation id + optional auth), `useYouVersion`, `useYVAuth` (its value carries `grantedPermissions` / `hasPermission` / `invalidatePermissions` / `requestPermissions` / `ensureFreshToken` alongside the sign-in surface), `useHighlights`, `useHighlightPermissionFlow`, `deriveServerColors`, `HIGHLIGHT_COLORS` / `isHighlightColor`, `mmkvStorage`, auth types (`AuthConfig`, `AuthPermission`, `KnownAuthPermission`, `AuthScope`, `DataExchangeOutcome`, `DataExchangeFailureReason`, `YVUserInfo`), and highlights types (`Highlight`, `HighlightScope`, `ServerColors`, `HighlightWriteOutcome`, `HighlightsFetchError`, `UseHighlightsOptions`, `UseHighlightsResult`, `UseHighlightPermissionFlowResult`, `PermissionFlowError`, `PermissionFlowErrorReason`) UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider`. Import Bible components from UI; import `useYVAuth` from core. @@ -173,6 +173,7 @@ 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. - 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. @@ -186,6 +187,18 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider - The five swatches in `HIGHLIGHT_COLORS` are a company standard enforced in core: both `apply` and `remove` reject anything else as `invalid` before painting or issuing a request. Do not relax this on layering grounds — the open improvement is relocating the palette to `@youversion/platform-core`, not deferring it to the UI layer. - Overlay math lives in the pure, React-free `packages/core/src/highlights/optimistic.ts`, ported from the web highlights machine. Ownership tokens and the colour-aware overlay retirement rule are documented in [ADR 0013](docs/adr/0013-native-highlights-optimistic-layer.md); the retirement rule reads like a bug in both directions and is defended only by its regression pair, so read the ADR before touching `shouldRetire`. +## Highlight permission flow (core) + +- `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 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. +- `flowError` is for terminal _flow_ failures only (a failed grant, a still-refused write). Cancels and declines resolve `{ status: 'noop' }` — a user choice is not an error and must not surface as one. +- **The consent sheet is not built yet.** `HighlightConsentSheet` in `packages/ui/src/native/` is blocked on subtask 4's localization sync: `dataExchangeHighlightsQuestion` / `dataExchangeHighlightsExplanation` / `dataExchangeContinue` are not in `packages/ui/src/i18n/locales/en.json`, and `SdkTranslationKey` is generated, so the component cannot type-check here until they land. The hook's contract is UI-agnostic; drive the sheet's `isOpen` from `isConfirming` and route **every** dismissal path (button, backdrop, pan-down, displacement) to `decline()`. `apps/example/app/(tabs)/highlight-flow.tsx` is a temporary harness standing in for it and is deleted by U2 (YPE-3711). + ## Runtime Dependencies **UI** bundles: `@radix-ui/react-use-controllable-state`, `@rn-primitives/portal`, `zustand`, `@youversion/platform-react-hooks`, `@youversion/platform-react-ui`, and `@youversion/platform-react-native-expo-core`. diff --git a/CONTEXT.md b/CONTEXT.md index d5120bad..ed78d70b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -126,6 +126,14 @@ _Avoid_: Scopes (permissions travel as `requested_permissions[]`, never in `scop YouVersion's just-in-time permission grant: a signed-in user grants a permission on the spot through a hosted consent page, without signing out. Mint a short-lived token, run the consent page in an auth session, parse the return, and **merge** the result into **Granted Permissions**. Resolves to a granted / cancel / failure outcome and never throws. The consent page returns to the app's `redirectUri` — the same callback URL sign-in uses, because an app key has exactly one — see [ADR 0015](docs/adr/0015-data-exchange-return-scheme.md). _Avoid_: Treating the return URL as a separate, SDK-owned thing from the app's OAuth `redirectUri` (one app key, one callback URL, both flows share it); replacing the cached grant with what one consent reported; "re-authenticating" (the user never signs out) +**Permission Flow**: +The two-branch journey guarding a highlight `apply`: not signed in → sign-in → re-check → apply (or fall through to consent); signed in without the permission → confirmation → **Data Exchange** → apply on grant. The branch point is a pre-flight **Granted Permissions** read after a token refresh, never a write's 401/403 — a `reason: 'auth'` write is the corrective path for a stale cache and re-prompts exactly once. State is a pure hand-rolled reducer (`permission-flow.ts`); events invalid for the current step are no-ops, which is what stops a late browser return from resurrecting a discarded intent. Guards `apply` only; `remove` passes through. +_Avoid_: Branching on a write failure first (burns a round-trip before every first highlight); re-prompting in a loop; running `remove` through the flow; `xstate` (Swift's equivalent is ~60 lines of view-model state) + +**Pending Highlight**: +The in-memory `{ color, verses, scope }` a **Permission Flow** stashes when the user taps a color before they can write, and applies when sign-in or consent succeeds. Lives only inside reducer state — `openAuthSessionAsync` returns to the same live process, so web's `sessionStorage` stash and TTL solve a problem native does not have. Discarded cleanly on every cancel, decline, failure, or scope change. Its `scope` is the passage the intent was formed in and governs it: verse numbers replayed into another chapter would paint text the user never selected, so anything resumed after an await is checked against the scope it was claimed under. +_Avoid_: Persisting it (that is F1's offline queue, a different thing); keeping it across a scope change (the user has left the passage); reading the current **Highlight Scope** at replay time instead of the claimed one; treating a discard as an error + ## Relationships - A **React Web SDK Component** may expose reusable content that can be rendered by an **Expo DOM Component**. @@ -155,6 +163,8 @@ _Avoid_: Treating the return URL as a separate, SDK-owned thing from the app's O - **Granted Permissions** are read only from the app redirect, never from the `/auth/callback` hop, which drops them. They are **Native-Owned State** cached per user in MMKV and purged with the rest of auth state on sign-out; a stale grant can be invalidated so the next pre-flight re-prompts. - A permission pre-flight reads **Granted Permissions**; a **Highlight Write Outcome** of `reason: 'auth'` is the corrective path when that cache is wrong, not the primary signal. - **Data Exchange** is the other way to obtain **Granted Permissions** — the one that does not require a new sign-in. It writes into the same per-user cache, merging rather than replacing, and only ever on a granted return. +- A **Permission Flow** composes the permission pre-flight, sign-in, and **Data Exchange** around a single guarded `apply`; its consent confirmation is a **Native Sheet** (pending localization), whose every dismissal path routes to decline. +- A **Pending Highlight** belongs to exactly one **Permission Flow** and one **Highlight Scope**; when the flow ends in an apply, its fate is reported through the ordinary **Highlight Write Outcome**. ## Example Dialogue diff --git a/apps/example/app/(tabs)/_layout.tsx b/apps/example/app/(tabs)/_layout.tsx index 0ce61380..ff2fa771 100644 --- a/apps/example/app/(tabs)/_layout.tsx +++ b/apps/example/app/(tabs)/_layout.tsx @@ -32,6 +32,12 @@ export default function Layout() { Card + {/* TEMPORARY: dev harness for the highlight permission flow (YPE-3709). + Removed together with `highlight-flow.tsx` by U2 / YPE-3711. */} + + Flow + + Profile diff --git a/apps/example/app/(tabs)/highlight-flow.tsx b/apps/example/app/(tabs)/highlight-flow.tsx new file mode 100644 index 00000000..bb0b5299 --- /dev/null +++ b/apps/example/app/(tabs)/highlight-flow.tsx @@ -0,0 +1,305 @@ +// ⚠️ TEMPORARY DEV HARNESS — delete this file (and its tab trigger in +// `_layout.tsx`) as part of U2 / YPE-3711, which wires the permission flow into +// the real reader. +// +// It exists because the flow shipped by YPE-3709 subtask 3 is complete and +// unit-tested but NOT reachable from the reader: U1 has to forward the verse +// intent across the DOM boundary and U2 has to subscribe it. This screen calls +// `apply()` directly instead, which is the only way to prove the +// `youversionauth://` consent return actually works on device — especially on +// Android, where it resolves through a deep link. +// +// The inline Confirm / Decline panel below stands in for `HighlightConsentSheet`, +// which cannot be written yet: its localized keys (subtask 4) have not synced, and +// `SdkTranslationKey` is generated, so `t('dataExchangeHighlightsQuestion')` does +// not type-check in this repo today. The hook's contract is UI-agnostic, so the +// sheet is a drop-in replacement for this panel when the keys land. +// +// The example provider is deliberately still scopes-only (no `permissions` on +// `auth`), so signing in never grants `highlights`. That makes this screen walk +// the longest path every time — sign-in, then fall through to consent, then apply +// — which is exactly the path worth verifying. +import { + HIGHLIGHT_COLORS, + useHighlightPermissionFlow, + useYVAuth, + type HighlightWriteOutcome, +} from '@youversion/platform-react-native-expo-core' +import { useCallback, useState } from 'react' +import { Pressable, ScrollView, StyleSheet, Text, useColorScheme, View } from 'react-native' + +const VERSION_ID = 111 // NIV +const BOOK = 'JHN' +const CHAPTER = '3' +const VERSES = [16, 17, 18] + +export default function HighlightFlowScreen() { + const isDark = useColorScheme() === 'dark' + const c = isDark ? dark : light + + const { isAuthenticated, isLoading, userInfo, grantedPermissions, signOut } = useYVAuth() + const flow = useHighlightPermissionFlow({ + versionId: VERSION_ID, + book: BOOK, + chapter: CHAPTER, + }) + + const [selected, setSelected] = useState([16]) + const [lastOutcome, setLastOutcome] = useState(null) + const [inFlight, setInFlight] = useState(0) + const [lastSettleMs, setLastSettleMs] = useState(null) + + const toggleVerse = (verse: number) => { + setSelected((prev) => + prev.includes(verse) + ? prev.filter((v) => v !== verse) + : [...prev, verse].sort((a, b) => a - b), + ) + } + + // Deliberately does NOT lock the controls until the write settles. The paint + // is optimistic and lands on tap; the network round-trip behind it is the + // slow part, and gating the swatches on it would hide the one behaviour this + // harness exists to demonstrate. `useHighlights` supports concurrent writes, + // so overlapping taps are a supported case rather than one to prevent. + // + // `lastSettleMs` is the round-trip, measured from the tap. Compare it against + // how fast the Highlights section below repaints: the gap between the two is + // the optimistic window. + // + // `useCallback` is not here for memoization. `Date.now()` is impure, and the + // React Compiler's purity rule rejects it in a function declared bare in the + // component body — it cannot see that this one only ever runs from an `onPress`. + const run = useCallback(async (action: () => Promise) => { + const startedAt = Date.now() + setInFlight((count) => count + 1) + try { + setLastOutcome(await action()) + } finally { + setLastSettleMs(Date.now() - startedAt) + setInFlight((count) => count - 1) + } + }, []) + + return ( + + + Temporary dev harness — removed by U2 (YPE-3711) + + +
+ + + + {isAuthenticated ? ( + void signOut()} + > + Sign out (reset the flow) + + ) : null} +
+ +
+ + {VERSES.map((verse) => { + const on = selected.includes(verse) + return ( + toggleVerse(verse)} + > + v{verse} + + ) + })} + +
+ +
+ + {HIGHLIGHT_COLORS.map((color) => ( + void run(() => flow.apply(color, selected))} + /> + ))} + + + {HIGHLIGHT_COLORS.map((color) => ( + void run(() => flow.highlights.remove(color, selected))} + /> + ))} + + + Top row applies (guarded by the flow); bottom row removes (passes straight through). + +
+ + {/* Stand-in for HighlightConsentSheet — see the header comment. */} + {flow.isConfirming ? ( + + + Allow this app to save highlights with YouVersion? + + + YouVersion will ask you to grant access before highlights can be saved. + + + + Continue + + + Cancel + + + + ) : null} + +
+ + + + + +
+ +
+ + + {flow.highlights.highlights.length === 0 ? ( + none + ) : ( + flow.highlights.highlights.map((h) => ( + + )) + )} +
+
+ ) +} + +function formatGrant(granted: readonly string[] | null): string { + if (granted === null) { + return 'null (nothing requested / unknown)' + } + return granted.length === 0 ? '[] (asked and denied)' : granted.join(', ') +} + +type Palette = typeof light + +function Section({ + title, + color, + children, +}: { + title: string + color: Palette + children: React.ReactNode +}) { + return ( + + {title.toUpperCase()} + {children} + + ) +} + +function Row({ label, value, color }: { label: string; value: string; color: Palette }) { + return ( + + {label} + + {value} + + + ) +} + +const light = { + bg: '#ffffff', + fg: '#000000', + muted: '#6b6b6b', + border: '#d8d8d8', + chipOn: '#e6e6e6', + warn: '#a35200', +} +const dark = { + bg: '#000000', + fg: '#ffffff', + muted: '#9b9b9b', + border: '#333333', + chipOn: '#2a2a2a', + warn: '#ffb964', +} + +const styles = StyleSheet.create({ + container: { padding: 16, gap: 16 }, + banner: { + borderWidth: 1, + borderRadius: 8, + padding: 8, + fontSize: 12, + fontWeight: '600', + textAlign: 'center', + }, + section: { borderWidth: 1, borderRadius: 10, padding: 12, gap: 8 }, + sectionTitle: { fontSize: 11, fontWeight: '700', letterSpacing: 0.8 }, + row: { flexDirection: 'row', gap: 8, flexWrap: 'wrap', alignItems: 'center' }, + chip: { + borderWidth: 1, + borderRadius: 8, + paddingVertical: 8, + paddingHorizontal: 12, + minWidth: 44, + }, + swatch: { width: 44, height: 32, borderRadius: 8, borderWidth: 1 }, + button: { borderWidth: 1, borderRadius: 8, paddingVertical: 10, paddingHorizontal: 14 }, + hint: { fontSize: 12 }, + consent: { borderWidth: 2, borderRadius: 10, padding: 12, gap: 10 }, + consentTitle: { fontSize: 16, fontWeight: '600' }, + kv: { flexDirection: 'row', gap: 8, alignItems: 'flex-start' }, + key: { fontSize: 13, width: 104 }, + value: { fontSize: 13, flex: 1 }, +}) diff --git a/docs/adr/0016-highlight-permission-flow.md b/docs/adr/0016-highlight-permission-flow.md new file mode 100644 index 00000000..d8a2212e --- /dev/null +++ b/docs/adr/0016-highlight-permission-flow.md @@ -0,0 +1,73 @@ +# 16. The highlight permission flow branches on a pre-flight read, and re-prompts once + +Date: 2026-08-05 + +## Status + +Accepted + +## Context + +A user taps a highlight color before they are signed in, or before they have granted the `highlights` permission. The tap has to survive whatever step is missing (YPE-3709, YPE-4355), mirroring Swift's `BibleReaderViewModel.addHighlightOrStartPermissionFlow`. + +Three questions had answers that are not obvious from the code, and each has a cheaper-looking alternative a future reader will reach for. + +**Where the flow branches.** The SDK can learn the user is not permitted in two places: a pre-flight read of the cached grant, or a write that comes back 401/403. Reason-first is tempting because [ADR 0014](0014-cached-grant-is-a-hint.md) already says the cache is only a hint, so the failure is the authoritative signal either way. + +**How many times to re-prompt.** [ADR 0014](0014-cached-grant-is-a-hint.md) requires the write path to correct a stale hint through `invalidatePermissions`. Correcting it means asking the user again, and asking again on a signal that can repeat invites a loop. + +**Where the pending highlight lives.** The web SDK stashes an equivalent intent in `sessionStorage` with a TTL, because a web OAuth redirect destroys the page. + +## Decision + +**Branch on the pre-flight read. Treat a `reason: 'auth'` write as the corrective path, not the primary one.** + +`useHighlightPermissionFlow` reads `hasPermission('highlights')` and chooses sign-in, consent, or a straight-through write from that. Reason-first would issue a request the SDK already knows will fail before every first highlight, so the common case would pay a failed round-trip to learn something the cache could answer. + +**The token refresh belongs on the send path, not in front of the tap.** + +The refresh is not optional. An expired token 401s, a 401 classifies as `auth`, and `auth` reads as a stale grant, so a write issued on an expired token presents to the user as a request to grant a permission they already granted. + +It runs inside `useHighlights.runWrite`, next to the existing `waitForAuthSettled()`, rather than as a pre-flight in `apply`. Both orderings make the token current when the request goes out. Only one of them keeps the optimistic paint immediate: + +| Refresh here | What the user sees when a refresh is due | +| ----------------------------------- | ------------------------------------------- | +| Pre-flight, before `hasPermission` | Nothing, until a token round-trip completes | +| `runWrite`, after the claim painted | The colour, on tap | + +`hasPermission` reads the local grant cache and needs no token, so nothing about the branch decision required the refresh to come first. `runWrite` already re-reads the current token at send time — deliberately, so a mid-write refresh does not fail the write — which is the same place the fresh one lands. + +Two things follow. `apply` is now synchronous up to the branch, so the guard that compared `pending.scope` across the pre-flight await is gone: the window it covered no longer exists. And `remove`, plus any direct `useHighlights` consumer, gets the same protection `apply` used to get alone. + +**Re-prompt exactly once, then go terminal.** + +The reducer carries a `retried` flag from `confirming` onward. A write refused with `reason: 'auth'` after the user has just granted the permission calls `invalidatePermissions()` and re-prompts once. A second refusal resolves as a flow error. More consent cannot fix a server that is still refusing, and looping the hosted consent page on a repeated 403 is the worst available reading of it. + +**Keep the pending highlight in memory, and let its scope govern it.** + +`openAuthSessionAsync` returns to the same live process, so `sessionStorage` and a TTL solve a problem native does not have. The pending highlight lives inside reducer state as `{ color, verses, scope }`. + +`scope` is not decoration. Verse numbers mean nothing without the chapter they were tapped in, and the hook's `useHighlights` reference follows the reader. Anything replayed after an await is compared against the scope it was claimed under before it may write: + +| Window | Guard | +| ----------------------------------------------------- | --------------------------------------------- | +| A live flow spans a scope change | Render-time `RESET` plus the generation token | +| A straight-through write is out and comes back `auth` | Claimed scope versus the current scope | + +The second is the one a generation token cannot cover: no flow exists yet, so there is no waiting caller to abandon. + +**Hand-roll the reducer.** Swift's equivalent is about sixty lines of view-model state, and `useHighlights` already serializes writes through a promise chain. `xstate` would be a dependency in a published package for a five-state machine. + +## Consequences + +Every event invalid for the current step is a no-op. That is the mechanism keeping a browser round-trip that lands after a `RESET` from resurrecting a discarded highlight, so it must not be "tidied" into a smaller reducer that throws or logs on unexpected events. + +Ordinary highlights deliberately do not go through the reducer. Modelling every tap as an exclusive flow step would serialize concurrent writes that `useHighlights` supports and users do constantly. Only a flow is exclusive; an overlapping tap during one resolves `transient`. + +Cancels and declines resolve `{ status: 'noop' }`. A user choice is not an error, and `flowError` carries only terminal flow failures, so a consumer can wire it straight to a toast. + +The accepted residual is the one ADR 0014 already named: a grant the server disagrees with costs the user one extra consent prompt. This flow bounds that at one prompt per tap rather than removing it. + +`ensureFreshToken` joins an in-flight refresh rather than skipping it, so awaiting it does mean the token is current. A failed refresh still leaves the old token in place, which is why the corrective path exists at all and must not be removed as redundant. + +A write now settles no faster than before — the refresh round-trip moved, it did not disappear. What changed is that the user stops waiting on it. Anything added in front of `apply`'s branch, or in front of the claim in `useHighlights.startWrite`, puts the delay back. diff --git a/packages/core/src/auth/__tests__/auth-provider.test.tsx b/packages/core/src/auth/__tests__/auth-provider.test.tsx index 073d06a8..a707f7bd 100644 --- a/packages/core/src/auth/__tests__/auth-provider.test.tsx +++ b/packages/core/src/auth/__tests__/auth-provider.test.tsx @@ -523,6 +523,58 @@ describe('AuthProvider — refresh lock', () => { }) await waitFor(() => expect(getText('isLoading')).toBe('false')) }) + + it('joins an in-flight refresh instead of resolving on the stale token', async () => { + mockLoadTokens.mockResolvedValue({ + accessToken: 'expired-access', + refreshToken: 'r', + expiryDate: new Date(Date.now() - 1000), + }) + + let resolveRefresh: (v: TokenResponse) => void = () => {} + mockRefreshTokens.mockReturnValue( + new Promise((r) => { + resolveRefresh = r + }), + ) + + render( + + + , + ) + + // Bootstrap's refresh is in flight and deliberately left unresolved, which + // is the ordinary case this guards: the app foregrounds, a refresh starts, + // and the user acts before it lands. + await waitFor(() => expect(mockRefreshTokens).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(latestAuth).not.toBeNull()) + + let joined = false + const joiner = latestAuth!.ensureFreshToken().then(() => { + joined = true + }) + + // Drain the microtask queue. A caller that skipped the in-flight refresh + // rather than joining it would have settled by now, and its pre-flight would + // have read the expired token. + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + expect(joined).toBe(false) + expect(getText('accessToken')).toBe('expired-access') + + await act(async () => { + resolveRefresh(validTokens) + await joiner + }) + + expect(joined).toBe(true) + expect(getText('accessToken')).toBe('new-access') + // Joining, not starting a second one. + expect(mockRefreshTokens).toHaveBeenCalledTimes(1) + }) }) describe('AuthProvider — AppState wiring', () => { diff --git a/packages/core/src/auth/__tests__/use-yv-auth.test.tsx b/packages/core/src/auth/__tests__/use-yv-auth.test.tsx index bb1d400a..abc217e6 100644 --- a/packages/core/src/auth/__tests__/use-yv-auth.test.tsx +++ b/packages/core/src/auth/__tests__/use-yv-auth.test.tsx @@ -19,6 +19,7 @@ describe('useYVAuth', () => { signIn: jest.fn(), signOut: jest.fn(), refreshNow: jest.fn(), + ensureFreshToken: jest.fn(), isLoading: false, grantedPermissions: null, hasPermission: jest.fn(() => false), diff --git a/packages/core/src/auth/auth-context.tsx b/packages/core/src/auth/auth-context.tsx index 850514fd..f1ed36ca 100644 --- a/packages/core/src/auth/auth-context.tsx +++ b/packages/core/src/auth/auth-context.tsx @@ -10,6 +10,26 @@ export type AuthContextValue = { signIn: () => Promise signOut: () => Promise refreshNow: () => Promise + /** + * Refresh the access token **only if it is at or near expiry**, then resolve. + * Cheap to await on every user gesture, unlike {@link refreshNow}, which always + * hits the token endpoint. + * + * Exists so an expired token cannot be misread as a missing permission + * (Swift's `hasValidToken()` parity). It never throws: a failed refresh + * surfaces through {@link error}, exactly as the periodic refresh does. + * + * Await it on the **send** path, immediately before an auth-sensitive request + * — not in front of whatever the user just tapped. When a refresh is due this + * costs a full token round-trip, so anything optimistic should have painted + * already. `useHighlights` is the worked example. + * + * A refresh already in flight is **joined**, not skipped, so once this resolves + * the token is the current one. A failed refresh still leaves the old token in + * place, so a caller doing something auth-sensitive wants a corrective path for + * a 401 regardless. + */ + ensureFreshToken: () => Promise isLoading: boolean /** * Three-state grant: `null` = unknown / never requested, `[]` = requested and diff --git a/packages/core/src/auth/auth-provider.tsx b/packages/core/src/auth/auth-provider.tsx index 1ad3e500..6d712bda 100644 --- a/packages/core/src/auth/auth-provider.tsx +++ b/packages/core/src/auth/auth-provider.tsx @@ -42,7 +42,11 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth const expiryRef = useRef(null) const refreshTokenRef = useRef(null) - const isRefreshingRef = useRef(false) + // The single in-flight refresh, held as a promise rather than a boolean so a + // second caller can *join* it. A caller that awaits `refreshToken` needs a + // usable token when it resolves; a boolean flag can only tell it to give up + // and carry on with the expired one it was trying to replace. + const refreshPromiseRef = useRef | null>(null) // The access token as a ref, alongside the state, for the one read that has to // see past the render closure: data exchange refreshes before minting, and the // token it must send is the one that refresh just wrote, not the one this @@ -125,8 +129,9 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth }, [invalidatePermissions, setIdentity]) const refreshToken = useCallback( - async (options?: { force?: boolean }) => { - if (!refreshTokenRef.current) { + async (options?: { force?: boolean }): Promise => { + const currentRefreshToken = refreshTokenRef.current + if (!currentRefreshToken) { return } const expiresAt = expiryRef.current?.getTime() ?? 0 @@ -135,29 +140,47 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth return } - if (isRefreshingRef.current) { - return + // Join an in-flight refresh rather than stepping over it. The trigger is + // ordinary: the app foregrounds, the `AppState` listener starts a refresh, + // and the user taps a moment later. Returning early there would resolve a + // pre-flight on the very token this refresh exists to replace, and the + // write that followed would 401. + // + // A `force` caller joins too. It asked for a token minted now, and the run + // it joins was minted now. + const inFlight = refreshPromiseRef.current + if (inFlight !== null) { + return inFlight } - isRefreshingRef.current = true - try { - const response = await refreshTokens({ - apiHost, - appKey, - refreshToken: refreshTokenRef.current, - }) - await setAuthState({ - accessToken: response.access_token, - refreshToken: response.refresh_token, - expiryDate: new Date(Date.now() + Number(response.expires_in) * 1000), - }) - } catch (e) { - if (e instanceof TokenEndpointError && e.isRevoked) { - await clearAuthState() + const run = (async () => { + try { + const response = await refreshTokens({ + apiHost, + appKey, + refreshToken: currentRefreshToken, + }) + await setAuthState({ + accessToken: response.access_token, + refreshToken: response.refresh_token, + expiryDate: new Date(Date.now() + Number(response.expires_in) * 1000), + }) + } catch (e) { + if (e instanceof TokenEndpointError && e.isRevoked) { + await clearAuthState() + } + setError(e instanceof Error ? e : new Error(String(e))) } - setError(e instanceof Error ? e : new Error(String(e))) + })() + + // Published in the same synchronous block that started `run`, so nothing + // can arrive between the two and open a second refresh. Only the caller + // that started the run clears it; joiners return the promise untouched. + refreshPromiseRef.current = run + try { + await run } finally { - isRefreshingRef.current = false + refreshPromiseRef.current = null } }, [apiHost, appKey, setAuthState, clearAuthState], @@ -269,6 +292,13 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth const refreshNow = useCallback(() => refreshToken({ force: true }), [refreshToken]) + // The leeway-gated refresh, made public under a name that says what a caller + // wants from it. A pre-flight before a permission-sensitive write needs "make + // sure the token is usable" without paying for a token round-trip on every + // tap, which is exactly the non-forced path — and, since the refresh is + // single-flight by promise, awaiting it does mean the token is usable. + const ensureFreshToken = useCallback(() => refreshToken(), [refreshToken]) + const hasPermission = useCallback( (permission: AuthPermission) => grantedPermissions?.includes(permission) ?? false, [grantedPermissions], @@ -405,6 +435,7 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth signIn, signOut, refreshNow, + ensureFreshToken, isLoading, grantedPermissions, hasPermission, @@ -418,6 +449,7 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth signIn, signOut, refreshNow, + ensureFreshToken, isLoading, grantedPermissions, hasPermission, diff --git a/packages/core/src/highlights/__tests__/permission-flow.test.ts b/packages/core/src/highlights/__tests__/permission-flow.test.ts new file mode 100644 index 00000000..6a3180b6 --- /dev/null +++ b/packages/core/src/highlights/__tests__/permission-flow.test.ts @@ -0,0 +1,374 @@ +import type { DataExchangeOutcome } from '../../auth' +import type { HighlightScope } from '../constants' +import { + HIGHLIGHTS_PERMISSION, + initialPermissionFlowState, + permissionFlowReducer, + toWriteReason, + type PendingHighlight, + type PermissionFlowEvent, + type PermissionFlowState, +} from '../permission-flow' +import type { HighlightWriteOutcome } from '../use-highlights' + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const YELLOW = 'fffe00' +const scope: HighlightScope = { versionId: 111, book: 'JHN', chapter: '3' } + +/** Frozen so any transition that "keeps the pending intact" has to mean it. */ +const pending: PendingHighlight = Object.freeze({ color: YELLOW, verses: [16, 17], scope }) + +const granted = (permissions: string[] = [HIGHLIGHTS_PERMISSION]): DataExchangeOutcome => ({ + status: 'granted', + grantedPermissions: permissions, +}) + +const authWriteError = ( + failedVerses: number[], + message = 'unauthorized', +): HighlightWriteOutcome => ({ + status: 'error', + reason: 'auth', + message, + failedVerses, + succeededVerses: [], +}) + +function run( + events: PermissionFlowEvent[], + from: PermissionFlowState = initialPermissionFlowState, +): PermissionFlowState { + return events.reduce(permissionFlowReducer, from) +} + +const TAP_SIGN_IN: PermissionFlowEvent = { type: 'TAP', pending, branch: 'sign-in' } +const TAP_CONSENT: PermissionFlowEvent = { type: 'TAP', pending, branch: 'consent' } + +// ── The two branches ───────────────────────────────────────────────────────── + +describe('the not-signed-in branch', () => { + it('reaches applying with the original pending highlight intact', () => { + const state = run([TAP_SIGN_IN, { type: 'SIGN_IN_DONE', signedIn: true, hasPermission: true }]) + + expect(state).toEqual({ step: 'applying', pending, retried: false }) + // Not merely equal — the same intent the user expressed before signing in. + expect(state.step === 'applying' && state.pending).toBe(pending) + }) + + it('falls through to consent when signing in did not grant the permission', () => { + const state = run([TAP_SIGN_IN, { type: 'SIGN_IN_DONE', signedIn: true, hasPermission: false }]) + + expect(state).toEqual({ step: 'confirming', pending, retried: false }) + }) + + it('reaches applying through the consent fall-through, still holding the pending', () => { + const state = run([ + TAP_SIGN_IN, + { type: 'SIGN_IN_DONE', signedIn: true, hasPermission: false }, + { type: 'CONFIRM' }, + { type: 'GRANT_RESULT', outcome: granted() }, + ]) + + expect(state).toEqual({ step: 'applying', pending, retried: false }) + }) + + it('discards the pending highlight when sign-in is cancelled', () => { + const state = run([ + TAP_SIGN_IN, + { type: 'SIGN_IN_DONE', signedIn: false, hasPermission: false }, + ]) + + expect(state).toEqual({ step: 'idle', error: null }) + expect('pending' in state).toBe(false) + }) + + it('treats a cancel as no failure at all — nothing to surface', () => { + const state = run([ + TAP_SIGN_IN, + { type: 'SIGN_IN_DONE', signedIn: false, hasPermission: false }, + ]) + + expect(state.step === 'idle' && state.error).toBeNull() + }) +}) + +describe('the signed-in-without-permission branch', () => { + it('opens the consent prompt on tap', () => { + expect(run([TAP_CONSENT])).toEqual({ step: 'confirming', pending, retried: false }) + }) + + it('reaches applying with the original pending highlight intact', () => { + const state = run([ + TAP_CONSENT, + { type: 'CONFIRM' }, + { type: 'GRANT_RESULT', outcome: granted() }, + ]) + + expect(state).toEqual({ step: 'applying', pending, retried: false }) + expect(state.step === 'applying' && state.pending).toBe(pending) + }) + + it('discards the pending highlight on decline', () => { + const state = run([TAP_CONSENT, { type: 'DECLINE' }]) + + expect(state).toEqual({ step: 'idle', error: null }) + expect('pending' in state).toBe(false) + }) + + it('discards the pending highlight when the grant is cancelled, without an error', () => { + const state = run([ + TAP_CONSENT, + { type: 'CONFIRM' }, + { type: 'GRANT_RESULT', outcome: { status: 'cancel' } }, + ]) + + expect(state).toEqual({ step: 'idle', error: null }) + }) + + it('surfaces a grant failure and reverts to idle', () => { + const state = run([ + TAP_CONSENT, + { type: 'CONFIRM' }, + { + type: 'GRANT_RESULT', + outcome: { status: 'failure', reason: 'not-permitted', message: 'nope' }, + }, + ]) + + expect(state).toEqual({ + step: 'idle', + error: { reason: 'not-permitted', message: 'nope' }, + }) + expect('pending' in state).toBe(false) + }) + + it('does not apply when the grant came back without the highlights permission', () => { + const state = run([ + TAP_CONSENT, + { type: 'CONFIRM' }, + { type: 'GRANT_RESULT', outcome: granted(['bibles']) }, + ]) + + expect(state.step).toBe('idle') + expect(state.step === 'idle' && state.error?.reason).toBe('not-permitted') + }) +}) + +// ── The corrective path ────────────────────────────────────────────────────── + +describe('the stale-grant corrective path', () => { + it('re-prompts once on an auth refusal, replaying only the verses that failed', () => { + const state = run([{ type: 'AUTH_RETRY', pending }], { + step: 'applying', + pending, + retried: false, + }) + // AUTH_RETRY is an *entry* event and is ignored mid-flow — the applying state + // gets there through APPLY_RESULT instead. + expect(state).toEqual({ step: 'applying', pending, retried: false }) + + const reprompted = permissionFlowReducer( + { step: 'applying', pending, retried: false }, + { type: 'APPLY_RESULT', outcome: authWriteError([17]) }, + ) + + expect(reprompted).toEqual({ + step: 'confirming', + pending: { color: YELLOW, verses: [17], scope }, + retried: true, + }) + }) + + it('enters at confirming with retried already set when a fast-path write is refused', () => { + expect(run([{ type: 'AUTH_RETRY', pending }])).toEqual({ + step: 'confirming', + pending, + retried: true, + }) + }) + + it('goes terminal on a second auth refusal instead of re-prompting again', () => { + const state = run( + [ + { type: 'CONFIRM' }, + { type: 'GRANT_RESULT', outcome: granted() }, + { type: 'APPLY_RESULT', outcome: authWriteError([17], 'still unauthorized') }, + ], + { step: 'confirming', pending, retried: true }, + ) + + expect(state).toEqual({ + step: 'idle', + error: { reason: 'auth', message: 'still unauthorized' }, + }) + }) + + it('carries `retried` across confirm and grant so the bound cannot be laundered', () => { + const state = run([{ type: 'CONFIRM' }, { type: 'GRANT_RESULT', outcome: granted() }], { + step: 'confirming', + pending, + retried: true, + }) + + expect(state).toEqual({ step: 'applying', pending, retried: true }) + }) + + it('goes terminal rather than re-prompting when an auth refusal names no verses', () => { + const state = permissionFlowReducer( + { step: 'applying', pending, retried: false }, + { type: 'APPLY_RESULT', outcome: authWriteError([]) }, + ) + + expect(state).toEqual({ step: 'idle', error: { reason: 'auth', message: 'unauthorized' } }) + }) + + it.each([ + ['ok', { status: 'ok', verses: [16, 17] } as HighlightWriteOutcome], + ['noop', { status: 'noop' } as HighlightWriteOutcome], + [ + 'a non-auth error', + { + status: 'error', + reason: 'transient', + message: 'boom', + failedVerses: [16], + succeededVerses: [], + } as HighlightWriteOutcome, + ], + ])('ends the flow cleanly on %s, leaving the write to report itself', (_label, outcome) => { + const state = permissionFlowReducer( + { step: 'applying', pending, retried: false }, + { type: 'APPLY_RESULT', outcome }, + ) + + expect(state).toEqual({ step: 'idle', error: null }) + }) +}) + +// ── RESET ──────────────────────────────────────────────────────────────────── + +describe('RESET', () => { + const steps: PermissionFlowState[] = [ + { step: 'signing-in', pending }, + { step: 'confirming', pending, retried: false }, + { step: 'granting', pending, retried: true }, + { step: 'applying', pending, retried: false }, + ] + + it.each(steps.map((s) => [s.step, s] as const))( + 'discards a flow in progress from %s', + (_step, state) => { + expect(permissionFlowReducer(state, { type: 'RESET' })).toEqual({ + step: 'idle', + error: null, + }) + }, + ) + + it('clears a terminal error, so a failure cannot outlive the chapter it happened in', () => { + const state = permissionFlowReducer( + { step: 'idle', error: { reason: 'transient', message: 'boom' } }, + { type: 'RESET' }, + ) + + expect(state).toEqual({ step: 'idle', error: null }) + }) + + it('is identity-stable when already clean, so React can skip the re-render', () => { + expect(permissionFlowReducer(initialPermissionFlowState, { type: 'RESET' })).toBe( + initialPermissionFlowState, + ) + }) +}) + +// ── Stale and invalid events ───────────────────────────────────────────────── + +describe('events invalid for the current step', () => { + const states: PermissionFlowState[] = [ + initialPermissionFlowState, + { step: 'signing-in', pending }, + { step: 'confirming', pending, retried: false }, + { step: 'granting', pending, retried: false }, + { step: 'applying', pending, retried: false }, + ] + + const events: PermissionFlowEvent[] = [ + TAP_SIGN_IN, + TAP_CONSENT, + { type: 'AUTH_RETRY', pending }, + { type: 'SIGN_IN_DONE', signedIn: true, hasPermission: true }, + { type: 'CONFIRM' }, + { type: 'DECLINE' }, + { type: 'GRANT_RESULT', outcome: granted() }, + { type: 'APPLY_RESULT', outcome: { status: 'ok', verses: [16, 17] } }, + ] + + /** The only event each step is willing to hear (RESET aside, which is universal). */ + const validFor: Record = { + idle: ['TAP', 'AUTH_RETRY'], + 'signing-in': ['SIGN_IN_DONE'], + confirming: ['CONFIRM', 'DECLINE'], + granting: ['GRANT_RESULT'], + applying: ['APPLY_RESULT'], + } + + for (const state of states) { + for (const event of events) { + if (validFor[state.step].includes(event.type)) { + continue + } + const label = event.type === 'TAP' ? `TAP(${event.branch})` : event.type + it(`ignores ${label} in ${state.step}`, () => { + expect(permissionFlowReducer(state, event)).toBe(state) + }) + } + } + + it('cannot be resurrected by a browser result that lands after a RESET', () => { + // The exact sequence a real cancel-then-return produces: the reader moved on + // while the consent page was open, and the grant arrives to an empty flow. + const state = run([ + TAP_CONSENT, + { type: 'CONFIRM' }, + { type: 'RESET' }, + { type: 'GRANT_RESULT', outcome: granted() }, + ]) + + expect(state).toEqual({ step: 'idle', error: null }) + expect('pending' in state).toBe(false) + }) + + it('cannot be resurrected by a sign-in that lands after a RESET', () => { + const state = run([ + TAP_SIGN_IN, + { type: 'RESET' }, + { type: 'SIGN_IN_DONE', signedIn: true, hasPermission: true }, + ]) + + expect(state).toEqual({ step: 'idle', error: null }) + }) + + it('ignores an event it does not recognise rather than corrupting the flow', () => { + const state: PermissionFlowState = { step: 'granting', pending, retried: false } + // Reachable from untyped callers and from a hot reload mid-flow. + const bogus = { type: 'SOMETHING_ELSE' } as unknown as PermissionFlowEvent + + expect(permissionFlowReducer(state, bogus)).toBe(state) + }) +}) + +// ── Reason mapping ─────────────────────────────────────────────────────────── + +describe('toWriteReason', () => { + it.each([ + ['not-signed-in', 'not-signed-in'], + ['auth', 'auth'], + ['not-permitted', 'invalid'], + ['user-changed', 'transient'], + ['in-progress', 'transient'], + ['transient', 'transient'], + ] as const)('maps %s to %s', (from, to) => { + expect(toWriteReason(from)).toBe(to) + }) +}) diff --git a/packages/core/src/highlights/__tests__/use-highlight-permission-flow.test.tsx b/packages/core/src/highlights/__tests__/use-highlight-permission-flow.test.tsx new file mode 100644 index 00000000..27f00380 --- /dev/null +++ b/packages/core/src/highlights/__tests__/use-highlight-permission-flow.test.tsx @@ -0,0 +1,782 @@ +import { act, renderHook } from '@testing-library/react-native' +import { useEffect, useState, type ReactNode } from 'react' + +import type { AuthPermission, DataExchangeOutcome } from '../../auth' +import { AuthContext, type AuthContextValue } from '../../auth/auth-context' +import { + useHighlightPermissionFlow, + type UseHighlightPermissionFlowResult, +} from '../use-highlight-permission-flow' +import type { + HighlightWriteOutcome, + UseHighlightsOptions, + UseHighlightsResult, +} from '../use-highlights' + +// ── Boundaries ─────────────────────────────────────────────────────────────── +// `useHighlights` is mocked wholesale: this hook composes it and must not change +// it, so the only thing worth asserting is what crosses between them. + +const mockWriteApply = jest.fn, [string, number[]]>() +const mockRemove = jest.fn, [string, number[]]>() +const mockRefresh = jest.fn, []>() + +// Built inside the factory, and every name it closes over is `mock`-prefixed: +// babel-plugin-jest-hoist lifts `jest.mock` above the module body, so an object +// assembled at module scope would capture these before they are initialized. +jest.mock('../use-highlights', () => ({ + useHighlights: jest.fn( + (options: UseHighlightsOptions): UseHighlightsResult => ({ + highlights: [], + // Follows the options it was rendered with, exactly as the real hook does. + // A fixed scope here would let a pending highlight replay into a chapter + // the reader has left without any test noticing. + scope: { versionId: options.versionId, book: options.book, chapter: options.chapter }, + isRefreshing: false, + error: null, + refresh: mockRefresh, + apply: mockWriteApply, + remove: mockRemove, + }), + ), +})) + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const YELLOW = 'fffe00' +const GREEN = '5dff79' +const options: UseHighlightsOptions = { versionId: 111, book: 'JHN', chapter: '3' } + +/** Ordered log of the pre-flight, so "before" can be asserted rather than assumed. */ +let calls: string[] = [] + +const mockSignIn = jest.fn, []>() +const mockRequestPermissions = jest.fn, [readonly AuthPermission[]]>() +const mockInvalidatePermissions = jest.fn() +const mockEnsureFreshToken = jest.fn, []>() + +type AuthState = { signedIn: boolean; permissions: string[] } + +/** `null` models a provider with no `auth` configured at all. */ +let currentAuth: AuthState | null = null + +const listeners = new Set<() => void>() + +/** + * Move the auth context and re-render the provider, the way `AuthProvider` does + * when its own state changes. Anything that only mutated the module variable + * would let a hook holding a stale context object pass. + */ +function setAuth(next: AuthState | null): void { + currentAuth = next + for (const listener of listeners) { + listener() + } +} + +function authValue(state: AuthState): AuthContextValue { + return { + isAuthenticated: state.signedIn, + accessToken: state.signedIn ? 'token-1' : null, + userInfo: state.signedIn ? { id: 'user-1' } : null, + error: null, + signIn: mockSignIn, + signOut: jest.fn(), + refreshNow: jest.fn(), + ensureFreshToken: mockEnsureFreshToken, + isLoading: false, + grantedPermissions: state.permissions, + hasPermission: (permission) => { + calls.push(`hasPermission:${permission}`) + return state.permissions.includes(permission) + }, + invalidatePermissions: mockInvalidatePermissions, + requestPermissions: mockRequestPermissions, + } +} + +function Wrapper({ children }: { children: ReactNode }) { + const [, force] = useState(0) + useEffect(() => { + const listener = () => force((n) => n + 1) + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, []) + + // A fresh context value per render, matching AuthProvider's useMemo when its + // inputs change. `null` is supplied through the provider rather than by dropping + // it from the tree: `use(AuthContext)` sees the same `null` either way, and + // removing the element would remount the subtree — losing the flow under test + // for reasons that have nothing to do with auth. + return ( + + {children} + + ) +} + +function renderFlow(initialProps: UseHighlightsOptions = options) { + return renderHook((props: UseHighlightsOptions) => useHighlightPermissionFlow(props), { + wrapper: Wrapper, + initialProps, + }) +} + +type FlowResult = { current: UseHighlightPermissionFlowResult } + +/** + * Start an apply and hand back its promise **unawaited** — most of these flows + * are still waiting on the user when this returns, so awaiting here would hang. + */ +async function startApply( + result: FlowResult, + color = YELLOW, + verses = [16], +): Promise<{ promise: Promise }> { + let promise: Promise | undefined + await act(async () => { + promise = result.current.apply(color, verses) + }) + if (promise === undefined) { + throw new Error('apply() did not return a promise') + } + return { promise } +} + +type Deferred = { promise: Promise; resolve: (value: T) => void } + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +const authWriteError = (failedVerses: number[]): HighlightWriteOutcome => ({ + status: 'error', + reason: 'auth', + message: 'unauthorized', + failedVerses, + succeededVerses: [], +}) + +const grantedOutcome: DataExchangeOutcome = { + status: 'granted', + grantedPermissions: ['highlights'], +} + +const signedInWithGrant: AuthState = { signedIn: true, permissions: ['highlights'] } +const signedInNoGrant: AuthState = { signedIn: true, permissions: [] } +const signedOut: AuthState = { signedIn: false, permissions: [] } + +beforeEach(() => { + jest.clearAllMocks() + mockWriteApply.mockReset() + mockRequestPermissions.mockReset() + mockSignIn.mockReset() + mockEnsureFreshToken.mockReset() + + calls = [] + currentAuth = signedInWithGrant + + mockWriteApply.mockImplementation(async (_color, verses) => { + calls.push('apply') + return { status: 'ok', verses } + }) + // Default: the user dismisses the browser. `signIn` resolves on cancel too, so + // a cancel is indistinguishable from success until auth state is re-read — + // which is the whole reason the flow re-reads it. + mockSignIn.mockImplementation(async () => undefined) + mockRequestPermissions.mockResolvedValue({ status: 'cancel' }) + mockEnsureFreshToken.mockImplementation(async () => { + calls.push('ensureFreshToken') + }) +}) + +// ── The pre-flight ─────────────────────────────────────────────────────────── + +describe('pre-flight', () => { + it('writes straight through when the permission is already granted', async () => { + const { result } = renderFlow() + const { promise } = await startApply(result) + + await expect(promise).resolves.toEqual({ status: 'ok', verses: [16] }) + expect(mockWriteApply).toHaveBeenCalledWith(YELLOW, [16]) + expect(mockSignIn).not.toHaveBeenCalled() + expect(mockRequestPermissions).not.toHaveBeenCalled() + expect(result.current.isConfirming).toBe(false) + expect(result.current.flowError).toBeNull() + }) + + it('reaches the write without awaiting anything first', async () => { + const { result } = renderFlow() + + let promise: Promise | undefined + act(() => { + promise = result.current.apply(YELLOW, [16]) + }) + + // Asserted with no `await` in between. If `apply` awaited anything before + // branching, the write would not have gone out yet — and since the + // optimistic paint lives inside it, the user would be looking at unpainted + // text until that await resolved. The token refresh the write needs runs + // inside `useHighlights.runWrite` instead, behind the claim (ADR 0016). + expect(calls).toEqual(['hasPermission:highlights', 'apply']) + expect(mockEnsureFreshToken).not.toHaveBeenCalled() + + await act(async () => { + await promise + }) + }) + + it('leaves remove alone — a user with visible highlights already has the grant', () => { + const { result } = renderFlow() + + expect(result.current.highlights.remove).toBe(mockRemove) + expect(result.current.highlights.refresh).toBe(mockRefresh) + }) +}) + +// ── Branch 1: not signed in ────────────────────────────────────────────────── + +describe('the not-signed-in branch', () => { + beforeEach(() => { + currentAuth = signedOut + }) + + it('signs in, then applies without asking for consent when sign-in granted it', async () => { + mockSignIn.mockImplementation(async () => { + setAuth(signedInWithGrant) + }) + + const { result } = renderFlow() + const { promise } = await startApply(result) + + await expect(promise).resolves.toEqual({ status: 'ok', verses: [16] }) + expect(mockSignIn).toHaveBeenCalledTimes(1) + // The grant rides on the OAuth redirect, so asking again would ask twice. + expect(mockRequestPermissions).not.toHaveBeenCalled() + expect(mockWriteApply).toHaveBeenCalledWith(YELLOW, [16]) + }) + + it('falls through to consent when sign-in did not grant the permission', async () => { + mockSignIn.mockImplementation(async () => { + setAuth(signedInNoGrant) + }) + mockRequestPermissions.mockResolvedValue(grantedOutcome) + + const { result } = renderFlow() + const { promise } = await startApply(result) + + expect(result.current.isConfirming).toBe(true) + expect(mockWriteApply).not.toHaveBeenCalled() + + await act(async () => { + result.current.confirm() + }) + + await expect(promise).resolves.toEqual({ status: 'ok', verses: [16] }) + expect(mockRequestPermissions).toHaveBeenCalledWith(['highlights']) + }) + + it('discards the highlight when sign-in is cancelled, and reports nothing', async () => { + const { result } = renderFlow() + const { promise } = await startApply(result) + + await expect(promise).resolves.toEqual({ status: 'noop' }) + expect(mockWriteApply).not.toHaveBeenCalled() + expect(result.current.isConfirming).toBe(false) + expect(result.current.flowError).toBeNull() + }) + + it('discards the highlight when sign-in throws', async () => { + mockSignIn.mockRejectedValue(new Error('token endpoint exploded')) + + const { result } = renderFlow() + const { promise } = await startApply(result) + + // The rejection stays on the auth context's `error`; a caller who asked for a + // highlight is not handed somebody else's throw. + await expect(promise).resolves.toEqual({ status: 'noop' }) + expect(mockWriteApply).not.toHaveBeenCalled() + }) +}) + +// ── Branch 2: signed in without the permission ─────────────────────────────── + +describe('the signed-in-without-permission branch', () => { + beforeEach(() => { + currentAuth = signedInNoGrant + }) + + it('asks for consent, then grants and applies', async () => { + mockRequestPermissions.mockResolvedValue(grantedOutcome) + + const { result } = renderFlow() + const { promise } = await startApply(result) + + expect(result.current.isConfirming).toBe(true) + expect(mockRequestPermissions).not.toHaveBeenCalled() + expect(mockWriteApply).not.toHaveBeenCalled() + + await act(async () => { + result.current.confirm() + }) + + await expect(promise).resolves.toEqual({ status: 'ok', verses: [16] }) + expect(mockRequestPermissions).toHaveBeenCalledWith(['highlights']) + expect(mockWriteApply).toHaveBeenCalledWith(YELLOW, [16]) + expect(result.current.isConfirming).toBe(false) + }) + + it('never writes when the user declines', async () => { + const { result } = renderFlow() + const { promise } = await startApply(result) + + await act(async () => { + result.current.decline() + }) + + await expect(promise).resolves.toEqual({ status: 'noop' }) + expect(mockRequestPermissions).not.toHaveBeenCalled() + expect(mockWriteApply).not.toHaveBeenCalled() + expect(result.current.isConfirming).toBe(false) + // A decline is a choice, not a failure — nothing to toast. + expect(result.current.flowError).toBeNull() + }) + + it('discards the highlight when the consent page is cancelled', async () => { + mockRequestPermissions.mockResolvedValue({ status: 'cancel' }) + + const { result } = renderFlow() + const { promise } = await startApply(result) + await act(async () => { + result.current.confirm() + }) + + await expect(promise).resolves.toEqual({ status: 'noop' }) + expect(mockWriteApply).not.toHaveBeenCalled() + expect(result.current.flowError).toBeNull() + }) + + it('surfaces a failed grant and does not write', async () => { + mockRequestPermissions.mockResolvedValue({ + status: 'failure', + reason: 'not-permitted', + message: 'this app key is not enabled for data exchange', + }) + + const { result } = renderFlow() + const { promise } = await startApply(result) + await act(async () => { + result.current.confirm() + }) + + // `not-permitted` is a configuration fact, so it reports as `invalid` rather + // than inviting a retry. + await expect(promise).resolves.toEqual({ + status: 'error', + reason: 'invalid', + message: 'this app key is not enabled for data exchange', + failedVerses: [16], + succeededVerses: [], + }) + expect(result.current.flowError).toEqual({ + reason: 'not-permitted', + message: 'this app key is not enabled for data exchange', + }) + expect(mockWriteApply).not.toHaveBeenCalled() + }) + + it('does not write when the grant came back without the highlights permission', async () => { + mockRequestPermissions.mockResolvedValue({ status: 'granted', grantedPermissions: ['bibles'] }) + + const { result } = renderFlow() + const { promise } = await startApply(result) + await act(async () => { + result.current.confirm() + }) + + await expect(promise).resolves.toMatchObject({ status: 'error', reason: 'invalid' }) + expect(mockWriteApply).not.toHaveBeenCalled() + }) + + it('ignores confirm and decline when no prompt is open', async () => { + const { result } = renderFlow() + + await act(async () => { + result.current.confirm() + result.current.decline() + }) + + expect(mockRequestPermissions).not.toHaveBeenCalled() + expect(result.current.isConfirming).toBe(false) + }) + + it('refuses an overlapping tap rather than opening a second browser session', async () => { + const { result } = renderFlow() + const first = await startApply(result, YELLOW, [16]) + expect(result.current.isConfirming).toBe(true) + + const second = await startApply(result, GREEN, [17]) + + await expect(second.promise).resolves.toEqual({ + status: 'error', + reason: 'transient', + message: expect.stringContaining('already in progress'), + failedVerses: [17], + succeededVerses: [], + }) + // The first tap is untouched — it is still the highlight the user is waiting on. + expect(result.current.isConfirming).toBe(true) + + await act(async () => { + result.current.decline() + }) + await first.promise + }) +}) + +// ── The corrective path ────────────────────────────────────────────────────── + +describe('a stale cached grant', () => { + it('invalidates the grant and re-prompts once, then applies', async () => { + mockWriteApply + .mockResolvedValueOnce(authWriteError([16])) + .mockResolvedValueOnce({ status: 'ok', verses: [16] }) + mockRequestPermissions.mockResolvedValue(grantedOutcome) + + const { result } = renderFlow() + const { promise } = await startApply(result) + + // The cache said yes and the server said no, so the cache is wrong — keeping + // it would make the next pre-flight read the same wrong answer. + expect(mockInvalidatePermissions).toHaveBeenCalledTimes(1) + expect(result.current.isConfirming).toBe(true) + + await act(async () => { + result.current.confirm() + }) + + await expect(promise).resolves.toEqual({ status: 'ok', verses: [16] }) + expect(mockRequestPermissions).toHaveBeenCalledTimes(1) + expect(mockWriteApply).toHaveBeenCalledTimes(2) + }) + + it('replays only the verses that failed', async () => { + mockWriteApply + .mockResolvedValueOnce({ + status: 'error', + reason: 'auth', + message: 'unauthorized', + failedVerses: [17], + succeededVerses: [16], + }) + .mockResolvedValueOnce({ status: 'ok', verses: [17] }) + mockRequestPermissions.mockResolvedValue(grantedOutcome) + + const { result } = renderFlow() + const { promise } = await startApply(result, YELLOW, [16, 17]) + await act(async () => { + result.current.confirm() + }) + await promise + + expect(mockWriteApply).toHaveBeenNthCalledWith(1, YELLOW, [16, 17]) + expect(mockWriteApply).toHaveBeenNthCalledWith(2, YELLOW, [17]) + }) + + it('gives up after one re-prompt instead of looping the consent page', async () => { + mockWriteApply.mockResolvedValue(authWriteError([16])) + mockRequestPermissions.mockResolvedValue(grantedOutcome) + + const { result } = renderFlow() + const { promise } = await startApply(result) + await act(async () => { + result.current.confirm() + }) + + await expect(promise).resolves.toMatchObject({ status: 'error', reason: 'auth' }) + expect(mockRequestPermissions).toHaveBeenCalledTimes(1) + expect(mockWriteApply).toHaveBeenCalledTimes(2) + expect(mockInvalidatePermissions).toHaveBeenCalledTimes(1) + expect(result.current.isConfirming).toBe(false) + expect(result.current.flowError).toEqual({ reason: 'auth', message: 'unauthorized' }) + }) + + it('re-prompts once when the write the CONSENT branch resumed is itself refused', async () => { + // The grant landed and the server still said no — the same corrective edge as + // the fast path, but reached from a resumed pending highlight. + currentAuth = signedInNoGrant + mockRequestPermissions.mockResolvedValue(grantedOutcome) + mockWriteApply + .mockResolvedValueOnce(authWriteError([16])) + .mockResolvedValueOnce({ status: 'ok', verses: [16] }) + + const { result } = renderFlow() + const { promise } = await startApply(result) + await act(async () => { + result.current.confirm() + }) + + expect(mockInvalidatePermissions).toHaveBeenCalledTimes(1) + expect(result.current.isConfirming).toBe(true) + + await act(async () => { + result.current.confirm() + }) + + await expect(promise).resolves.toEqual({ status: 'ok', verses: [16] }) + expect(mockRequestPermissions).toHaveBeenCalledTimes(2) + expect(mockWriteApply).toHaveBeenCalledTimes(2) + }) + + it('reports a non-auth write failure as itself, with no prompt', async () => { + mockWriteApply.mockResolvedValue({ + status: 'error', + reason: 'transient', + message: 'boom', + failedVerses: [16], + succeededVerses: [], + }) + + const { result } = renderFlow() + const { promise } = await startApply(result) + + await expect(promise).resolves.toMatchObject({ status: 'error', reason: 'transient' }) + expect(mockInvalidatePermissions).not.toHaveBeenCalled() + expect(result.current.isConfirming).toBe(false) + expect(result.current.flowError).toBeNull() + }) + + it('does not re-prompt for an auth refusal that names no verses', async () => { + mockWriteApply.mockResolvedValue(authWriteError([])) + + const { result } = renderFlow() + const { promise } = await startApply(result) + + await expect(promise).resolves.toMatchObject({ status: 'error', reason: 'auth' }) + expect(mockInvalidatePermissions).not.toHaveBeenCalled() + expect(result.current.isConfirming).toBe(false) + }) +}) + +// ── Scope changes and unmount ──────────────────────────────────────────────── + +describe('when the reader moves on', () => { + it('discards the flow, and a late grant cannot resurrect the highlight', async () => { + currentAuth = signedInNoGrant + const grant = deferred() + mockRequestPermissions.mockReturnValue(grant.promise) + + const { result, rerender } = renderFlow() + const { promise } = await startApply(result) + await act(async () => { + result.current.confirm() + }) + expect(mockRequestPermissions).toHaveBeenCalledTimes(1) + + await act(async () => { + rerender({ ...options, chapter: '4' }) + }) + + expect(result.current.isConfirming).toBe(false) + await expect(promise).resolves.toEqual({ status: 'noop' }) + + // The consent page returns to a flow that no longer exists. + await act(async () => { + grant.resolve(grantedOutcome) + }) + + expect(mockWriteApply).not.toHaveBeenCalled() + expect(result.current.isConfirming).toBe(false) + }) + + it('clears a terminal error so it cannot outlive the chapter it happened in', async () => { + currentAuth = signedInNoGrant + mockRequestPermissions.mockResolvedValue({ + status: 'failure', + reason: 'transient', + message: 'boom', + }) + + const { result, rerender } = renderFlow() + const { promise } = await startApply(result) + await act(async () => { + result.current.confirm() + }) + await promise + expect(result.current.flowError).not.toBeNull() + + await act(async () => { + rerender({ ...options, chapter: '4' }) + }) + + expect(result.current.flowError).toBeNull() + }) + + it('drops a sign-in that returns after the reader moved on', async () => { + currentAuth = signedOut + const browser = deferred() + mockSignIn.mockImplementation(() => browser.promise) + + const { result, rerender } = renderFlow() + const { promise } = await startApply(result) + expect(mockSignIn).toHaveBeenCalledTimes(1) + + await act(async () => { + rerender({ ...options, chapter: '4' }) + }) + await expect(promise).resolves.toEqual({ status: 'noop' }) + + // Sign-in succeeds *after* the flow was discarded. The highlight belonged to a + // chapter the user has left, so it must not be applied to the one they are on. + await act(async () => { + setAuth(signedInWithGrant) + browser.resolve() + }) + + expect(mockWriteApply).not.toHaveBeenCalled() + expect(result.current.isConfirming).toBe(false) + }) + + it('drops a write that settles after the reader moved on', async () => { + currentAuth = signedInNoGrant + mockRequestPermissions.mockResolvedValue(grantedOutcome) + const write = deferred() + mockWriteApply.mockReturnValue(write.promise) + + const { result, rerender } = renderFlow() + const { promise } = await startApply(result) + await act(async () => { + result.current.confirm() + }) + expect(mockWriteApply).toHaveBeenCalledTimes(1) + + await act(async () => { + rerender({ ...options, chapter: '4' }) + }) + await expect(promise).resolves.toEqual({ status: 'noop' }) + + // The write itself is left to land — `useHighlights` owns that — but it can no + // longer drive a flow that no longer exists. + await act(async () => { + write.resolve(authWriteError([16])) + }) + + expect(mockInvalidatePermissions).not.toHaveBeenCalled() + expect(result.current.isConfirming).toBe(false) + }) + + it('does not re-prompt for a write that was refused after the reader moved on', async () => { + // The permission is cached, so this takes the straight-through path and no + // flow exists yet while the write is out. Nothing has a caller to abandon, + // so the render-time RESET cannot help here — only the claimed scope can. + currentAuth = signedInWithGrant + mockRequestPermissions.mockResolvedValue(grantedOutcome) + const write = deferred() + mockWriteApply.mockReturnValue(write.promise) + + const { result, rerender } = renderFlow() + const { promise } = await startApply(result, YELLOW, [16, 17, 18]) + + await act(async () => { + rerender({ ...options, chapter: '4' }) + }) + await act(async () => { + write.resolve(authWriteError([16, 17, 18])) + }) + + // The grant is demonstrably wrong either way, so dropping it is still right. + expect(mockInvalidatePermissions).toHaveBeenCalledTimes(1) + + // But verses 16-18 belong to JHN.3. Re-prompting here would end in a write + // through JHN.4's hook, painting text the user never selected. + expect(result.current.isConfirming).toBe(false) + expect(mockRequestPermissions).not.toHaveBeenCalled() + expect(mockWriteApply).toHaveBeenCalledTimes(1) + await expect(promise).resolves.toEqual(authWriteError([16, 17, 18])) + }) + + // There used to be a third window here: the reader moving while the + // pre-flight refresh was out. That refresh moved into + // `useHighlights.runWrite` (ADR 0016), so `apply` no longer awaits anything + // before it branches and the window it guarded cannot open. The test above, + // "reaches the write without awaiting anything first", is what keeps it shut. + + it('settles a waiting caller on unmount instead of leaving the promise pending', async () => { + currentAuth = signedInNoGrant + + const { result, unmount } = renderFlow() + const { promise } = await startApply(result) + expect(result.current.isConfirming).toBe(true) + + await act(async () => { + unmount() + }) + + await expect(promise).resolves.toEqual({ status: 'noop' }) + }) +}) + +describe('when the provider loses its auth config mid-flow', () => { + it('reports not-signed-in rather than trying to mint a grant', async () => { + currentAuth = signedInNoGrant + + const { result } = renderFlow() + const { promise } = await startApply(result) + expect(result.current.isConfirming).toBe(true) + + await act(async () => { + setAuth(null) + }) + await act(async () => { + result.current.confirm() + }) + + await expect(promise).resolves.toMatchObject({ + status: 'error', + reason: 'not-signed-in', + failedVerses: [16], + }) + expect(mockRequestPermissions).not.toHaveBeenCalled() + expect(result.current.isConfirming).toBe(false) + }) +}) + +// ── Misconfiguration ───────────────────────────────────────────────────────── +// Must stay the only block that runs without `auth`: the warning fires once per +// module instance, by design. + +describe('with no auth configured', () => { + it('warns once and behaves exactly as signed out', async () => { + currentAuth = null + const notSignedIn: HighlightWriteOutcome = { + status: 'error', + reason: 'not-signed-in', + message: 'Not signed in', + failedVerses: [16], + succeededVerses: [], + } + mockWriteApply.mockResolvedValue(notSignedIn) + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + + const { result } = renderFlow() + const first = await startApply(result) + await expect(first.promise).resolves.toEqual(notSignedIn) + const second = await startApply(result) + await second.promise + + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[0]).toContain('needs `auth` configured') + // Passed straight through both times — no prompt, no flow, no sign-in. + expect(mockWriteApply).toHaveBeenCalledTimes(2) + expect(mockEnsureFreshToken).not.toHaveBeenCalled() + expect(result.current.isConfirming).toBe(false) + + warn.mockRestore() + }) +}) diff --git a/packages/core/src/highlights/__tests__/use-highlights.test.tsx b/packages/core/src/highlights/__tests__/use-highlights.test.tsx index e52b7a23..e1ccbabe 100644 --- a/packages/core/src/highlights/__tests__/use-highlights.test.tsx +++ b/packages/core/src/highlights/__tests__/use-highlights.test.tsx @@ -107,6 +107,10 @@ const tokenLoading: AuthShape = { const refreshNow = jest.fn(async () => undefined) +// Hoisted rather than built per render, so a test can assert on it and can gate +// it on a deferred promise. +const ensureFreshToken = jest.fn(async () => undefined) + function authValue(overrides: Partial): AuthContextValue { return { isAuthenticated: false, @@ -116,6 +120,7 @@ function authValue(overrides: Partial): AuthContextValue { signIn: jest.fn(async () => undefined), signOut: jest.fn(async () => undefined), refreshNow, + ensureFreshToken, isLoading: false, grantedPermissions: null, hasPermission: jest.fn(() => false), @@ -195,6 +200,8 @@ beforeEach(() => { mockGetHighlights.mockReset() mockCreateHighlight.mockReset() mockDeleteHighlight.mockReset() + ensureFreshToken.mockReset() + ensureFreshToken.mockResolvedValue(undefined) mockGetHighlights.mockResolvedValue(collection([])) mockCreateHighlight.mockResolvedValue({ ok: true, value: highlight('JHN.3.16', YELLOW) }) mockDeleteHighlight.mockResolvedValue({ ok: true, value: undefined }) @@ -485,6 +492,38 @@ describe('apply', () => { expect(readCache()).toEqual([highlight('JHN.3.16', YELLOW), highlight('JHN.3.17', YELLOW)]) }) + it('refreshes the token after the paint and before the POST', async () => { + const refresh = deferred() + ensureFreshToken.mockReturnValueOnce(refresh.promise) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let outcome: Promise | undefined + act(() => { + outcome = result.current.apply(YELLOW, [16]) + }) + + // Painted while the refresh is still out. A refresh in front of the claim + // would leave the verse unpainted for a whole token round-trip every time + // one was due, which is the whole reason it lives here (ADR 0016). + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + expect(mockCreateHighlight).not.toHaveBeenCalled() + + await act(async () => { + refresh.resolve(undefined) + await outcome + }) + + // And the POST did wait for it. An expired token 401s, a 401 classifies as + // `auth`, and `useHighlightPermissionFlow` reads `auth` as a stale grant — + // so the user would be asked to grant a permission they already granted. + expect(mockCreateHighlight).toHaveBeenCalledTimes(1) + expect(ensureFreshToken).toHaveBeenCalledTimes(1) + }) + it('collapses contiguous verses into one ranged POST per run', async () => { const { result } = renderUseHighlights() await act(async () => { diff --git a/packages/core/src/highlights/constants.ts b/packages/core/src/highlights/constants.ts index 8270b244..571f187c 100644 --- a/packages/core/src/highlights/constants.ts +++ b/packages/core/src/highlights/constants.ts @@ -30,6 +30,14 @@ export type HighlightScope = { export type ServerColors = Record +/** + * One copy of the message, shared by the write path and the permission flow that + * wraps it. Both can report the same refusal, and two drifting copies of a + * user-facing string is a bug waiting for a translator. + */ +export const NOT_SIGNED_IN_MESSAGE = + 'Not signed in — highlights require an authenticated YouVersion user.' + export function highlightsCacheKey(userId: string, scope: HighlightScope): string { return `${MMKV_HIGHLIGHTS_KEY_PREFIX}${userId}.${scope.versionId}.${scope.book}.${scope.chapter}` } diff --git a/packages/core/src/highlights/index.ts b/packages/core/src/highlights/index.ts index f3fdda19..7238d42b 100644 --- a/packages/core/src/highlights/index.ts +++ b/packages/core/src/highlights/index.ts @@ -26,6 +26,15 @@ export { export { HIGHLIGHT_COLORS, isHighlightColor, type HighlightColor } from './constants' +// The reducer, its events, and `PendingHighlight` stay internal — the flow's +// public surface is the hook plus what a caller has to render or report. +export type { PermissionFlowError, PermissionFlowErrorReason } from './permission-flow' + +export { + useHighlightPermissionFlow, + type UseHighlightPermissionFlowResult, +} from './use-highlight-permission-flow' + export { useHighlights, type HighlightsFetchError, diff --git a/packages/core/src/highlights/permission-flow.ts b/packages/core/src/highlights/permission-flow.ts new file mode 100644 index 00000000..e7f63eb4 --- /dev/null +++ b/packages/core/src/highlights/permission-flow.ts @@ -0,0 +1,239 @@ +import type { AuthPermission, DataExchangeFailureReason, DataExchangeOutcome } from '../auth' +import type { HighlightScope } from './constants' +import type { HighlightWriteOutcome, HighlightWriteReason } from './use-highlights' + +/** + * The one permission this flow exists to obtain. Named so the reducer, the hook, + * and the request it issues cannot drift apart on spelling. + */ +export const HIGHLIGHTS_PERMISSION: AuthPermission = 'highlights' + +/** + * A highlight the user asked for before they were allowed to make it — held + * while sign-in and/or consent runs, then applied. + * + * In-memory only, by design: `openAuthSessionAsync` returns to the same live + * process, so web's `sessionStorage` stash and its TTL solve a problem native + * does not have. `scope` travels with it as identity — the chapter the intent was + * formed in — and a scope change discards the whole flow (`RESET`) rather than + * replaying the highlight somewhere the user is no longer looking. + */ +export type PendingHighlight = { + color: string + verses: number[] + scope: HighlightScope +} + +/** + * Identity of the passage an intent belongs to. Two scopes match only when all + * three parts do. + * + * This is what makes {@link PendingHighlight.scope} load-bearing rather than + * decorative: verse numbers mean nothing without the chapter they were tapped + * in, so anything replayed after an await is checked against the scope it was + * claimed under before it is allowed to write. + */ +export function scopeKey(scope: HighlightScope): string { + return `${scope.versionId}|${scope.book}|${scope.chapter}` +} + +/** + * Why a flow gave up. `auth` is the flow's own reason (the permission was + * granted, and the server still refused the write); the rest come straight from + * {@link DataExchangeOutcome}. + */ +export type PermissionFlowErrorReason = DataExchangeFailureReason | 'auth' + +export type PermissionFlowError = { + reason: PermissionFlowErrorReason + message: string +} + +/** + * Where a pending highlight is in the flow. + * + * `retried` is carried from `confirming` onward and marks the one corrective + * re-prompt a stale permission cache is allowed to cause. Only `idle` can hold + * an `error`: a flow still in progress has nothing terminal to report yet, and + * making that unrepresentable is cheaper than remembering to clear it. + */ +export type PermissionFlowState = + | { step: 'idle'; error: PermissionFlowError | null } + | { step: 'signing-in'; pending: PendingHighlight } + | { step: 'confirming'; pending: PendingHighlight; retried: boolean } + | { step: 'granting'; pending: PendingHighlight; retried: boolean } + | { step: 'applying'; pending: PendingHighlight; retried: boolean } + +/** + * `TAP` opens a flow the pre-flight decided is needed — never the ordinary case + * where the permission is already granted, which writes straight through and + * must stay concurrent (see the hook). + * + * `AUTH_RETRY` is the corrective entry: the cached grant said yes, the write got + * a 401/403 anyway, so the cache was stale and the user is re-prompted once. + */ +export type PermissionFlowEvent = + | { type: 'TAP'; pending: PendingHighlight; branch: 'sign-in' | 'consent' } + | { type: 'AUTH_RETRY'; pending: PendingHighlight } + | { type: 'SIGN_IN_DONE'; signedIn: boolean; hasPermission: boolean } + | { type: 'CONFIRM' } + | { type: 'DECLINE' } + | { type: 'GRANT_RESULT'; outcome: DataExchangeOutcome } + | { type: 'APPLY_RESULT'; outcome: HighlightWriteOutcome } + | { type: 'RESET' } + +export const initialPermissionFlowState: PermissionFlowState = { step: 'idle', error: null } + +/** + * The clean terminal state, shared by every abandonment path. One object rather + * than a fresh literal each time so React can bail out of a re-render when a + * flow that was already idle is reset again. + */ +const IDLE: PermissionFlowState = initialPermissionFlowState + +const GRANT_WITHOUT_HIGHLIGHTS_MESSAGE = + 'The permission grant came back without `highlights`, so the highlight was not applied.' + +/** + * The flow's whole state model: React-free, total, and pure. + * + * **Every event invalid for the current step returns the state unchanged.** That + * is not defensive noise — it is the mechanism that keeps a browser round-trip + * from resurrecting a discarded highlight. A sign-in or consent session that + * lands after the reader moved chapters (`RESET`) finds an `idle` state and dies + * there, instead of painting a highlight the user has scrolled away from. + */ +export function permissionFlowReducer( + state: PermissionFlowState, + event: PermissionFlowEvent, +): PermissionFlowState { + switch (event.type) { + case 'RESET': + // Also clears a terminal error: a failure about the chapter the reader + // just left is noise, not something to surface over the new one. + return state.step === 'idle' && state.error === null ? state : IDLE + + case 'TAP': + if (state.step !== 'idle') { + return state + } + return event.branch === 'sign-in' + ? { step: 'signing-in', pending: event.pending } + : { step: 'confirming', pending: event.pending, retried: false } + + case 'AUTH_RETRY': + if (state.step !== 'idle') { + return state + } + return { step: 'confirming', pending: event.pending, retried: true } + + case 'SIGN_IN_DONE': + if (state.step !== 'signing-in') { + return state + } + // A cancel is not an error and not a failure — the user changed their + // mind, so the highlight goes with it. + if (!event.signedIn) { + return IDLE + } + // Swift's `continuePendingHighlightAfterSignIn`: signing in may itself + // have granted the permission (it rides on the OAuth redirect), in which + // case asking again would be asking twice for the same thing. + return event.hasPermission + ? { step: 'applying', pending: state.pending, retried: false } + : { step: 'confirming', pending: state.pending, retried: false } + + case 'CONFIRM': + if (state.step !== 'confirming') { + return state + } + return { step: 'granting', pending: state.pending, retried: state.retried } + + case 'DECLINE': + if (state.step !== 'confirming') { + return state + } + return IDLE + + case 'GRANT_RESULT': { + if (state.step !== 'granting') { + return state + } + const { outcome } = event + if (outcome.status === 'cancel') { + return IDLE + } + if (outcome.status === 'failure') { + return { step: 'idle', error: { reason: outcome.reason, message: outcome.message } } + } + // `granted` reports what the server actually granted, which is not + // necessarily what was asked for — so check the list, not the status. + // Consenting to something else is not consent to save highlights. + if (!outcome.grantedPermissions.includes(HIGHLIGHTS_PERMISSION)) { + return { + step: 'idle', + error: { reason: 'not-permitted', message: GRANT_WITHOUT_HIGHLIGHTS_MESSAGE }, + } + } + return { step: 'applying', pending: state.pending, retried: state.retried } + } + + case 'APPLY_RESULT': { + if (state.step !== 'applying') { + return state + } + const { outcome } = event + // Anything that is not an auth refusal is the write's own business: it + // reports through the outcome the caller is already holding. + if (outcome.status !== 'error' || outcome.reason !== 'auth') { + return IDLE + } + // One corrective re-prompt, ever. A second auth refusal after the user has + // just granted the permission is not something more consent can fix, and + // looping the consent page on it would be the worst possible read of it. + if (state.retried || outcome.failedVerses.length === 0) { + return { step: 'idle', error: { reason: 'auth', message: outcome.message } } + } + // Replay only what failed: a partial batch already landed the rest. + return { + step: 'confirming', + pending: { ...state.pending, verses: outcome.failedVerses }, + retried: true, + } + } + + default: + // Unreachable through the typed API. Kept because the reducer is the one + // place a stray or hot-reloaded event could otherwise corrupt a flow, and + // "ignore what you do not understand" is the same rule as every branch above. + return state + } +} + +/** + * Collapses a flow failure onto the four reasons a write can report, so the + * whole flow resolves through one channel the caller already handles. + * + * `not-permitted` maps to `invalid` rather than `transient` on purpose: the app + * key is not enabled for data exchange, which is a configuration fact, and + * telling a caller to retry it would be a lie. + * + * `in-progress` maps to `transient` because a write refused for holding the + * consent flow open is retryable — later, once that flow settles. A write + * outcome has no finer bucket for "retry, but not this instant", and the flow + * already reports an overlapping tap as `transient` for the same reason. + */ +export function toWriteReason(reason: PermissionFlowErrorReason): HighlightWriteReason { + switch (reason) { + case 'not-signed-in': + return 'not-signed-in' + case 'auth': + return 'auth' + case 'not-permitted': + return 'invalid' + case 'user-changed': + case 'in-progress': + case 'transient': + return 'transient' + } +} diff --git a/packages/core/src/highlights/use-highlight-permission-flow.ts b/packages/core/src/highlights/use-highlight-permission-flow.ts new file mode 100644 index 00000000..79fc3b73 --- /dev/null +++ b/packages/core/src/highlights/use-highlight-permission-flow.ts @@ -0,0 +1,520 @@ +import { useCallback, useEffect, useReducer, useRef, useState } from 'react' + +import { useYVAuthOptional } from '../auth' +import { NOT_SIGNED_IN_MESSAGE } from './constants' +import { + HIGHLIGHTS_PERMISSION, + initialPermissionFlowState, + permissionFlowReducer, + scopeKey, + toWriteReason, + type PendingHighlight, + type PermissionFlowError, + type PermissionFlowEvent, + type PermissionFlowState, +} from './permission-flow' +import { + useHighlights, + type HighlightWriteOutcome, + type UseHighlightsOptions, + type UseHighlightsResult, +} from './use-highlights' + +export type UseHighlightPermissionFlowResult = { + /** + * The underlying {@link useHighlights} result, unmodified — render `highlights` + * from it, and use its `remove` / `refresh` / `isRefreshing` / `error` as + * documented. Only `apply` is wrapped, and the wrapper lives on this object, + * not inside `highlights`. + */ + highlights: UseHighlightsResult + /** + * The consent prompt should be showing. Drive a sheet's `isOpen` from this and + * wire **every** dismissal path — button, backdrop, pan-down — to + * {@link decline}, so no dismissal can strand a pending highlight. + */ + isConfirming: boolean + /** + * Apply a highlight, running sign-in and/or consent first if the user is not + * yet allowed to make it. Resolves once the whole round-trip has finished: + * with the write's own outcome when one was issued, `noop` when the user + * abandoned the flow, or an `error` when the flow itself failed. + */ + apply: (color: string, verses: number[]) => Promise + /** The user accepted the consent prompt. No-op unless {@link isConfirming}. */ + confirm: () => void + /** + * The user dismissed the consent prompt, by any route. Discards the pending + * highlight. No-op unless {@link isConfirming}. + */ + decline: () => void + /** + * The last terminal flow failure — a failed permission grant, or a write still + * refused after the user granted the permission. Cancels and declines are not + * failures and never appear here. Cleared when a new flow starts or the scope + * changes. + */ + flowError: PermissionFlowError | null +} + +const FLOW_IN_PROGRESS_MESSAGE = + 'A highlight permission flow is already in progress; this highlight was not applied.' + +let hasWarnedMissingAuthConfig = false + +/** + * The only genuine misconfiguration this hook can detect. A missing *permission* + * is the user flow and must never warn; a missing `auth` **config** means the + * flow can never do anything, which the developer wants to hear about once. + */ +function warnMissingAuthConfig(): void { + if (hasWarnedMissingAuthConfig || process.env.NODE_ENV === 'production') { + return + } + hasWarnedMissingAuthConfig = true + console.warn( + '[YouVersion SDK] useHighlightPermissionFlow needs `auth` configured on YouVersionProvider. ' + + 'Without it there is no user to sign in and no permission to grant, so highlight writes ' + + 'behave exactly as signed out.', + ) +} + +function notSignedInOutcome(verses: number[]): HighlightWriteOutcome { + return { + status: 'error', + reason: 'not-signed-in', + message: NOT_SIGNED_IN_MESSAGE, + failedVerses: verses, + succeededVerses: [], + } +} + +/** + * Guided highlighting: the two-branch permission flow over {@link useHighlights}. + * + * A user who taps a color before signing in, or before granting the `highlights` + * permission, still gets their highlight — it is held in memory, the missing + * step runs, and the write goes out on the way back. Mirrors the Swift SDK's + * `BibleReaderViewModel.addHighlightOrStartPermissionFlow`. + * + * The branch point is a **pre-flight cache read**, not a write's 401/403: + * reason-first would burn a failed round-trip before every first highlight. The + * `reason: 'auth'` path is the corrective fallback for a cache that turned out + * to be stale, and it re-prompts exactly once. + * + * With no `auth` on `YouVersionProvider` this behaves exactly as signed out (and + * says so once, in development). + */ +export function useHighlightPermissionFlow( + options: UseHighlightsOptions, +): UseHighlightPermissionFlowResult { + const highlights = useHighlights(options) + const auth = useYVAuthOptional() + + const [state, dispatch] = useReducer(permissionFlowReducer, initialPermissionFlowState) + + // ── Scope-change reset, during render ─────────────────────────────────────── + // Same "adjust state when props change" pattern useHighlights uses, and for + // the same reason: an effect would leave one frame where a consent sheet for + // the chapter the reader just left is still open over the new one. + const currentScopeKey = scopeKey(options) + const [seenScopeKey, setSeenScopeKey] = useState(currentScopeKey) + + let renderedState = state + if (seenScopeKey !== currentScopeKey) { + renderedState = permissionFlowReducer(state, { type: 'RESET' }) + setSeenScopeKey(currentScopeKey) + dispatch({ type: 'RESET' }) + } + + // ── Latest-value refs for the async layer ────────────────────────────────── + // Seeded from this render and re-synced after every one. Async continuations + // read these instead of closing over a single render's values — the flow spans + // a browser round-trip, across which almost everything can change. + const stateRef = useRef(renderedState) + const authRef = useRef(auth) + const highlightsRef = useRef(highlights) + + /** + * Generation token. Bumped when a flow starts and when one is abandoned, so a + * continuation that resolves late — a browser result arriving after a scope + * change — can tell it is talking about a flow that no longer exists. The + * reducer would ignore its event anyway; this is what stops it resolving + * somebody else's promise. + */ + const flowIdRef = useRef(0) + + /** The caller awaiting the in-progress flow. Exactly one, since flows are exclusive. */ + const resolveRef = useRef<((outcome: HighlightWriteOutcome) => void) | null>(null) + + // A forced render, used to read auth state that was committed by an action we + // just awaited (see `nextCommittedRender`). + const [, bumpRender] = useState(0) + const renderWaitersRef = useRef<(() => void)[]>([]) + + /** + * Resolve whoever is waiting on a flow that ended without an answer for them — + * a scope change, or an unmount. Bumps the generation so the continuation that + * was going to answer them cannot. + */ + const settleAbandoned = useCallback((): void => { + const resolve = resolveRef.current + if (resolve === null) { + return + } + resolveRef.current = null + flowIdRef.current += 1 + resolve({ status: 'noop' }) + }, []) + + // Mutually recursive continuations (advance → run* → advance), so the runners + // reach `advance` through a ref instead of a dependency cycle between + // `useCallback`s that cannot name each other. Filled in by the effect below. + const runnersRef = useRef({ + advance: (_next: PermissionFlowState, _flowId: number, _pending: PendingHighlight): void => {}, + }) + + // Runs after EVERY render. + useEffect(() => { + stateRef.current = renderedState + authRef.current = auth + highlightsRef.current = highlights + + // `idle` with somebody still waiting means the flow was discarded out from + // under them (the render-time reset above is the only route that does this + // without resolving them itself). + if (renderedState.step === 'idle') { + settleAbandoned() + } + + const waiters = renderWaitersRef.current + renderWaitersRef.current = [] + for (const resolve of waiters) { + resolve() + } + }) + + useEffect( + () => () => { + // On unmount, settle first (so any flushed continuation finds nothing to + // answer) and then release the waiters, rather than leaving a caller's + // promise pending for the lifetime of the app. + settleAbandoned() + const waiters = renderWaitersRef.current + renderWaitersRef.current = [] + for (const resolve of waiters) { + resolve() + } + }, + [settleAbandoned], + ) + + /** + * Resolves after React has committed a render, so `authRef` reflects state that + * an action we just awaited set. + * + * This is load-bearing, not belt-and-braces. `signIn()` resolves in a + * microtask; the re-render for the `setState` calls it made is scheduled on a + * macrotask, so reading auth straight after awaiting it is *guaranteed* to be + * too early. Forcing a render fixes the ordering rather than hoping for it: + * React applies every update enqueued before a render begins, and auth's were + * enqueued before this one. + */ + const nextCommittedRender = useCallback( + (): Promise => + new Promise((resolve) => { + renderWaitersRef.current.push(resolve) + bumpRender((n) => n + 1) + }), + [], + ) + + /** + * Reduce and dispatch in one step, returning the next state synchronously. + * + * The mirror into `stateRef` is what makes the hook's own control flow + * deterministic: a `confirm()` handler must know it moved to `granting` before + * React re-renders, or a double-tap would mint two data-exchange tokens. Safe + * because the reducer is pure and React replays the same events in the same + * order onto the same committed state. + */ + const send = useCallback((event: PermissionFlowEvent): PermissionFlowState => { + const next = permissionFlowReducer(stateRef.current, event) + stateRef.current = next + dispatch(event) + return next + }, []) + + /** Hand the waiting caller their answer, unless this flow has been superseded. */ + const finish = useCallback((flowId: number, outcome: HighlightWriteOutcome): void => { + if (flowIdRef.current !== flowId) { + return + } + const resolve = resolveRef.current + resolveRef.current = null + resolve?.(outcome) + }, []) + + const runSignIn = useCallback( + async (pending: PendingHighlight, flowId: number): Promise => { + const current = authRef.current + if (current === null) { + finish(flowId, notSignedInOutcome(pending.verses)) + send({ type: 'RESET' }) + return + } + + try { + await current.signIn() + } catch { + // `signIn` already recorded the failure on the auth context's `error`. + // Nothing is rethrown at a caller who asked for a highlight: the read + // below settles the flow either way, and a cancel and a failure lead to + // the same place from here. + } + + await nextCommittedRender() + if (flowIdRef.current !== flowId) { + return + } + + const after = authRef.current + const next = send({ + type: 'SIGN_IN_DONE', + signedIn: after?.isAuthenticated ?? false, + // Signing in can grant the permission on the way through — the grant + // rides on the OAuth redirect — so this is a real fall-through, not a + // formality. + hasPermission: after?.hasPermission(HIGHLIGHTS_PERMISSION) ?? false, + }) + runnersRef.current.advance(next, flowId, pending) + }, + [finish, nextCommittedRender, send], + ) + + const runGrant = useCallback( + async (pending: PendingHighlight, flowId: number): Promise => { + const current = authRef.current + if (current === null) { + finish(flowId, notSignedInOutcome(pending.verses)) + send({ type: 'RESET' }) + return + } + + // Read from the ref rather than a captured context value: `requestPermissions` + // closes over the access token, and a refresh mid-flow replaces it. + const outcome = await current.requestPermissions([HIGHLIGHTS_PERMISSION]) + if (flowIdRef.current !== flowId) { + return + } + const next = send({ type: 'GRANT_RESULT', outcome }) + runnersRef.current.advance(next, flowId, pending) + }, + [finish, send], + ) + + const runApply = useCallback( + async (pending: PendingHighlight, flowId: number): Promise => { + const outcome = await highlightsRef.current.apply(pending.color, pending.verses) + if (flowIdRef.current !== flowId) { + return + } + const next = send({ type: 'APPLY_RESULT', outcome }) + + if (next.step === 'idle') { + // The write's own outcome is the report — including a terminal auth + // failure, which `flowError` also carries for a toast. + finish(flowId, outcome) + return + } + + // The only other destination is one corrective re-prompt: the grant we + // hold is demonstrably not what the server thinks, so drop it before + // asking again, or the next pre-flight would read the same wrong answer. + authRef.current?.invalidatePermissions() + runnersRef.current.advance(next, flowId, pending) + }, + [finish, send], + ) + + /** + * Perform whatever the step we just entered requires. `pending` is only needed + * for the terminal case, where the state no longer carries one; every other + * step reads its own. + */ + const advance = useCallback( + (next: PermissionFlowState, flowId: number, pending: PendingHighlight): void => { + switch (next.step) { + case 'idle': + finish( + flowId, + next.error === null + ? // Abandoned by the user: nothing was written and nothing is + // wrong, so this is not an error to report to them. + { status: 'noop' } + : { + status: 'error', + reason: toWriteReason(next.error.reason), + message: next.error.message, + failedVerses: pending.verses, + succeededVerses: [], + }, + ) + return + case 'signing-in': + void runSignIn(next.pending, flowId) + return + case 'granting': + void runGrant(next.pending, flowId) + return + case 'applying': + void runApply(next.pending, flowId) + return + case 'confirming': + // Waits for the user: `confirm()` or `decline()` drives it from here. + return + } + }, + [finish, runSignIn, runGrant, runApply], + ) + + // Published after commit rather than assigned during render: a render may be + // discarded, and nothing calls back into `advance` before a user event anyway. + useEffect(() => { + runnersRef.current.advance = advance + }, [advance]) + + /** + * Open a flow and hand the caller a promise for its eventual outcome. Flows are + * exclusive — a second one while a browser session is open would mint a second + * token and, on Android, fail outright — so an overlapping caller is told the + * write did not happen rather than being silently queued behind an unbounded wait. + */ + const startFlow = useCallback( + (entry: PermissionFlowEvent, pending: PendingHighlight): Promise => { + if (stateRef.current.step !== 'idle') { + return Promise.resolve({ + status: 'error', + reason: 'transient', + message: FLOW_IN_PROGRESS_MESSAGE, + failedVerses: pending.verses, + succeededVerses: [], + }) + } + + const flowId = flowIdRef.current + 1 + flowIdRef.current = flowId + + const promise = new Promise((resolve) => { + resolveRef.current = resolve + }) + const next = send(entry) + advance(next, flowId, pending) + return promise + }, + [advance, send], + ) + + /** + * The common case: the permission is cached, so the write goes out immediately. + * Deliberately **not** routed through the reducer — modelling every ordinary + * highlight as an exclusive flow step would serialize concurrent taps, which + * `useHighlights` supports and users do constantly. + */ + const applyThroughGrant = useCallback( + async (color: string, verses: number[]): Promise => { + // Claimed before the write goes out. `useHighlights.apply` binds the + // passage at this moment, so anything replayed afterwards has to be + // measured against the same one rather than against wherever the reader + // has since got to. + const claimedScope = highlightsRef.current.scope + const outcome = await highlightsRef.current.apply(color, verses) + if (outcome.status !== 'error' || outcome.reason !== 'auth') { + return outcome + } + // The cached grant was stale. Drop it and re-prompt once — unless a flow is + // already running, in which case that flow is the re-prompt. + if (outcome.failedVerses.length === 0 || stateRef.current.step !== 'idle') { + return outcome + } + authRef.current?.invalidatePermissions() + // The reader changed chapters while the write was out. The grant was still + // worth dropping, but the intent belongs to a passage they have left, so + // there is nothing left to re-prompt for. No flow exists yet at this + // point, so the render-time RESET had nothing to abandon. + if (scopeKey(claimedScope) !== scopeKey(highlightsRef.current.scope)) { + return outcome + } + const pending: PendingHighlight = { + color, + verses: outcome.failedVerses, + scope: claimedScope, + } + return startFlow({ type: 'AUTH_RETRY', pending }, pending) + }, + [startFlow], + ) + + // Deliberately not `async`. Every branch already returns a promise, and the + // whole point of this function is that nothing is awaited before the write is + // handed off, so an `async` wrapper here would only invite one back. + const apply = useCallback( + (color: string, verses: number[]): Promise => { + const current = authRef.current + if (current === null) { + warnMissingAuthConfig() + return highlightsRef.current.apply(color, verses) + } + + // Nothing is awaited in front of this branch, so the optimistic paint + // inside `applyThroughGrant` lands in the same tick as the tap. + // `hasPermission` reads the local grant cache, and the token freshness the + // write depends on is guaranteed inside `useHighlights.runWrite` — behind + // the claim rather than in front of it (ADR 0016). Putting a refresh here + // instead would make the common case wait on a token round-trip before a + // single pixel changed. + if (current.hasPermission(HIGHLIGHTS_PERMISSION)) { + return applyThroughGrant(color, verses) + } + + // Records the passage the user actually tapped, so nothing replayed after + // sign-in or consent can paint verses onto text they never selected. + const pending: PendingHighlight = { color, verses, scope: highlightsRef.current.scope } + + return startFlow( + { type: 'TAP', pending, branch: current.isAuthenticated ? 'consent' : 'sign-in' }, + pending, + ) + }, + [applyThroughGrant, startFlow], + ) + + const confirm = useCallback((): void => { + const current = stateRef.current + if (current.step !== 'confirming') { + return + } + const { pending } = current + const flowId = flowIdRef.current + advance(send({ type: 'CONFIRM' }), flowId, pending) + }, [advance, send]) + + const decline = useCallback((): void => { + const current = stateRef.current + if (current.step !== 'confirming') { + return + } + const { pending } = current + const flowId = flowIdRef.current + advance(send({ type: 'DECLINE' }), flowId, pending) + }, [advance, send]) + + return { + highlights, + isConfirming: renderedState.step === 'confirming', + apply, + confirm, + decline, + flowError: renderedState.step === 'idle' ? renderedState.error : null, + } +} diff --git a/packages/core/src/highlights/use-highlights.ts b/packages/core/src/highlights/use-highlights.ts index 920b0fca..58e33340 100644 --- a/packages/core/src/highlights/use-highlights.ts +++ b/packages/core/src/highlights/use-highlights.ts @@ -5,7 +5,7 @@ import { useYVAuthOptional } from '../auth' import { useYouVersion } from '../use-youversion' import { createHighlightsApi, type HighlightsApi, type HighlightsApiError } from './api' import { deriveServerColors, getCachedHighlights, setCachedHighlights } from './cache' -import { isHighlightColor, type HighlightScope } from './constants' +import { isHighlightColor, NOT_SIGNED_IN_MESSAGE, type HighlightScope } from './constants' import { claim, collapseVerseRuns, @@ -75,7 +75,6 @@ export type UseHighlightsResult = { remove: (color: string, verses: number[]) => Promise } -const NOT_SIGNED_IN_MESSAGE = 'Not signed in — highlights require an authenticated YouVersion user.' const INVALID_COLOR_MESSAGE = 'Unsupported highlight color. Use one of the five YouVersion highlight swatches.' @@ -164,6 +163,7 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul const accessToken = auth?.accessToken ?? null const isAuthLoading = auth?.isLoading ?? false const userId = auth?.userInfo?.id ?? null + const ensureFreshToken = auth?.ensureFreshToken ?? null const scope = useMemo( () => ({ versionId: options.versionId, book: options.book, chapter: options.chapter }), @@ -205,7 +205,7 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul // over one render's values. const identityRef = useRef({ key: currentIdentityKey, scope, userId }) const stateRef = useRef(renderedState) - const authRef = useRef({ accessToken, isAuthLoading }) + const authRef = useRef({ accessToken, isAuthLoading, ensureFreshToken }) // ── The token-loading hold ───────────────────────────────────────────────── // `userInfo` is seeded synchronously but `accessToken` only arrives after @@ -225,7 +225,7 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul useEffect(() => { identityRef.current = { key: currentIdentityKey, scope, userId } stateRef.current = renderedState - authRef.current = { accessToken, isAuthLoading } + authRef.current = { accessToken, isAuthLoading, ensureFreshToken } if (accessToken !== null && userId === null) { warnMissingUserId() @@ -346,6 +346,20 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul await waitForAuthSettled() + // The token has to be current when the request goes out — not when the + // user tapped. An expired token 401s, a 401 classifies as `auth`, and + // `useHighlightPermissionFlow` reads `auth` as a stale grant, so without + // this a user would be asked to grant a permission they already granted + // (ADR 0016). + // + // It belongs here, in the send path, rather than in front of the tap: a + // refresh that is actually due costs a token round-trip, and the + // optimistic claim in `startWrite` has already painted by the time this + // runs. Nothing the user can see waits on it. `refreshToken` handles its + // own failures and never rejects, so a dead network leaves the old token + // in place and the write below reports through the normal outcome. + await authRef.current.ensureFreshToken?.() + // The write chain outlives an identity change: `enqueue` serializes behind // whatever is in flight, and there is no AbortController, so one hung // request can hold a queued batch across a sign-out and a sign-in as diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd06feb3..12110afc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,7 +13,13 @@ export type { YVUserInfo, } from './auth' -export { deriveServerColors, HIGHLIGHT_COLORS, isHighlightColor, useHighlights } from './highlights' +export { + deriveServerColors, + HIGHLIGHT_COLORS, + isHighlightColor, + useHighlightPermissionFlow, + useHighlights, +} from './highlights' export type { Highlight, HighlightColor, @@ -21,7 +27,10 @@ export type { HighlightsFetchError, HighlightWriteOutcome, HighlightWriteReason, + PermissionFlowError, + PermissionFlowErrorReason, ServerColors, + UseHighlightPermissionFlowResult, UseHighlightsOptions, UseHighlightsResult, } from './highlights' From 17f4ae761e4b8e79d663ba46995f6a64c9984c72 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Wed, 5 Aug 2026 12:53:00 -0500 Subject: [PATCH 12/43] feat(ui): the reader renders native-owned highlights (YPE-3710) (U1) (#118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): update Web SDK packages to 2.5.0 Bumps @youversion/platform-react-ui (packages/ui) and @youversion/platform-core (packages/core) from 2.4.0 to 2.5.0, pulling platform-core and platform-react-hooks 2.5.0 with them so a single copy of each resolves across the workspace. 2.5.0 swaps the reader's serif face from Source Serif 4 to Untitled Serif, which required two native-side changes: - reader-fonts mirrors the new UNTITLED_SERIF_FONT stack and carries it over the bridge as an `untitled-serif` token. The Web SDK's picker now emits that stack, and without a token for it encodeFontFamilyForDom passes the raw quoted string across the bridge — the exact input that corrupts @expo/dom-webview's prop injection on iOS and renders the reader blank (ADR 0009). SOURCE_SERIF_FONT stays, deprecated, so values persisted by earlier versions still encode to a known token. - The reader settings store defaults to Untitled Serif and migrates a persisted Source Serif value on read. The Web SDK runs that migration itself only when fontFamily is uncontrolled; we always pass it controlled, so the reader would have kept the deprecated stack and matched neither button in the picker. Also corrects the AGENTS.md cooldown section: pnpm 11.11 verifies the committed lockfile against minimumReleaseAge on every install, including --frozen-lockfile, so CI is not exempt as previously documented. Co-Authored-By: Claude Opus 5 * chore: exempt @youversion/* from the minimumReleaseAge cooldown The 3-day cooldown mitigates hijacked third-party releases by giving the ecosystem time to spot one. For the Web SDK packages we publish ourselves it provides little of that signal and blocks us from consuming our own work on release day — which is what held up the 2.5.0 bump. pnpm's minimumReleaseAgeExclude accepts scope globs, so '@youversion/*' covers platform-core, platform-react-hooks, and platform-react-ui. Verified against `pnpm install --frozen-lockfile`, the command CI runs, which now passes the lockfile policy check. The tradeoff is deliberate: a compromised YouVersion npm token would reach our builds with no waiting period, so these packages rely on publish-side controls rather than this cooldown. Every third-party dependency keeps the full 3 days. Co-Authored-By: Claude Opus 5 * feat(ui): the reader renders native-owned highlights (YPE-3710) (U1) Replace the `highlights={[]}` containment literal with real, natively-owned highlight data, switch off the in-WebView verse action popover, and forward verse selection across the bridge as a serializable native action — so the WebView reader is a pure view and every highlight API call originates in native code. - core: `requestedPermissions` on `AuthContextValue`; `shouldFetchHighlights` gates the fetch on the app's configured permissions. - ui: `highlights` is required on the DOM reader (the controlled-mode latch), `verseActions="none"` is hardcoded, and `onVerseSelect` / `clearSelectionSignal` become public native props. - example: requests the `highlights` permission and demos selection + clear. Co-Authored-By: Claude Opus 5 * fix(ui): keep the verse action popover on web (YPE-3710) U1 hardcoded `verseActions="none"` inside the `'use dom'` file, for every platform. `NativeSheet` returns null on web, so no native sheet can replace the popover there — web was left with no verse action UI at all. Thread `verseActions` from the native wrapper instead, and pick it per platform via `resolveVerseActions`. The branch lives in `lib/` because a platform fork is invisible to a layer-3 test that always runs as one platform; four layer-1 tests cover it, including an unknown platform falling toward `'none'`. `verseActions` is required on the DOM props (like `highlights`) and omitted from `BibleReaderProps` — native owns it, consumers don't choose. The web popover's swatches stay inert under controlled mode, unchanged from before U1; Copy and Share still work. Co-Authored-By: Claude Opus 5 * refactor(ui): hardcode verseActions to "none" and remove related logic * fix(ui): ensure highlights prop is always an array in BibleReader * docs(core): improve documentation and comments in use-highlights and BibleReader components * refactor(core): clean up comments in use-highlights for clarity and maintainability * docs(README): enhance verse selection documentation for clarity and usage * fix(reader): update highlight rendering to support natively-owned highlights and disable in-WebView verse action popover * fix(pr118): tighten the verseActions guard, baseline clearSelectionSignal, unpublish AuthContextValue Three follow-ups from the review of PR #118. The `verseActions="none"` source guard passed on a JSDoc mention, so deleting the real JSX prop left it green. Anchor the match to a line that is nothing but the prop. `clearSelectionSignal` was forwarded undefaulted. The Web SDK clears the selection whenever the value differs from the one it saw last, so a consumer who started passing `0` after mount tripped `undefined !== 0` and cleared a live selection. Default it to `0` so a number crosses the bridge from first mount, which is what YPE-3710 AC5 asks for. The prop stays optional. `AuthContextValue` was added to both core barrels. Nothing outside `packages/core/src/auth/` imports it from a barrel, and publishing it makes every future field on the auth context a semver surface. Adding the export later is a minor bump; removing it later is a major. Co-Authored-By: Claude Opus 5 * refactor(bible-reader): enhance prop documentation for clarity and maintainability --------- Co-authored-by: Claude Opus 5 Co-authored-by: Cameron Pak --- .../reader-renders-native-owned-highlights.md | 19 + AGENTS.md | 10 +- CONTEXT.md | 15 + README.md | 20 +- apps/example/app/(tabs)/index.tsx | 67 +++- apps/example/app/_layout.tsx | 6 +- packages/core/README.md | 10 +- .../src/auth/__tests__/use-yv-auth.test.tsx | 1 + packages/core/src/auth/auth-context.tsx | 6 + packages/core/src/auth/auth-provider.tsx | 8 + .../__tests__/should-fetch-highlights.test.ts | 54 +++ .../use-highlight-permission-flow.test.tsx | 5 + .../__tests__/use-highlights.test.tsx | 63 +++ .../core/src/highlights/use-highlights.ts | 45 ++- packages/ui/jest.setup.js | 26 ++ packages/ui/src/dom/bible-reader.tsx | 62 ++- packages/ui/src/index.ts | 2 + .../bible-reader-highlights-bridge.test.tsx | 361 ++++++++++++++++++ packages/ui/src/native/bible-reader.tsx | 31 +- packages/ui/src/native/index.ts | 6 +- 20 files changed, 786 insertions(+), 31 deletions(-) create mode 100644 .changeset/reader-renders-native-owned-highlights.md create mode 100644 packages/core/src/highlights/__tests__/should-fetch-highlights.test.ts create mode 100644 packages/ui/src/native/__tests__/bible-reader-highlights-bridge.test.tsx diff --git a/.changeset/reader-renders-native-owned-highlights.md b/.changeset/reader-renders-native-owned-highlights.md new file mode 100644 index 00000000..3ed50127 --- /dev/null +++ b/.changeset/reader-renders-native-owned-highlights.md @@ -0,0 +1,19 @@ +--- +'@youversion/platform-react-native-expo-core': patch +'@youversion/platform-react-native-expo-ui': patch +--- + +`BibleReader` now renders natively-owned highlights, and the in-WebView verse action popover is switched off. + +The reader subscribes to `useHighlights` for its current version / book / chapter and feeds the result into the Web SDK reader as a controlled prop. Because the MMKV cache read is synchronous, highlights are in the very first props — no blank first frame. Nothing about the highlight path runs inside the WebView any more: no network calls, no local store, no auth surface. + +**`verseActions="none"` is now hardcoded.** Until the native verse action sheet lands (YPE-3712), selecting a verse raises **no** action UI inside the reader — the colour swatches, Copy, and Share buttons are gone. Verse selection and selection painting are unchanged. Two new props replace what the popover provided: + +- `onVerseSelect(selection)` fires on every selection change, including clears (`verses: []`). The payload carries `versionId`, `book`, `chapter`, `verses`, `passageIds`, a localized `reference` (`Hebrews 11:4`, not `HEB 11:4`), and the `shareData` the popover's Copy / Share buttons would have used — all bridge-safe primitives. +- `clearSelectionSignal` dismisses the current selection from native. Increment it; the value at mount is the baseline, so mounting never clears. A counter rather than an imperative ref handle because only serializable props cross the DOM bridge. + +`BibleReaderVerseSelection` and `BibleReaderShareData` are re-exported so a handler can be typed without depending on `@youversion/platform-react-ui` directly. + +On the core side, `useHighlights` now gates its GET on the app having **requested** the `highlights` permission (`auth.permissions` on `YouVersionProvider`). Without it the SDK issues no highlights request at all — so an app that renders a reader and never asked for highlights pays nothing. 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. `useYVAuth()` gains `requestedPermissions` to carry it, defaulting to `[]`. + +Not in this change: applying or removing highlights from native, the native verse action sheet, and copy / share. diff --git a/AGENTS.md b/AGENTS.md index 6ba32f42..30ae3282 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,8 @@ Keep `GestureHandlerRootView` outside `YouVersionProvider`; bottom-sheet gesture `BibleCard`, `VerseOfTheDay`, `BibleReader`, and `BibleTextView` read `appKey` from `YouVersionProvider`, then pass serializable `appKey` and theme props into their DOM wrappers. Component-level theme props remain valid overrides. +`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 also hardcodes `verseActions="none"`: there is no in-WebView verse action popover, and consumers get no say. `onVerseSelect` and `clearSelectionSignal` are the public replacements — the former reports every selection change (including clears), the latter dismisses one from native. + `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. ### Version Picker Sheet @@ -146,16 +148,17 @@ 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` +**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` -**Core** (`@youversion/platform-react-native-expo-core`): `YouVersionProvider` (installation id + optional auth), `useYouVersion`, `useYVAuth` (its value carries `grantedPermissions` / `hasPermission` / `invalidatePermissions` / `requestPermissions` / `ensureFreshToken` alongside the sign-in surface), `useHighlights`, `useHighlightPermissionFlow`, `deriveServerColors`, `HIGHLIGHT_COLORS` / `isHighlightColor`, `mmkvStorage`, auth types (`AuthConfig`, `AuthPermission`, `KnownAuthPermission`, `AuthScope`, `DataExchangeOutcome`, `DataExchangeFailureReason`, `YVUserInfo`), and highlights types (`Highlight`, `HighlightScope`, `ServerColors`, `HighlightWriteOutcome`, `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` / `ensureFreshToken` alongside the sign-in surface), `useHighlights`, `useHighlightPermissionFlow`, `deriveServerColors`, `HIGHLIGHT_COLORS` / `isHighlightColor`, `mmkvStorage`, auth types (`AuthConfig`, `AuthPermission`, `KnownAuthPermission`, `AuthScope`, `DataExchangeOutcome`, `DataExchangeFailureReason`, `YVUserInfo`), and highlights types (`Highlight`, `HighlightScope`, `ServerColors`, `HighlightWriteOutcome`, `HighlightsFetchError`, `UseHighlightsOptions`, `UseHighlightsResult`, `UseHighlightPermissionFlowResult`, `PermissionFlowError`, `PermissionFlowErrorReason`) UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider`. Import Bible components from UI; import `useYVAuth` from core. ## Auth (core) - Optional PKCE OAuth when `auth: { redirectUri, scopes?, permissions? }` is passed to core `YouVersionProvider` (forwarded by UI provider). -- On RN, `permissions` is configured on `YouVersionProvider`'s `auth` config (not on `YouVersionAuthButton` / `signIn()`), unlike web. The example app stays scopes-only until the permission flow lands (C3). +- On RN, `permissions` is configured on `YouVersionProvider`'s `auth` config (not on `YouVersionAuthButton` / `signIn()`), unlike web. The example app requests `['highlights']` so the reader demo has data to paint. +- The configured list is readable from the auth context as `requestedPermissions` (always an array; `[]` when `auth` is unconfigured). It is what was **asked for**; `grantedPermissions` below is what came back. The highlights fetch gates on the former deliberately — see the note in `shouldFetchHighlights`. - **Requesting a permission is not being granted it.** `useYVAuth()` reads the grant back: `hasPermission(permission)` for a single check, `grantedPermissions` for the list, and `invalidatePermissions()` to drop a stale grant after a 401/403 so the next pre-flight re-prompts. Three states, and collapsing them loses "the user said no": `null` = nothing requested / unknown, `[]` = requested and denied, populated = granted. - The grant rides only on the **app redirect** — the `/auth/callback` `Location` hop drops it — so `pkce-flow.ts` parses it from `result.url` before that hop, and a test in `__tests__/pkce-flow.test.ts` pins the ordering. It is then cached per user in MMKV (redirect parsing in `auth/granted-permissions.ts`, the cache in `auth/granted-permissions-cache.ts`), seeded synchronously in a `useState` initializer so it is correct on the first render, and purged in `clearAuthState`. `AuthPermission` is an open union and cached values are kept verbatim, not filtered — filtering would turn a server-side addition into a silent denial. - `useYVAuth().requestPermissions(permissions)` is the **just-in-time grant** (data exchange): a signed-in user grants a permission on the spot, no sign-out. Mint (`POST /data-exchange/token`, 201) → hosted consent in an auth session → parse the return → merge into the grant cache. Resolves to a `DataExchangeOutcome` (`granted` / `cancel` / `failure` with `reason: 'not-signed-in' | 'not-permitted' | 'user-changed' | 'in-progress' | 'transient'`) and never throws. Permission-generic — nothing highlights-specific lives in `auth/data-exchange.ts`. @@ -181,6 +184,7 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider - `useHighlights({ versionId, book, chapter })` is the whole public surface. The `createHighlightsApi` wrapper over `@youversion/platform-core`'s `HighlightsClient`, the MMKV cache, and the local `Result` seam (`packages/core/src/result.ts`) all stay internal. - Requires `auth` on `YouVersionProvider` and the `highlights` **permission** (see the permissions note above — highlights go in `requested_permissions[]`, never in `scope`). With no auth configured it behaves exactly as signed out. +- The GET is gated on `shouldFetchHighlights(requestedPermissions)`: an app that never asked for `highlights` issues no highlights request at all. Gate on the **requested** list, never on a grant — a missing grant is indistinguishable from an unknown one, so `hasPermission('highlights')` (documented "false when unknown") would silently un-paint the highlights of every user who signed in before grant reporting shipped. When C3.1 tightens this, only a *known* denial may skip; the constraint is written out on the predicate. - Paints from the MMKV cache **synchronously** in a `useState` initializer. That only works because `AuthProvider` seeds `userInfo` from its own initializer, so `userInfo.id` exists on the first render — load-bearing coupling, commented at both ends. - `highlights` is always safe to render. `isRefreshing` means "a GET is in flight", never "no data yet"; gating a spinner on it reintroduces the blank first frame the cache exists to prevent. - `error` is **fetch-only**. Writes report once, through the `HighlightWriteOutcome` they resolve to — that is also C3's branch point for the sign-in prompt (`reason === 'auth'` / `'not-signed-in'`). diff --git a/CONTEXT.md b/CONTEXT.md index ed78d70b..c25a2d47 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -114,6 +114,18 @@ _Avoid_: Flattening to **Server Colors** before writing; treating an empty array The local layer of pending edits for a **Highlight Scope**, `Record` — a hex color where the user just applied one, `null` where they just removed one. Sits on top of **Server Colors** so the reader paints before the server answers; entries retire once the server confirms them (see [ADR 0013](docs/adr/0013-native-highlights-optimistic-layer.md) for the color-aware remove rule). Never persisted — see **Cached Highlights**. _Avoid_: Optimistic state (too vague — this is one specific layer), **Server Colors** (the layer underneath), persisting it +**Controlled Highlights Latch**: +The **Native Wrapper** always supplying a `highlights` array to its **Expo DOM Component**, never `undefined`. The Web SDK reader decides at first mount whether its highlight slice is controlled, and only the controlled branch makes no network calls, keeps no local store, and exposes no auth surface. So the array's _presence on the mount render_ is the guarantee, and `[]` is a legitimate value meaning "controlled, nothing highlighted". Missing it on that first render is what hands the WebView back the ability to write highlights with the token native gave it; dropping it later only un-paints, because the SDK reads `highlights ?? []` after the latch is set. Both are bugs — the first is unrecoverable and silent, which is why the DOM wrapper coerces a non-array to `[]` rather than trusting the type alone. +_Avoid_: Treating an empty highlights array as "nothing to pass"; a conditional or optional `highlights` prop; "controlled mode" alone (names the Web SDK's state, not our obligation) + +**Verse Selection**: +The serializable payload the reader emits on every selection change, cleared selections included (`verses: []`). Carries the **Highlight Scope** triple plus `verses`, per-verse `passageIds`, a localized `reference` for display, and `shareData`. With the in-WebView verse action UI switched off (`verseActions="none"`), this is the only channel a host learns about a selection on — and **Selection Clear Signal** is the only way it dismisses one. +_Avoid_: Verse press, tap event; keying off the payload's location fields when `verses` is empty (a clear from navigation carries the _destination_) + +**Selection Clear Signal**: +A serializable counter the **Native Wrapper** increments to clear the reader's current **Verse Selection** from outside the WebView. Mount value is the baseline, so mounting never clears. Same nonce idiom as **Sheet Reset Key** and `openKey`, and for the same reason: an imperative ref handle cannot cross the DOM bridge. +_Avoid_: `ref.clearSelection()`; a boolean "is selected" prop; **Sheet Reset Key** (that remounts a picker tree; this one clears a selection) + **Highlight Write Outcome**: What an `apply` or `remove` resolves to: `ok` with the verses that landed, `noop` when there was nothing to write, or `error` carrying a `reason` (`not-signed-in` / `auth` / `invalid` / `transient`), a diagnostic `message`, and both `failedVerses` (reverted) and `succeededVerses` (landed — non-empty means a partial batch). The only channel a write failure reports on; the hook's `error` state is for fetches alone, so a failed write can never evict a fetch error that is still true. _Avoid_: Branching on `message` (generic outside development builds); routing write failures through the hook's `error`; a separate `partial` status (the two verse arrays already say it) @@ -160,6 +172,9 @@ _Avoid_: Persisting it (that is F1's offline queue, a different thing); keeping - **Server Colors** are derived from **Cached Highlights** for a given **Highlight Scope**, never stored: entries whose version, book, or chapter does not match the scope are ignored, so stale data cannot mispaint. - A **Highlight Overlay** sits on top of **Server Colors** and is the only optimistic layer in the stack — the web reader's controlled `highlights` prop is pure projection. Each write claims the verses it paints, and a settling write only reverts verses it still owns. - A **Highlight Write Outcome** is the sole report of a write's fate, and is where C3's sign-in branch reads from. +- The reader's **Native Wrapper** derives **Cached Highlights** for its current **Highlight Scope** and holds the **Controlled Highlights Latch** with them; the **Expo DOM Component** only projects that array and never fetches, stores, or authenticates for highlights. +- The highlights fetch is mounted only when the app **requested** the `highlights` permission on its auth config — not when a grant is known. A never-requested permission means no request; an unknown grant still fetches, because absence of a grant record is not a denial. +- With the in-WebView verse action **Presentation Shell** switched off, a **Verse Selection** crosses to native as a **Native Action** and a **Selection Clear Signal** crosses back. Neither is **DOM-Owned Sheet UI State**: the selection is a committed observation, and the clear is a one-way native→DOM command. - **Granted Permissions** are read only from the app redirect, never from the `/auth/callback` hop, which drops them. They are **Native-Owned State** cached per user in MMKV and purged with the rest of auth state on sign-out; a stale grant can be invalidated so the next pre-flight re-prompts. - A permission pre-flight reads **Granted Permissions**; a **Highlight Write Outcome** of `reason: 'auth'` is the corrective path when that cache is wrong, not the primary signal. - **Data Exchange** is the other way to obtain **Granted Permissions** — the one that does not require a new sign-in. It writes into the same per-user cache, merging rather than replacing, and only ever on a granted return. diff --git a/README.md b/README.md index 2f909540..fa5c41da 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,25 @@ function ReaderScreen() { } ``` -`BibleReader` is stateful — it owns the current `versionId` and coordinates its built-in chapter and version picker sheets. +`BibleReader` is stateful — it owns the current `versionId` and coordinates its built-in chapter and version picker sheets. It also paints the signed-in user's highlights on its own, provided your `auth` config requests the `highlights` permission — there is no prop to pass. + +#### 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. `clearSelectionSignal` dismisses the current selection from native: increment it, and note its value at mount is the baseline, so mounting never clears. + +```tsx +const [clearSelectionSignal, setClearSelectionSignal] = useState(0) + + { + // selection.reference ("John 3:16"), .verses, .passageIds, .shareData + }} + clearSelectionSignal={clearSelectionSignal} +/> +``` + +Clears arrive too, as a selection with `verses: []`. Type a handler with `BibleReaderVerseSelection` / `BibleReaderShareData`, both re-exported from this package. #### Custom picker flows diff --git a/apps/example/app/(tabs)/index.tsx b/apps/example/app/(tabs)/index.tsx index 52d8c81b..29ec375e 100644 --- a/apps/example/app/(tabs)/index.tsx +++ b/apps/example/app/(tabs)/index.tsx @@ -1,10 +1,21 @@ -import { BibleReader } from '@youversion/platform-react-native-expo-ui' -import { StyleSheet, useColorScheme, View } from 'react-native' +import { + BibleReader, + type BibleReaderVerseSelection, +} from '@youversion/platform-react-native-expo-ui' +import { useCallback, useState } from 'react' +import { Pressable, StyleSheet, Text, useColorScheme, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' export default function BibleScreen() { const isDark = useColorScheme() === 'dark' - const { top } = useSafeAreaInsets() + const { top, bottom } = useSafeAreaInsets() + + const [selectedVerses, setSelectedVerses] = useState(null) + const [clearSelectionSignal, setClearSelectionSignal] = useState(0) + + const onVerseSelect = useCallback(async (next: BibleReaderVerseSelection) => { + setSelectedVerses(next.verses.length > 0 ? next : null) + }, []) return ( - + + {selectedVerses ? ( + + + {selectedVerses.reference} + + setClearSelectionSignal((signal) => signal + 1)} + style={styles.clearButton} + > + Clear + + + ) : null} ) } @@ -22,4 +51,34 @@ const styles = StyleSheet.create({ container: { flex: 1, }, + selectionBar: { + position: 'absolute', + left: 16, + right: 16, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + paddingVertical: 10, + paddingHorizontal: 14, + borderRadius: 12, + backgroundColor: '#1f2933', + }, + selectionLabel: { + flexShrink: 1, + color: '#ffffff', + fontSize: 15, + fontWeight: '600', + }, + clearButton: { + paddingVertical: 6, + paddingHorizontal: 12, + borderRadius: 8, + backgroundColor: '#3e4c59', + }, + clearButtonLabel: { + color: '#ffffff', + fontSize: 14, + fontWeight: '600', + }, }) diff --git a/apps/example/app/_layout.tsx b/apps/example/app/_layout.tsx index ada45588..34b7ff40 100644 --- a/apps/example/app/_layout.tsx +++ b/apps/example/app/_layout.tsx @@ -24,7 +24,11 @@ export default function RootLayout() { diff --git a/packages/core/README.md b/packages/core/README.md index 95dedd29..424b951f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -56,6 +56,8 @@ export default function App() { `auth.permissions` asks for YouVersion Platform permissions (e.g. `'highlights'`) at sign-in; the user can decline. Read the grant back with `useYVAuth()`: `hasPermission(permission)`, or `grantedPermissions` for the list (`null` = nothing requested or nothing known yet, `[]` = declined). +`requestedPermissions` is the other side of that pair — what your app **asked for**, straight from this config, always an array (`[]` when `auth` is unconfigured). Some SDK behavior gates on the request rather than the grant: `useHighlights` issues no network request at all unless `'highlights'` is in this list, so an app that never asks pays nothing. + To ask an already signed-in user — without making them sign out — call `requestPermissions`: ```tsx @@ -78,10 +80,12 @@ If `redirectUri` disagrees with the callback URL registered for your app key, th `useHighlights` gives you a chapter's highlights, cached locally so they paint on the first frame, and write functions that apply optimistically and roll back on failure. +**You do not need this hook to show highlights in `BibleReader`.** That component subscribes to it internally for its own version / book / chapter and paints the result itself — highlights are not a prop you pass. Reach for `useHighlights` when you are building your own reading surface, or when you need the highlight data alongside the reader (a count, a summary, your own verse-action UI). + ```tsx import { useHighlights, HIGHLIGHT_COLORS } from '@youversion/platform-react-native-expo-core' -function Reader() { +function HighlightSummary() { const { highlights, apply, remove, isRefreshing, refresh } = useHighlights({ versionId: 111, book: 'JHN', @@ -95,11 +99,11 @@ function Reader() { } } - return + return {highlights.length} highlighted verses in John 3 } ``` -`highlights` is one entry per verse, ready for the reader's controlled `highlights` prop, and is always safe to render — `isRefreshing` only means a network refresh is in flight, so pair it with `RefreshControl` rather than gating a spinner on it. +`highlights` is one entry per verse and is always safe to render — `isRefreshing` only means a network refresh is in flight, so pair it with `RefreshControl` rather than gating a spinner on it. Writes resolve to a typed outcome rather than throwing: `{ status: 'ok', verses }`, `{ status: 'noop' }`, or `{ status: 'error', reason, message, failedVerses, succeededVerses }` where `reason` is `'not-signed-in' | 'auth' | 'invalid' | 'transient'`. Branch on `reason`, not `message` — the message is generic outside development builds. `failedVerses` is what to retry; `succeededVerses` being non-empty alongside it means the batch partly landed. diff --git a/packages/core/src/auth/__tests__/use-yv-auth.test.tsx b/packages/core/src/auth/__tests__/use-yv-auth.test.tsx index abc217e6..41c8d20a 100644 --- a/packages/core/src/auth/__tests__/use-yv-auth.test.tsx +++ b/packages/core/src/auth/__tests__/use-yv-auth.test.tsx @@ -21,6 +21,7 @@ describe('useYVAuth', () => { refreshNow: jest.fn(), ensureFreshToken: jest.fn(), isLoading: false, + requestedPermissions: ['highlights'], grantedPermissions: null, hasPermission: jest.fn(() => false), invalidatePermissions: jest.fn(), diff --git a/packages/core/src/auth/auth-context.tsx b/packages/core/src/auth/auth-context.tsx index f1ed36ca..72244a69 100644 --- a/packages/core/src/auth/auth-context.tsx +++ b/packages/core/src/auth/auth-context.tsx @@ -31,6 +31,12 @@ export type AuthContextValue = { */ ensureFreshToken: () => Promise isLoading: boolean + /** + * What the app **asked for** on its `auth` config — never what was granted. + * Always an array; `[]` when `auth` is unconfigured. Pairs with + * {@link grantedPermissions}, which answers the different question. + */ + requestedPermissions: readonly AuthPermission[] /** * Three-state grant: `null` = unknown / never requested, `[]` = requested and * denied, populated = granted. Unrecognized values are kept verbatim so a diff --git a/packages/core/src/auth/auth-provider.tsx b/packages/core/src/auth/auth-provider.tsx index 6d712bda..33591ad2 100644 --- a/packages/core/src/auth/auth-provider.tsx +++ b/packages/core/src/auth/auth-provider.tsx @@ -21,6 +21,10 @@ import { signInWithPKCE } from './pkce-flow' import { loadTokens, saveTokens, type StoredTokens } from './token-storage' import type { AuthConfig, AuthPermission, YVUserInfo } from './types' +// Stable empty reference, so an unconfigured `permissions` does not give the +// context value a new identity on every render. +const NO_PERMISSIONS: readonly AuthPermission[] = [] + type AuthProviderProps = { config: AuthConfig appKey: string @@ -292,6 +296,8 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth const refreshNow = useCallback(() => refreshToken({ force: true }), [refreshToken]) + const requestedPermissions = config.permissions ?? NO_PERMISSIONS + // The leeway-gated refresh, made public under a name that says what a caller // wants from it. A pre-flight before a permission-sensitive write needs "make // sure the token is usable" without paying for a token round-trip on every @@ -437,6 +443,7 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth refreshNow, ensureFreshToken, isLoading, + requestedPermissions, grantedPermissions, hasPermission, invalidatePermissions, @@ -451,6 +458,7 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth refreshNow, ensureFreshToken, isLoading, + requestedPermissions, grantedPermissions, hasPermission, invalidatePermissions, diff --git a/packages/core/src/highlights/__tests__/should-fetch-highlights.test.ts b/packages/core/src/highlights/__tests__/should-fetch-highlights.test.ts new file mode 100644 index 00000000..e15cd444 --- /dev/null +++ b/packages/core/src/highlights/__tests__/should-fetch-highlights.test.ts @@ -0,0 +1,54 @@ +/** + * Layer 1 — the fetch gate's truth table, pinned directly. + * + * The gate is deliberately keyed on what the app *requested*, not on what the + * user granted; the reasoning (and the failure mode each choice trades away) + * lives on the predicate itself. + */ +import type { AuthPermission } from '../../auth' +import { shouldFetchHighlights } from '../use-highlights' + +// The module under test reaches MMKV through the cache; the predicate itself is +// pure, so stub the native module rather than booting it. +jest.mock('../../storage/mmkv-storage', () => ({ + mmkvStorage: { + set: jest.fn(), + getString: jest.fn(), + remove: jest.fn(), + getAllKeys: jest.fn(() => []), + }, +})) + +describe('shouldFetchHighlights', () => { + it('skips when no permissions were requested', () => { + expect(shouldFetchHighlights([])).toBe(false) + }) + + it('fetches when `highlights` was requested', () => { + expect(shouldFetchHighlights(['highlights'])).toBe(true) + }) + + it('skips when other permissions were requested but not `highlights`', () => { + expect(shouldFetchHighlights(['bibles', 'votd'])).toBe(false) + }) + + it('fetches when `highlights` is one of several requested permissions', () => { + expect(shouldFetchHighlights(['bibles', 'highlights', 'demographics'])).toBe(true) + }) + + /** + * Regression guard for the `hasPermission` anti-pattern. The predicate takes + * the *requested* list and nothing else, so there is no grant state — known, + * denied, or unknown — that can turn a requested `highlights` into a skip. + * + * This must survive C3.1's tightening: when a real grant is consulted, only a + * KNOWN denial may skip. Unknown must still fetch, or every user already + * signed in when C3.1 ships loses their painted highlights until they + * re-authenticate. + */ + it('fetches on a requested permission regardless of any grant state', () => { + const requested: readonly AuthPermission[] = ['highlights'] + expect(shouldFetchHighlights(requested)).toBe(true) + expect(shouldFetchHighlights.length).toBe(1) + }) +}) diff --git a/packages/core/src/highlights/__tests__/use-highlight-permission-flow.test.tsx b/packages/core/src/highlights/__tests__/use-highlight-permission-flow.test.tsx index 27f00380..f1bce306 100644 --- a/packages/core/src/highlights/__tests__/use-highlight-permission-flow.test.tsx +++ b/packages/core/src/highlights/__tests__/use-highlight-permission-flow.test.tsx @@ -85,6 +85,11 @@ function authValue(state: AuthState): AuthContextValue { refreshNow: jest.fn(), ensureFreshToken: mockEnsureFreshToken, isLoading: false, + // An app that reaches this flow has asked for `highlights` — the reader + // never mounts the fetch otherwise (`shouldFetchHighlights`). What the user + // then granted is `state.permissions` below, which is what this suite + // varies. + requestedPermissions: ['highlights'], grantedPermissions: state.permissions, hasPermission: (permission) => { calls.push(`hasPermission:${permission}`) diff --git a/packages/core/src/highlights/__tests__/use-highlights.test.tsx b/packages/core/src/highlights/__tests__/use-highlights.test.tsx index e1ccbabe..d05cbbfb 100644 --- a/packages/core/src/highlights/__tests__/use-highlights.test.tsx +++ b/packages/core/src/highlights/__tests__/use-highlights.test.tsx @@ -107,6 +107,16 @@ const tokenLoading: AuthShape = { const refreshNow = jest.fn(async () => undefined) +/** + * The app asked for `highlights` but the user denied it — or the SDK never + * learned either way. Either way the fetch is still mounted; see + * `shouldFetchHighlights`. + */ +const signedInWithoutPermission: AuthShape = { + ...signedIn, + requestedPermissions: [], +} + // Hoisted rather than built per render, so a test can assert on it and can gate // it on a deferred promise. const ensureFreshToken = jest.fn(async () => undefined) @@ -122,6 +132,12 @@ function authValue(overrides: Partial): AuthContextValue { refreshNow, ensureFreshToken, isLoading: false, + // The default for every existing case: these tests exercise the fetch, so + // the app must have asked for the permission that mounts it. + requestedPermissions: ['highlights'], + // `grantedPermissions: null` is deliberate — the fetch gates on what was + // *requested*, so an unknown grant must still fetch. See the gate note in + // `shouldFetchHighlights`. grantedPermissions: null, hasPermission: jest.fn(() => false), invalidatePermissions: jest.fn(), @@ -345,6 +361,34 @@ describe('fetching server truth', () => { expect(result.current.highlights).toEqual([]) }) + it('does not fetch when the app never requested the highlights permission', async () => { + const { result } = renderUseHighlights(signedInWithoutPermission) + await act(async () => { + await Promise.resolve() + }) + expect(mockGetHighlights).not.toHaveBeenCalled() + expect(result.current.highlights).toEqual([]) + expect(result.current.isRefreshing).toBe(false) + }) + + it('still paints the cache when the permission was not requested', async () => { + seedCache([highlight('JHN.3.16', YELLOW)]) + const { result } = renderUseHighlights(signedInWithoutPermission) + await act(async () => { + await Promise.resolve() + }) + expect(mockGetHighlights).not.toHaveBeenCalled() + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + }) + + it('does not fetch on an explicit refresh when the permission was not requested', async () => { + const { result } = renderUseHighlights(signedInWithoutPermission) + await act(async () => { + await result.current.refresh() + }) + expect(mockGetHighlights).not.toHaveBeenCalled() + }) + it('drops a late response for a scope the reader has left', async () => { const pending = deferred, HighlightsApiError>>() mockGetHighlights.mockReturnValueOnce(pending.promise) @@ -438,6 +482,25 @@ describe('fetching server truth', () => { expect(result.current.isRefreshing).toBe(false) }) + it('clears isRefreshing when the permission gate closes on an in-flight fetch', async () => { + const pending = deferred, HighlightsApiError>>() + mockGetHighlights.mockReturnValueOnce(pending.promise) + + const { result, rerender } = renderUseHighlights() + expect(result.current.isRefreshing).toBe(true) + + // Revoking the request mid-fetch abandons the in-flight promise, so its + // `finally` no longer owns the flag. Nothing else would clear it. + setAuth(rerender, signedInWithoutPermission) + expect(result.current.isRefreshing).toBe(false) + + await act(async () => { + pending.resolve(collection([])) + await pending.promise + }) + expect(result.current.isRefreshing).toBe(false) + }) + it('shares one in-flight request between concurrent refresh calls', async () => { const pending = deferred, HighlightsApiError>>() mockGetHighlights.mockReturnValueOnce(pending.promise) diff --git a/packages/core/src/highlights/use-highlights.ts b/packages/core/src/highlights/use-highlights.ts index 58e33340..53d82508 100644 --- a/packages/core/src/highlights/use-highlights.ts +++ b/packages/core/src/highlights/use-highlights.ts @@ -1,6 +1,7 @@ import type { Highlight } from '@youversion/platform-core' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { AuthPermission } from '../auth' import { useYVAuthOptional } from '../auth' import { useYouVersion } from '../use-youversion' import { createHighlightsApi, type HighlightsApi, type HighlightsApiError } from './api' @@ -60,7 +61,11 @@ export type HighlightsFetchError = { } export type UseHighlightsResult = { - /** Per-verse passage ids, ascending, lowercase. Feed straight into a controlled reader. */ + /** + * Per-verse passage ids, ascending (e.g. `JHN.3.16`). Feed straight into a + * controlled reader. Book codes keep the case of the `book` they were + * requested for — the reader matches on them case-sensitively. + */ highlights: Highlight[] /** The scope these highlights belong to, so callers can gate an incoming intent. */ scope: HighlightScope @@ -107,6 +112,17 @@ function classifyApiError(error: HighlightsApiError): HighlightWriteReason { return 'transient' } +/** + * Should the highlights GET be mounted at all? + * + * Gated on what the app **requested** (`AuthConfig.permissions`), not on what the + * user granted, so an app that never asked for highlights issues no request. + * + */ +export function shouldFetchHighlights(requested: readonly AuthPermission[]): boolean { + return requested.includes('highlights') +} + let hasWarnedMissingUserId = false /** @@ -154,7 +170,9 @@ function sameIdentity(state: OptimisticState, identity: Identity): boolean { * `highlights` prop is pure projection. * * Requires `auth` to be configured on `YouVersionProvider`; with no auth - * configured it behaves exactly as signed out. + * configured it behaves exactly as signed out. It also requires `highlights` in + * that config's `permissions` — without it no GET is ever issued (see + * {@link shouldFetchHighlights}), so `highlights` stays whatever the cache holds. */ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResult { const { appKey, apiHost, installationId } = useYouVersion() @@ -163,6 +181,10 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul const accessToken = auth?.accessToken ?? null const isAuthLoading = auth?.isLoading ?? false const userId = auth?.userInfo?.id ?? null + // Config, not state: effectively constant for the life of the provider. Read + // through the closure rather than a ref so a change still re-runs the fetch + // effect below (`runFetch` is one of its deps). + const canFetchHighlights = shouldFetchHighlights(auth?.requestedPermissions ?? []) const ensureFreshToken = auth?.ensureFreshToken ?? null const scope = useMemo( @@ -255,6 +277,13 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul const inFlightRef = useRef | null>(null) const runFetch = useCallback((): Promise => { + // The app never asked for `highlights`, so a GET could only ever 403. + // Deliberately does not clear `isRefreshing`: the exposed value is derived + // against this same gate below, so a mid-fetch flip cannot strand it. + if (!canFetchHighlights) { + return Promise.resolve() + } + const existing = inFlightRef.current if (existing !== null) { return existing @@ -307,7 +336,7 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul inFlightRef.current = promise return promise - }, [api]) + }, [api, canFetchHighlights]) useEffect(() => { // Abandon any fetch belonging to the previous identity or token: there is no @@ -546,5 +575,13 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul const highlights = useMemo(() => selectHighlights(renderedState), [renderedState]) - return { highlights, scope, isRefreshing, error, refresh, apply, remove } + return { + highlights, + scope, + isRefreshing: isRefreshing && canFetchHighlights, + error, + refresh, + apply, + remove, + } } diff --git a/packages/ui/jest.setup.js b/packages/ui/jest.setup.js index 9a71446f..e598aad4 100644 --- a/packages/ui/jest.setup.js +++ b/packages/ui/jest.setup.js @@ -140,14 +140,40 @@ jest.mock('@youversion/platform-react-native-expo-core', () => { signOut: jest.fn(), refreshNow: jest.fn(), isLoading: false, + requestedPermissions: [], + } + } + + /** + * The real hook reads core's own `YouVersionContext`, which the passthrough + * provider above deliberately does not populate — so `BibleReader` would throw + * the moment it subscribes. Signed-out-shaped by default (`highlights: []`); + * suites that care about highlight data re-mock this module themselves. + */ + function useHighlights({ versionId, book, chapter }) { + return { + highlights: [], + scope: { versionId, book, chapter }, + isRefreshing: false, + error: null, + refresh: jest.fn(async () => undefined), + apply: jest.fn(async () => ({ status: 'noop' })), + remove: jest.fn(async () => ({ status: 'noop' })), } } return { + // Babel defines the real module's `__esModule` non-enumerably, so the spread + // above drops it. Without it back, `import * as core` runs through + // `_interopRequireWildcard`, which hands the importer a *copy* — and a + // `jest.spyOn(core, ...)` in a test file then patches the copy while the + // component under test keeps calling the original. + __esModule: true, ...actual, YouVersionProvider, useYouVersion, useYVAuth, + useHighlights, } }) diff --git a/packages/ui/src/dom/bible-reader.tsx b/packages/ui/src/dom/bible-reader.tsx index f0af535e..9141b898 100644 --- a/packages/ui/src/dom/bible-reader.tsx +++ b/packages/ui/src/dom/bible-reader.tsx @@ -1,14 +1,15 @@ 'use dom' -import type { YVUserInfo } from '@youversion/platform-react-native-expo-core' +import type { Highlight, YVUserInfo } from '@youversion/platform-react-native-expo-core' import type { BibleChapterPickerPressData, + BibleReaderVerseSelection, BibleVersionPickerPressData, FootnoteData, } from '@youversion/platform-react-ui' import { BibleReader } from '@youversion/platform-react-ui' import type { ComponentType, ReactNode } from 'react' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import type { StyleProp, ViewStyle } from 'react-native' import { applyAuthToken, applySDKConfig } from '../lib/dom-apply' @@ -29,6 +30,19 @@ type BibleReaderBaseProps = { apiHost: string installationId: string accessToken: string | null + /** + * Must be defined on the first render — its presence latches the reader into + * controlled mode, and omitting it lets the WebView fetch and write highlights + * with the token we hand it. Pass `[]` for "nothing highlighted". + */ + highlights: Highlight[] + /** + * Fires on every selection change, clears included (`verses: []`). Carries the + * selected verses, their passage ids, a localized `reference`, and `shareData`. + */ + onVerseSelect?: (selection: BibleReaderVerseSelection) => Promise + /** Increment to clear the current selection. Its value at mount never clears. */ + clearSelectionSignal?: number theme?: 'light' | 'dark' book?: string chapter?: string @@ -73,6 +87,9 @@ export default function BibleReaderDOM(props: BibleReaderProps) { apiHost, installationId, accessToken, + highlights, + onVerseSelect, + clearSelectionSignal, theme = 'light', book, chapter, @@ -102,6 +119,34 @@ export default function BibleReaderDOM(props: BibleReaderProps) { applySDKConfig({ appKey, apiHost, installationId }) applyAuthToken(accessToken) + // `highlights` is required, but this is the far side of a serialization + // boundary, so a bad value arrives as `undefined` with no compile-time trace. + // Coerce, don't just warn — the warning compiles out in production, and a + // missing prop hands the WebView back the ability to write highlights. + const safeHighlights = Array.isArray(highlights) ? highlights : [] + if (process.env.NODE_ENV !== 'production' && !Array.isArray(highlights)) { + console.error( + `[YouVersion SDK] BibleReader received a non-array \`highlights\` prop. The reader falls back to self-contained mode when this prop is missing, which lets the WebView write highlights itself. Pass \`[]\` for "nothing highlighted".`, + ) + } + + // The Web SDK calls this synchronously and ignores the return value, but a + // native action can only be async — so fire and forget. Catch rather than + // `void`: expo's `marshal` rejects when the consumer's handler throws, and an + // unattached rejection surfaces as an unhandled rejection in the DOM. + const handleVerseSelect = useMemo( + () => + onVerseSelect + ? (selection: BibleReaderVerseSelection) => { + // `Promise.resolve` so a sync handler (no bridge) can't throw on `.catch`. + Promise.resolve(onVerseSelect(selection)).catch((error: unknown) => { + console.error('[YouVersion SDK] onVerseSelect handler rejected:', error) + }) + } + : undefined, + [onVerseSelect], + ) + // fontFamily crosses the bridge as a quote-free token; resolve it back to the // canonical CSS stack the Web SDK expects. See lib/reader-fonts.ts. const resolvedFontFamily = decodeFontFamilyFromDom(fontFamily) @@ -162,15 +207,10 @@ export default function BibleReaderDOM(props: BibleReaderProps) {
Promise + onChapterChange?: (chapter: string) => Promise +} + +/** Every render's props, so "always an array" can be checked, not just "eventually". */ +let mockDomPropsHistory: CapturedDomProps[] = [] + +/** + * Read at press time, not at factory time, so `beforeEach` can swap it without + * tripping the mock-factory hoist. + */ +let mockVerseSelection: BibleReaderVerseSelection + +jest.mock('../../dom/bible-reader', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View, Pressable, Text } = require('react-native') + return { + __esModule: true, + default: function MockDOM(props: CapturedDomProps) { + mockDomPropsHistory.push(props) + return ( + + props.onVerseSelect?.(mockVerseSelection)} + > + Select + + props.onChapterChange?.('4')}> + Next chapter + + + ) + }, + } +}) + +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('../../stores/reader-settings-store', () => ({ + useReaderSettingsStore: () => ({ + fontSize: 16, + fontFamily: '"Inter", sans-serif', + lineSpacing: 1.5, + setFontSize: jest.fn(), + setFontFamily: jest.fn(), + setLineSpacing: jest.fn(), + }), +})) + +const YELLOW = 'fffe00' + +function highlight(passageId: string, versionId = 111): Highlight { + return { version_id: versionId, passage_id: passageId, color: YELLOW } +} + +/** + * The hook is already stubbed signed-out-shaped in `jest.setup.js` (the real one + * needs core's own provider, which UI tests replace). Steer it per test rather + * than re-mocking the whole package and losing that passthrough provider. + */ +const useHighlightsSpy = jest.spyOn(core, 'useHighlights') + +function stubHighlights(highlights: Highlight[]) { + useHighlightsSpy.mockImplementation(({ versionId, book, chapter }) => ({ + highlights, + scope: { versionId, book, chapter }, + isRefreshing: false, + error: null, + refresh: jest.fn(async () => undefined), + apply: jest.fn(async () => ({ status: 'noop' }) as const), + remove: jest.fn(async () => ({ status: 'noop' }) as const), + })) +} + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +) + +function lastDomProps(): CapturedDomProps { + const props = mockDomPropsHistory.at(-1) + if (props === undefined) { + throw new Error('The DOM component never rendered.') + } + return props +} + +beforeEach(async () => { + mockDomPropsHistory = [] + mockVerseSelection = { + versionId: 111, + book: 'HEB', + chapter: '11', + verses: [4, 5], + passageIds: ['HEB.11.4', 'HEB.11.5'], + reference: 'Hebrews 11:4-5', + shareData: { + text: '“By faith…”\n\nHebrews 11:4-5 BSB', + reference: 'Hebrews 11:4-5 BSB', + verseText: 'By faith…', + verses: [4, 5], + book: 'HEB', + chapter: '11', + versionId: 111, + }, + } + stubHighlights([]) + mmkvStorage.clearAll() + useReaderLocationStore.setState(readerLocationStoreInitialState) + await useReaderLocationStore.persist.rehydrate() +}) + +afterAll(() => { + useHighlightsSpy.mockRestore() +}) + +describe('the controlled-mode latch', () => { + it('hands the DOM component an array on the very first render', () => { + render(, { wrapper }) + + expect(mockDomPropsHistory.length).toBeGreaterThan(0) + expect(Array.isArray(mockDomPropsHistory[0]?.highlights)).toBe(true) + }) + + it('never renders with an undefined highlights prop, on any render', async () => { + stubHighlights([highlight('JHN.3.16')]) + const { getByTestId } = render(, { wrapper }) + + await act(async () => { + fireEvent.press(getByTestId('trigger-chapter-change')) + }) + + expect(mockDomPropsHistory.length).toBeGreaterThan(1) + for (const props of mockDomPropsHistory) { + expect(Array.isArray(props.highlights)).toBe(true) + } + }) + + // The hook is stubbed, so this pins the wrapper's pass-through, not the hook's + // no-auth behaviour — core covers that in `use-highlights.test.tsx`. + it('passes an empty hook result through as [], never undefined', () => { + render(, { wrapper }) + expect(lastDomProps().highlights).toEqual([]) + }) + + it('forwards the hook’s highlights verbatim, with no adapter in between', () => { + const data = [highlight('JHN.3.16'), highlight('JHN.3.17-18')] + stubHighlights(data) + + render(, { wrapper }) + + expect(lastDomProps().highlights).toEqual(data) + }) +}) + +describe('the highlights subscription scope', () => { + it('subscribes to the reader’s current location', () => { + render(, { wrapper }) + + expect(useHighlightsSpy).toHaveBeenCalledWith({ versionId: 111, book: 'ROM', chapter: '8' }) + }) + + it('re-scopes when the WebView navigates to another chapter', async () => { + const { getByTestId } = render(, { + wrapper, + }) + + await act(async () => { + fireEvent.press(getByTestId('trigger-chapter-change')) + }) + + expect(useHighlightsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ book: 'ROM', chapter: '4' }), + ) + }) +}) + +describe('the verse-action event set', () => { + it('sends no highlight-intent or copy/share handlers across the bridge', () => { + render(, { wrapper }) + + const props = lastDomProps() + expect(props).not.toHaveProperty('onHighlightApply') + expect(props).not.toHaveProperty('onHighlightRemove') + expect(props).not.toHaveProperty('onCopy') + expect(props).not.toHaveProperty('onShare') + }) + + it('does not let a consumer choose the verse-action UI', () => { + render(, { wrapper }) + expect(lastDomProps()).not.toHaveProperty('verseActions') + }) +}) + +describe('onVerseSelect', () => { + it('reaches the consumer prop', async () => { + const onVerseSelect = jest.fn(async () => undefined) + const { getByTestId } = render(, { wrapper }) + + await act(async () => { + fireEvent.press(getByTestId('trigger-verse-select')) + }) + + expect(onVerseSelect).toHaveBeenCalledWith(mockVerseSelection) + }) + + it('carries a payload that survives the bridge unchanged', async () => { + const onVerseSelect = jest.fn(async (_selection: BibleReaderVerseSelection) => undefined) + const { getByTestId } = render(, { wrapper }) + + await act(async () => { + fireEvent.press(getByTestId('trigger-verse-select')) + }) + + const received = onVerseSelect.mock.calls[0]![0] + // Only JSON-safe primitives cross an Expo DOM boundary; anything that did + // not survive this round-trip would arrive mangled or missing on device. + expect(JSON.parse(JSON.stringify(received))).toEqual(received) + // The localized reference is the label a host renders; the USFM book code is + // not human-readable and must not be what arrives. + expect(received.reference).toBe('Hebrews 11:4-5') + }) + + it('is absent from the DOM props when the consumer passes no handler', () => { + render(, { wrapper }) + expect(lastDomProps().onVerseSelect).toBeUndefined() + }) +}) + +describe('clearSelectionSignal', () => { + it('crosses the bridge as a number', () => { + render(, { wrapper }) + expect(lastDomProps().clearSelectionSignal).toBe(0) + }) + + it('forwards each increment', () => { + const { rerender } = render(, { wrapper }) + expect(lastDomProps().clearSelectionSignal).toBe(0) + + rerender() + expect(lastDomProps().clearSelectionSignal).toBe(1) + }) + + it('crosses as a number even when the consumer never clears', () => { + render(, { wrapper }) + expect(lastDomProps().clearSelectionSignal).toBe(0) + }) + + it('does not change when a consumer starts passing the baseline value late', () => { + // The Web SDK clears the selection whenever this value differs from the one + // it saw last. `undefined` on the first render followed by `0` on a later + // one is a change, so an undefaulted prop would clear a selection the user + // is still looking at. The default keeps both renders at `0`. + const { rerender } = render(, { wrapper }) + expect(lastDomProps().clearSelectionSignal).toBe(0) + + rerender() + expect(lastDomProps().clearSelectionSignal).toBe(0) + }) +}) + +/** + * `verseActions="none"` is set inside the `'use dom'` file, on the Web SDK root + * — it never crosses the bridge, so no test that mocks the DOM component (i.e. + * every layer-3 test) can observe it, and this repo has no jsdom project to + * render the real thing in. This reads the source instead. Crude, but the line + * it guards is the one that keeps a second verse-action popover from stacking + * over the native sheet and keeps the Web SDK's own highlight writes switched + * off; leaving it with no regression guard at all was the worse trade. + */ +describe('the DOM component source (unobservable from layer 3)', () => { + const source = readFileSync(join(__dirname, '../../dom/bible-reader.tsx'), 'utf8') + + it('hardcodes verseActions="none" on the Web SDK reader root', () => { + // Anchored to a line that is nothing but the JSX prop. A plain substring + // check would also be satisfied by the JSDoc in that file that mentions + // `verseActions="none"` in prose, so deleting the real prop would leave + // this green. + expect(source).toMatch(/^\s*verseActions="none"$/m) + }) + + it('wires no Web SDK highlight-intent or copy/share handlers', () => { + expect(source).not.toContain('onHighlightApply') + expect(source).not.toContain('onHighlightRemove') + expect(source).not.toContain('onCopy=') + expect(source).not.toContain('onShare=') + }) +}) diff --git a/packages/ui/src/native/bible-reader.tsx b/packages/ui/src/native/bible-reader.tsx index 5bfd7b07..6dacb9b0 100644 --- a/packages/ui/src/native/bible-reader.tsx +++ b/packages/ui/src/native/bible-reader.tsx @@ -1,5 +1,9 @@ import { useControllableState } from '@radix-ui/react-use-controllable-state' -import { useYouVersion, useYVAuthOptional } from '@youversion/platform-react-native-expo-core' +import { + useHighlights, + useYouVersion, + useYVAuthOptional, +} from '@youversion/platform-react-native-expo-core' import type { BibleChapterPickerPressData, BibleVersionPickerPressData, @@ -34,9 +38,16 @@ const EMPTY_FOOTNOTE: FootnoteData = { const DEFAULT_BOOK = 'JHN' const DEFAULT_CHAPTER = '1' +/** + * Re-exported so an `onVerseSelect` handler can be typed without depending on + * `@youversion/platform-react-ui` directly. + */ +export type { BibleReaderShareData, BibleReaderVerseSelection } from '@youversion/platform-react-ui' + export type BibleReaderProps = Omit< DomBibleReaderProps, | 'appKey' + | 'highlights' | 'fontSize' | 'fontFamily' | 'lineSpacing' @@ -55,9 +66,10 @@ export type BibleReaderProps = Omit< | 'onSignOutPress' | 'onExternalLinkPress' | 'userInfo' - // The reader owns its own bottom scroll padding (tab bar + home indicator on iOS), - // so consumers don't pass it — it lives inside the WebView. + // The reader owns its bottom scroll padding (tab bar + home indicator on iOS). | 'bottomScrollPadding' + // `onVerseSelect` and `clearSelectionSignal` are deliberately kept — they are + // the consumer's only handle on a selection. > & { theme?: 'light' | 'dark' | 'system' defaultBook?: string @@ -82,6 +94,14 @@ export function BibleReader({ onChapterPickerPress: consumerOnChapterPickerPress, onVersionPickerPress: consumerOnVersionPickerPress, onFootnotePress: consumerOnFootnotePress, + onVerseSelect, + // Defaulted to `0` so a number always crosses the bridge from first mount. + // The Web SDK treats the value it sees at mount as a baseline and clears the + // selection on every change after that. Leaving this `undefined` means a + // consumer who starts passing the signal later trips `undefined !== 0` and + // fires a spurious clear on their first render with the prop. The prop stays + // optional in the public type — this is a default, not a requirement. + clearSelectionSignal = 0, backgroundColor, foregroundColor, dom, @@ -140,6 +160,8 @@ export function BibleReader({ }, }) + const { highlights } = useHighlights({ versionId, book, chapter }) + 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) @@ -250,6 +272,9 @@ export function BibleReader({ apiHost={context.apiHost} installationId={context.installationId} accessToken={accessToken} + highlights={highlights} + onVerseSelect={onVerseSelect} + clearSelectionSignal={clearSelectionSignal} onSignInPress={signIn} onSignOutPress={signOut} userInfo={userInfo} diff --git a/packages/ui/src/native/index.ts b/packages/ui/src/native/index.ts index 112de99f..30283b3b 100644 --- a/packages/ui/src/native/index.ts +++ b/packages/ui/src/native/index.ts @@ -3,7 +3,11 @@ export type { BibleCardProps } from './bible-card' export { BibleChapterPickerSheet } from './bible-chapter-picker-sheet' export type { BibleChapterPickerSheetProps } from './bible-chapter-picker-sheet' export { BibleReader } from './bible-reader' -export type { BibleReaderProps } from './bible-reader' +export type { + BibleReaderProps, + BibleReaderShareData, + BibleReaderVerseSelection, +} from './bible-reader' export { BibleReaderSettingsSheet } from './bible-reader-settings-sheet' export type { BibleReaderSettingsSheetProps } from './bible-reader-settings-sheet' export { BibleTextView } from './bible-text-view' From b5c094dfb58c95a865eb2132e6f417cc55494287 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Thu, 6 Aug 2026 14:29:37 -0500 Subject: [PATCH 13/43] feat(ui): verse actions are a native bottom sheet (YPE-3712) (U2) (#120) * chore: ignore riptide task artifacts Co-Authored-By: Claude Opus 5 * feat(ui): verse selection raises a native action sheet (YPE-3712) (1/5) Selecting a verse in BibleReader now raises a non-modal BibleVerseActionSheet showing the localized reference, Copy, and Share. The WebView popover stays off (verseActions="none" from U1), so this is the native replacement. - NativeSheet gains modal?: boolean. Non-modal renders no backdrop and, on Android, keeps pointerEvents at box-none while active, so a second verse can be tapped while the sheet is open. - Every themed sheet gains an upward SHEET_TOP_SHADOW, keyed off theme. An explicit backgroundColor with no theme gets no shadow. - The reader forwards clearSelectionSignal + an internal counter. Both start at 0, so the consumer's public prop keeps working alongside the reader's own exits (swipe-down, deselect, acting on the sheet). - onCopy / onShare are new native-only props. They never cross the bridge. Without them, Copy writes through expo-clipboard and Share opens RN's Share. Swatches, the sign-in gate, and the consent sheet follow in later phases. Co-Authored-By: Claude Opus 5 * feat(ui): the verse action sheet paints and writes highlights (YPE-3712) (2/5) The swatch tray becomes the sheet's main control. A signed-in user with the highlights permission can apply and remove highlights without leaving the sheet. - buildVerseActionSwatches projects the selection's server colours onto the five-swatch palette. Remove uses the ANY rule, matching Swift and Kotlin: a colour on any selected verse offers removal. We still diverge on their add list, which is NOT-ALL; that only shows with all five colours active. - The reader swaps useHighlights for useHighlightPermissionFlow. The flow calls useHighlights itself, so there is no second subscription. - A remove swatch routes to flow.highlights.remove, an apply swatch to flow.apply. Both close the sheet immediately: the write is fire-and-forget and the paint is optimistic. - jest.setup.js stubs useHighlightPermissionFlow. The stub reaches useHighlights through the module object, not the local binding, so the bridge test's jest.spyOn(core, 'useHighlights') still steers the reader. The signed-out and no-permission paths land in the next phase. Co-Authored-By: Claude Opus 5 * feat(ui): sign-in and consent sheets gate the highlight write (YPE-3712) (3/5) The signed-out and no-permission paths get native UI. Exactly one sheet is ever active, so displacement never clears the verse selection as a side effect. - SignInWithYouVersionSheet is a reader-owned pre-step. The Permission Flow has no sign-in prompt state of its own; it calls auth.signIn() directly. So the reader stashes the swatch intent, prompts, and on confirm hands the intent to flow.apply(), which runs sign-in, falls through to consent if needed, and writes. Dismiss discards the intent and writes nothing. - HighlightConsentSheet is driven by flow.isConfirming. Every dismissal path reaches decline(). AGENTS.md records this sheet as blocked on localization; the dataExchange* keys have since landed. - The action sheet gate is selection !== null && prompt === 'none' && !flow.isConfirming. - A remove swatch opens neither prompt. A user with visible highlights already holds the grant (ADR 0016). The signed-in read is `auth !== null && !auth.isAuthenticated`, not the outline's `auth?.isAuthenticated ?? false`. A null auth means the consumer configured none at all, not "signed out": flow.apply warns and falls through to the unguarded write, so prompting would open a sheet whose only outcome is the one the user already had. Covered by a named test. Co-Authored-By: Claude Opus 5 * feat(ui): web keeps the in-WebView verse action popover (YPE-3712) (4/5) NativeSheet returns null on web, so a web host running verseActions="none" had no verse actions at all. resolveVerseActions forks it: web gets the popover, every other platform gets none and uses the native sheet. The DOM file reads the resolved value from a prop rather than calling Platform.OS itself. 'use dom' files execute in the WebView, where react-native's Platform is not the host's. This reverses PR #118's decision 5, which removed the fork on the grounds that web is not a supported target. Cam settled it: the fork ships. The source-text guard #118 added is retargeted rather than deleted, and stays line-anchored so the JSDoc discussing both values cannot satisfy it. Note the web branch has no runtime coverage. There is no web CI job and no web build script; its whole coverage is four layer-1 cases and two layer-3 cases. Co-Authored-By: Claude Opus 5 * docs(ui): ADR 0017, docs, and changeset for the verse action sheet (YPE-3712) (5/5) - docs/adr/0017-native-verse-action-sheet.md records the design, ported from ADR 0015 on PR #104 with three corrections: the remove rule is ANY on both Swift and Kotlin (cited by line, not "believed"); the sign-in gate is the base's Permission Flow, not the reverted highlight-tap-gate.ts; and the web fork reverses PR #118 decision 5 and has no runtime coverage. - The ADR closes with a verification table naming what was device-verified (iOS, Phase 1) and what never ran (Android, web, the swatch and prompt paths). The gap is written down rather than left to be rediscovered. - The example app's selection strip moved to the top of the screen. The non-modal sheet covers the bottom, so Clear was unreachable exactly when it mattered. onCopy / onShare sit behind a Switch so the SDK fallbacks stay testable on device. - README, packages/ui/README, CONTEXT.md, and AGENTS.md document the sheet, the swatch projection, onCopy / onShare, and the web popover. AGENTS.md's "consent sheet is not built yet" paragraph is replaced. - Changeset ships both packages minor. Core has no source change, but .changeset/config.json holds them fixed, so they version together. Co-Authored-By: Claude Opus 5 * fix(docs): standardize color spelling and simplify documentation * fix(ui): pending highlight loses scope * refactor(ui): rename selection state variables for clarity in BibleReader * fix(ui): prompt state management in BibleReader * fix(ui): improve destructuring of highlightPermissionFlow in BibleReader and enhance test clarity * refactor: address PR #120 review findings Sheet theme tokens were declared three times, once per sheet, with the same hex values. They now live in `lib/native-sheet-theme.ts` alongside the surface tokens already there: SHEET_FOREGROUND, SHEET_INVERSE_FOREGROUND, SHEET_MUTED_FOREGROUND, SHEET_STROKE. The two prompt sheets were near line-for-line twins. Their button and supporting paragraph move to `native/prompt-sheet.tsx`. Each sheet keeps its own NativeSheet, container, and headline, which is where they legitimately differ. The file is internal and stays out of the barrel, matching every other sheet. Types: PendingSwatchIntent.color is HighlightColor, not string. resolveVerseActions takes PlatformOSType, not string. Both types already existed and are already used elsewhere. Tests: the two new suites now use userEvent for presses, per the repo convention. The `act` wrappers around them are gone, since userEvent self-wraps. fireEvent stays for layout, contentSizeChange, and the scroll offset in measureTray, which stand in for the layout pass rather than a gesture. Docs: the changeset described web popover behavior nobody had observed. It now states the timing fact instead, that native verse actions are not on web in this release. ADR 0017 labels the same claim as an inference from source. Its verification table records the Android pass of 2026-08-06, and swipe-down as done on both platforms. Co-Authored-By: Claude Opus 5 * docs: simplify PR #120 copy and comments Rewrite the prose this PR adds, using the simple-english rules: one idea per sentence, active voice, conditions before commands, American spelling, and no semicolons. Surfaces touched: - ADR 0017 and the native-verse-action-sheet changeset, both rewritten in full. The ADR's verification table is unchanged. - AGENTS.md, CONTEXT.md, README.md, packages/ui/README.md. - Comments and JSDoc across the 16 source files this PR adds or changes. The largest surface is packages/ui/src/native/bible-reader.tsx. - Two changesets from the base branch, where this PR already touched the line. No comment was deleted. Each one carried a load-bearing fact, so each was rewritten instead. No code logic changed. Co-Authored-By: Claude Opus 5 * fix(ui): the verse action swatch tray scrolls on Android (YPE-3712) The tray did not scroll on Android at all, so any swatch past the sixth was unreachable by touch. Six fit; a selection spanning two existing highlight colours already produces seven. Gorhom builds its content pan with no activation criteria, so RNGH falls back to a direction-agnostic touch slop. A sideways drag over the tray activated the sheet's pan, and activation cancels the touch stream in every native view underneath it, so the ScrollView never saw a move. BibleVerseActionSheet now passes panActiveOffsetY={[-10, 10]}, forwarded by NativeSheet to Gorhom's activeOffsetY. Supplying any custom criterion makes RNGH drop minDist, so a horizontal drag no longer reaches the sheet; Android's ScrollView hands back via requestDisallowInterceptTouch Event. enableContentPanningGesture={false} cures the same symptom but kills swipe-down, this sheet's only backdrop-free exit, so ADR 0017 records it as rejected. Verified on a Pixel 6 Pro API 34 emulator: the tray scrolls, every hidden swatch is reachable, and swipe-down still dismisses from both the grabber and the sheet body. Co-Authored-By: Claude Opus 5 * chore: ignore the whole .humanlayer directory Broadens the existing .humanlayer/tasks/ rule to cover workspace.json, the riptide multi-repo workspace config, which is local tooling state rather than source. Co-Authored-By: Claude Opus 5 * chore: run prettier across the repo Formatting only, no behaviour change. Ten of these files were already failing format:check before this branch; the eleventh is the verse action ADR, where prettier realigned a table edited in 7c00f4d. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: Dustin Kelley --- .changeset/highlight-paint-before-refresh.md | 2 +- .changeset/native-verse-action-sheet.md | 28 + .../reader-renders-native-owned-highlights.md | 2 +- ...verse-action-swatch-tray-android-scroll.md | 11 + .gitignore | 3 + AGENTS.md | 35 +- CONTEXT.md | 18 +- README.md | 34 +- apps/example/app/(tabs)/_layout.tsx | 3 +- apps/example/app/(tabs)/index.tsx | 100 ++- apps/example/package.json | 1 + ...0013-native-highlights-optimistic-layer.md | 14 +- docs/adr/0016-highlight-permission-flow.md | 2 +- docs/adr/0017-native-verse-action-sheet.md | 165 +++++ docs/bug-reports/auth-website-issues.md | 11 +- ...icker-shell-and-dom-ui-state-2026-05-18.md | 12 +- .../highlights/__tests__/optimistic.test.ts | 2 +- .../__tests__/use-highlights.test.tsx | 2 +- packages/core/src/result.ts | 4 +- packages/ui/README.md | 1 + packages/ui/jest.setup.js | 26 +- packages/ui/package.json | 2 + packages/ui/src/dom/bible-reader.tsx | 13 +- .../__tests__/dom-dismiss-keyboard.test.ts | 9 +- .../__tests__/resolve-verse-actions.test.ts | 32 + .../__tests__/verse-action-swatches.test.ts | 128 ++++ packages/ui/src/lib/app-name.ts | 20 + packages/ui/src/lib/native-sheet-theme.ts | 56 ++ packages/ui/src/lib/resolve-verse-actions.ts | 17 + packages/ui/src/lib/verse-action-swatches.ts | 65 ++ .../src/native/__tests__/bible-card.test.tsx | 38 +- ...ible-reader-bottom-scroll-padding.test.tsx | 5 +- .../bible-reader-highlights-bridge.test.tsx | 55 +- .../bible-reader-highlights-prompts.test.tsx | 486 +++++++++++++ .../bible-reader-verse-actions.test.tsx | 647 ++++++++++++++++++ .../native/__tests__/native-sheet.test.tsx | 130 +++- packages/ui/src/native/bible-reader.tsx | 289 +++++++- .../src/native/bible-verse-action-sheet.tsx | 339 +++++++++ .../ui/src/native/highlight-consent-sheet.tsx | 95 +++ packages/ui/src/native/icons/check-icon.tsx | 25 + packages/ui/src/native/icons/copy-icon.tsx | 32 + packages/ui/src/native/icons/index.ts | 3 + packages/ui/src/native/icons/share-icon.tsx | 31 + packages/ui/src/native/native-sheet.tsx | 57 +- packages/ui/src/native/prompt-sheet.tsx | 96 +++ .../native/sign-in-with-youversion-sheet.tsx | 102 +++ .../src/native/youversion-platform-logo.tsx | 43 ++ pnpm-lock.yaml | 284 ++++---- scripts/test-native-i18n-eslint.mjs | 12 +- 49 files changed, 3345 insertions(+), 242 deletions(-) create mode 100644 .changeset/native-verse-action-sheet.md create mode 100644 .changeset/verse-action-swatch-tray-android-scroll.md create mode 100644 docs/adr/0017-native-verse-action-sheet.md create mode 100644 packages/ui/src/lib/__tests__/resolve-verse-actions.test.ts create mode 100644 packages/ui/src/lib/__tests__/verse-action-swatches.test.ts create mode 100644 packages/ui/src/lib/app-name.ts create mode 100644 packages/ui/src/lib/resolve-verse-actions.ts create mode 100644 packages/ui/src/lib/verse-action-swatches.ts create mode 100644 packages/ui/src/native/__tests__/bible-reader-highlights-prompts.test.tsx create mode 100644 packages/ui/src/native/__tests__/bible-reader-verse-actions.test.tsx create mode 100644 packages/ui/src/native/bible-verse-action-sheet.tsx create mode 100644 packages/ui/src/native/highlight-consent-sheet.tsx create mode 100644 packages/ui/src/native/icons/check-icon.tsx create mode 100644 packages/ui/src/native/icons/copy-icon.tsx create mode 100644 packages/ui/src/native/icons/index.ts create mode 100644 packages/ui/src/native/icons/share-icon.tsx create mode 100644 packages/ui/src/native/prompt-sheet.tsx create mode 100644 packages/ui/src/native/sign-in-with-youversion-sheet.tsx create mode 100644 packages/ui/src/native/youversion-platform-logo.tsx diff --git a/.changeset/highlight-paint-before-refresh.md b/.changeset/highlight-paint-before-refresh.md index 48e77dbf..df4c9a96 100644 --- a/.changeset/highlight-paint-before-refresh.md +++ b/.changeset/highlight-paint-before-refresh.md @@ -2,6 +2,6 @@ '@youversion/platform-react-native-expo-core': patch --- -Fix a highlight not painting until the token refresh in front of it finished. `useHighlightPermissionFlow.apply` awaited `ensureFreshToken()` before it reached the code that paints, so whenever a refresh was actually due — the access token at or inside its 60-second leeway, or a refresh already running from the `AppState` foreground listener — the user tapped a colour and watched nothing happen for a full token round-trip. +Fix a highlight not painting until the token refresh in front of it finished. `useHighlightPermissionFlow.apply` awaited `ensureFreshToken()` before it reached the code that paints. A refresh is due when the access token sits at or inside its 60-second leeway, or when the `AppState` foreground listener already started one. In both cases the user tapped a color and watched nothing happen for a full token round-trip. The refresh moved into `useHighlights.runWrite`, next to the existing auth-settled wait. The token is still current when the request goes out, which is the property that keeps a 401 from being misread as a stale permission grant, but the optimistic claim now paints on tap. `remove` and any direct `useHighlights` consumer pick up the same freshness guarantee, which previously only `apply` had. diff --git a/.changeset/native-verse-action-sheet.md b/.changeset/native-verse-action-sheet.md new file mode 100644 index 00000000..7354475f --- /dev/null +++ b/.changeset/native-verse-action-sheet.md @@ -0,0 +1,28 @@ +--- +'@youversion/platform-react-native-expo-core': minor +'@youversion/platform-react-native-expo-ui': minor +--- + +Verse actions in `BibleReader` are now a native bottom sheet, matching the Swift and Kotlin SDKs. + +Selecting a verse raises a native sheet with the localized reference, the highlight color swatches, Copy, and Share. It replaces the in-WebView popover the previous release switched off, so the actions that release removed are back as native UI. There is nothing to enable: no new prop, no opt-in. + +**Action required. Install `expo-clipboard` and rebuild your dev client.** `expo-clipboard` is a new peer dependency, and it backs the Copy fallback. It is a native module, so a JS-only reload cannot link it. `expo-application` is also now a UI peer dependency. Apps that already use the core package have it. + +```bash +npx expo install expo-clipboard expo-application +``` + +What the sheet does: + +- **Highlight swatches.** A remove circle for every color present on any selected verse, then an apply circle for each of the five palette colors. Writes go through the same highlights service as `useHighlights`, so the passage repaints at once and the sheet closes. +- **Sign-in and permission prompts.** The sheet asks a signed-out user, or one without the `highlights` permission, for exactly what is missing. It then applies their color choice, with no reselecting of the verse. This needs an `auth` config that requests the `highlights` permission. Without one, the swatches behave as they do for a signed-out user. +- **Copy and Share.** They fall back to `expo-clipboard` and React Native's `Share`. Two new optional props on `BibleReader`, `onCopy` and `onShare`, take either one over. Both receive the `BibleReaderShareData` this package already re-exports. + +**The sheet has no backdrop, and that is deliberate.** A backdrop intercepts the second verse tap, and extending a selection one verse at a time is the point. The consequence is that a tap outside does not dismiss the sheet. To dismiss it, swipe down, deselect the verses, or act on the sheet. Every themed bottom sheet in the SDK now draws an upward drop shadow, so a sheet without a backdrop still separates from the content behind it. + +`onVerseSelect` and `clearSelectionSignal` are unchanged and still public. The first fires alongside the sheet rather than instead of it. The second closes the sheet along with the selection. + +**Web keeps the React Web SDK's verse action popover.** Native verse actions 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 popover is what web gets until the native surface reaches it. + +Nothing changes in the core package's public API. It versions alongside UI. diff --git a/.changeset/reader-renders-native-owned-highlights.md b/.changeset/reader-renders-native-owned-highlights.md index 3ed50127..3b320388 100644 --- a/.changeset/reader-renders-native-owned-highlights.md +++ b/.changeset/reader-renders-native-owned-highlights.md @@ -7,7 +7,7 @@ The reader subscribes to `useHighlights` for its current version / book / chapter and feeds the result into the Web SDK reader as a controlled prop. Because the MMKV cache read is synchronous, highlights are in the very first props — no blank first frame. Nothing about the highlight path runs inside the WebView any more: no network calls, no local store, no auth surface. -**`verseActions="none"` is now hardcoded.** Until the native verse action sheet lands (YPE-3712), selecting a verse raises **no** action UI inside the reader — the colour swatches, Copy, and Share buttons are gone. Verse selection and selection painting are unchanged. Two new props replace what the popover provided: +**`verseActions="none"` is now hardcoded.** Until the native verse action sheet lands (YPE-3712), selecting a verse raises **no** action UI inside the reader. The color swatches, Copy, and Share buttons are gone. Verse selection and selection painting are unchanged. Two new props replace what the popover provided: - `onVerseSelect(selection)` fires on every selection change, including clears (`verses: []`). The payload carries `versionId`, `book`, `chapter`, `verses`, `passageIds`, a localized `reference` (`Hebrews 11:4`, not `HEB 11:4`), and the `shareData` the popover's Copy / Share buttons would have used — all bridge-safe primitives. - `clearSelectionSignal` dismisses the current selection from native. Increment it; the value at mount is the baseline, so mounting never clears. A counter rather than an imperative ref handle because only serializable props cross the DOM bridge. diff --git a/.changeset/verse-action-swatch-tray-android-scroll.md b/.changeset/verse-action-swatch-tray-android-scroll.md new file mode 100644 index 00000000..560d4026 --- /dev/null +++ b/.changeset/verse-action-swatch-tray-android-scroll.md @@ -0,0 +1,11 @@ +--- +'@youversion/platform-react-native-expo-ui': patch +--- + +Fix the verse action sheet's highlight swatch tray not scrolling on Android, which made hidden swatches unreachable by touch. + +Six swatches fit the tray. A selection spanning two existing highlight colours already produces seven, so this affected a common case, not an edge one — the extra swatches rendered, the trailing fade correctly reported them, and no gesture could reach them. + +`@gorhom/bottom-sheet` builds its pan gesture with no activation criteria, so `react-native-gesture-handler` falls back to a direction-agnostic touch slop. A sideways drag over the tray activated the _sheet's_ pan, and activating cancels the touch stream in every native view underneath it, so the tray's `ScrollView` never scrolled. The sheet now constrains that pan to vertical intent, which leaves horizontal drags to the tray. Swipe-down dismissal is unchanged. + +`NativeSheet` gains an internal `panActiveOffsetY` pass-through for this. No public API changes. diff --git a/.gitignore b/.gitignore index 03dd53ab..37ac37b0 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ apps/example/web-build/ #omc .omc + +# Riptide artifacts (cloud-synced) and workspace config +.humanlayer/ diff --git a/AGENTS.md b/AGENTS.md index 30ae3282..86de5915 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,9 +10,10 @@ YouVersion Platform React Native Expo SDK — wraps the React Web SDK (`@youvers - **Cooldown**: `minimumReleaseAge: 4320` (3 days) in `pnpm-workspace.yaml` — package versions published less than 3 days ago are rejected (mitigates hijacked-release supply-chain attacks). Workspace packages (`workspace:*`) are inherently exempt. It is enforced at **two** points: 1. **Resolution** — fails with `ERR_PNPM_NO_MATURE_MATCHING_VERSION`. **`--force` does not override it**; use `pnpm install --config.minimumReleaseAge=0`, which lifts the cooldown for whatever that one command resolves. - 2. **Lockfile verification** — every install re-checks the committed `pnpm-lock.yaml` against the policy and fails with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`. This runs on `--frozen-lockfile` too, so **CI is not exempt**, and neither is `pnpm exec` / turbo (their deps-status check shells out to `pnpm install`). Overriding at resolution is therefore *not* local-only: a lockfile carrying a too-new version reds CI until the package ages past the cutoff, at which point the same lockfile passes with no changes. + 2. **Lockfile verification** — every install re-checks the committed `pnpm-lock.yaml` against the policy and fails with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`. This runs on `--frozen-lockfile` too, so **CI is not exempt**, and neither is `pnpm exec` / turbo (their deps-status check shells out to `pnpm install`). Overriding at resolution is therefore _not_ local-only: a lockfile carrying a too-new version reds CI until the package ages past the cutoff, at which point the same lockfile passes with no changes. + + `minimumReleaseAgeExclude` exempts packages permanently, and accepts scope globs. **`@youversion/*` is excluded** — the cooldown buys time for the ecosystem to spot a hijacked _third-party_ release, but for the Web SDK we publish ourselves it only blocks us from consuming our own work on release day. Those packages lean on publish-side controls (2FA/trusted publishing) instead. Everything else keeps the full 3 days. - `minimumReleaseAgeExclude` exempts packages permanently, and accepts scope globs. **`@youversion/*` is excluded** — the cooldown buys time for the ecosystem to spot a hijacked *third-party* release, but for the Web SDK we publish ourselves it only blocks us from consuming our own work on release day. Those packages lean on publish-side controls (2FA/trusted publishing) instead. Everything else keeps the full 3 days. - **Exact pins**: `dependencies` and `devDependencies` use exact versions (no `^`/`~`). This matters most in `packages/ui` and `packages/core` — their published manifests are resolved fresh on consumers' machines, where our lockfile offers no protection. `peerDependencies` stay as ranges by design (satisfied by the host app). - **Build scripts**: pnpm 11 blocks dependency postinstall scripts unless approved in `allowBuilds` (`pnpm-workspace.yaml`). If an install reports ignored builds, decide explicitly — prefer `false` when the package ships prebuilt binaries (e.g. `unrs-resolver`). - **Version bumps**: when updating a third-party pin, pick a version published ≥3 days ago — otherwise CI stays red until it ages past the cutoff (see the two enforcement points above). `@youversion/*` bumps are exempt and can land on release day. Update cadence is defined separately. @@ -95,10 +96,23 @@ Keep `GestureHandlerRootView` outside `YouVersionProvider`; bottom-sheet gesture `BibleCard`, `VerseOfTheDay`, `BibleReader`, and `BibleTextView` read `appKey` from `YouVersionProvider`, then pass serializable `appKey` and theme props into their DOM wrappers. Component-level theme props remain valid overrides. -`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 also hardcodes `verseActions="none"`: there is no in-WebView verse action popover, and consumers get no say. `onVerseSelect` and `clearSelectionSignal` are the public replacements — the former reports every selection change (including clears), the latter dismisses one from native. +`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. `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. +### Verse Action Sheet + +`BibleVerseActionSheet` (`native/bible-verse-action-sheet.tsx`) is the native replacement for the Web SDK's verse action popover: reference, highlight swatch tray, Copy, Share. It is **internal**. The reader owns it, and nothing exports it. + +Read [ADR 0017](docs/adr/0017-native-verse-action-sheet.md) before you change any point below. Each one has a cheaper-looking alternative that the ADR rejects for a stated reason. + +- 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), ported verbatim from the Web SDK popover. It is an **ANY** rule, and Swift and Kotlin agree on the remove list. A partially-covering color appearing in both rows is intended. +- `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. + ### Version Picker Sheet `BibleVersionPickerSheet` → `bible-version-picker-content.tsx` (**Version Picker Shell Layout**). Native passes `versionId`, `resetKey`, theme, and `onVersionChange` (commit + close). Language panel visibility is **DOM-owned** — do not lift to native or bridge as a **Native Action** (first open will flash; see `docs/adr/0005-dom-owned-language-panel-in-version-picker.md`). @@ -119,7 +133,11 @@ Each `NativeSheet` portals its own `BottomSheet` to the root host. Do not hide i Inactive `NativeSheet` hosts may remain mounted for WebView pre-warming, but they must stay inert. Android applies the offscreen/no-chrome/no-gestures/no-pointer-events treatment; iOS intentionally keeps the default closed host so `matchContents` WebViews can pre-warm and measure correctly (see `docs/adr/0006-inactive-sheet-inertness.md`). -`NativeSheet` currently exposes `enableContentPanningGesture`, Android loader controls, and content styling. Add typed `@gorhom/bottom-sheet` keyboard pass-throughs only when a sheet needs them, and cover the native action/sheet contract in tests. +`NativeSheet` currently exposes `enableContentPanningGesture`, `modal`, `panActiveOffsetY`, Android loader controls, and content styling. Add typed `@gorhom/bottom-sheet` keyboard pass-throughs only when a sheet needs them, and cover the native action/sheet contract in tests. + +`modal` defaults to `true`. `modal={false}` drops the backdrop entirely, not a transparent one, and relaxes the Android wrapper from `pointerEvents: 'auto'` to `'box-none'`, so touches reach whatever is behind the sheet. Only `BibleVerseActionSheet` uses it, for a stated reason. Read [ADR 0017](docs/adr/0017-native-verse-action-sheet.md) before adding a second. + +Every themed sheet draws an upward drop shadow (`SHEET_TOP_SHADOW` in `lib/native-sheet-theme.ts`). It is what separates a sheet without a backdrop from the content behind it. It uses RN's `boxShadow` typed-array form with a negative `offsetY`, because `shadowColor` is iOS-only and Android's `elevation` cannot be aimed. It requires the New Architecture (mandatory from Expo SDK 55) and Android API 28+ for outset shadows. Below API 28 the sheet renders without a shadow. Dark mode carries much higher alpha on purpose, because a black shadow has little luminance to spend against a near-black surface. It keys off `theme`, so an unthemed sheet given an explicit `backgroundColor` gets no shadow rather than a guessed one. A soft keyboard raised by a search input inside an Expo DOM WebView cannot be dismissed from native: RN's `Keyboard.dismiss()` only blurs the focused RN `TextInput` (via `TextInputState`), and the WebView's HTML input is invisible to it, so the call is a no-op. Instead, the picker DOM components (`dom/bible-version-picker-content.tsx`, `dom/chapter-picker-content.tsx`) receive the sheet's `isOpen` and, via `useDismissKeyboardOnClose` (`lib/dom-dismiss-keyboard.ts`), blur `document.activeElement` inside the WebView when `isOpen` flips to false (Cancel, pan-down, backdrop, and displacement all drive `isOpen` false). This is a one-way native→DOM command on close, not bridged UI state. See `docs/adr/0010-dom-keyboard-dismissal-on-sheet-close.md`. @@ -184,12 +202,12 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider - `useHighlights({ versionId, book, chapter })` is the whole public surface. The `createHighlightsApi` wrapper over `@youversion/platform-core`'s `HighlightsClient`, the MMKV cache, and the local `Result` seam (`packages/core/src/result.ts`) all stay internal. - Requires `auth` on `YouVersionProvider` and the `highlights` **permission** (see the permissions note above — highlights go in `requested_permissions[]`, never in `scope`). With no auth configured it behaves exactly as signed out. -- The GET is gated on `shouldFetchHighlights(requestedPermissions)`: an app that never asked for `highlights` issues no highlights request at all. Gate on the **requested** list, never on a grant — a missing grant is indistinguishable from an unknown one, so `hasPermission('highlights')` (documented "false when unknown") would silently un-paint the highlights of every user who signed in before grant reporting shipped. When C3.1 tightens this, only a *known* denial may skip; the constraint is written out on the predicate. +- The GET is gated on `shouldFetchHighlights(requestedPermissions)`: an app that never asked for `highlights` issues no highlights request at all. Gate on the **requested** list, never on a grant — a missing grant is indistinguishable from an unknown one, so `hasPermission('highlights')` (documented "false when unknown") would silently un-paint the highlights of every user who signed in before grant reporting shipped. When C3.1 tightens this, only a _known_ denial may skip; the constraint is written out on the predicate. - Paints from the MMKV cache **synchronously** in a `useState` initializer. That only works because `AuthProvider` seeds `userInfo` from its own initializer, so `userInfo.id` exists on the first render — load-bearing coupling, commented at both ends. - `highlights` is always safe to render. `isRefreshing` means "a GET is in flight", never "no data yet"; gating a spinner on it reintroduces the blank first frame the cache exists to prevent. - `error` is **fetch-only**. Writes report once, through the `HighlightWriteOutcome` they resolve to — that is also C3's branch point for the sign-in prompt (`reason === 'auth'` / `'not-signed-in'`). - The five swatches in `HIGHLIGHT_COLORS` are a company standard enforced in core: both `apply` and `remove` reject anything else as `invalid` before painting or issuing a request. Do not relax this on layering grounds — the open improvement is relocating the palette to `@youversion/platform-core`, not deferring it to the UI layer. -- Overlay math lives in the pure, React-free `packages/core/src/highlights/optimistic.ts`, ported from the web highlights machine. Ownership tokens and the colour-aware overlay retirement rule are documented in [ADR 0013](docs/adr/0013-native-highlights-optimistic-layer.md); the retirement rule reads like a bug in both directions and is defended only by its regression pair, so read the ADR before touching `shouldRetire`. +- Overlay math lives in the pure, React-free `packages/core/src/highlights/optimistic.ts`, ported from the web highlights machine. Ownership tokens and the color-aware overlay retirement rule are documented in [ADR 0013](docs/adr/0013-native-highlights-optimistic-layer.md); the retirement rule reads like a bug in both directions and is defended only by its regression pair, so read the ADR before touching `shouldRetire`. ## Highlight permission flow (core) @@ -201,7 +219,8 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider - 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. - `flowError` is for terminal _flow_ failures only (a failed grant, a still-refused write). Cancels and declines resolve `{ status: 'noop' }` — a user choice is not an error and must not surface as one. -- **The consent sheet is not built yet.** `HighlightConsentSheet` in `packages/ui/src/native/` is blocked on subtask 4's localization sync: `dataExchangeHighlightsQuestion` / `dataExchangeHighlightsExplanation` / `dataExchangeContinue` are not in `packages/ui/src/i18n/locales/en.json`, and `SdkTranslationKey` is generated, so the component cannot type-check here until they land. The hook's contract is UI-agnostic; drive the sheet's `isOpen` from `isConfirming` and route **every** dismissal path (button, backdrop, pan-down, displacement) to `decline()`. `apps/example/app/(tabs)/highlight-flow.tsx` is a temporary harness standing in for it and is deleted by U2 (YPE-3711). +- **The flow's two prompts are `HighlightConsentSheet` and `SignInWithYouVersionSheet`** (`packages/ui/src/native/`). Both are presentational, and both are internal. `BibleReader` wires them. Consent's `isOpen` is the hook's `isConfirming`, and `onConfirm` is `confirm()`. Route **every** dismissal path (button, backdrop, pan-down, displacement) to `decline()`. A path that skips it strands the flow with `isConfirming` still true. The sign-in prompt is the reader's own, in front of the flow, because the hook calls `signIn()` with no UI of its own. Neither sheet runs any auth itself. +- `apps/example/app/(tabs)/highlight-flow.tsx` is a temporary harness from the hook's own subtask. The reader now drives the whole flow, so the harness only exercises the hook in isolation. Deleting it is YPE-3711's call, not something to do in passing. ## Runtime Dependencies @@ -211,6 +230,8 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider Native modules and app-owned framework packages are peer dependencies. Consumers must install peer dependencies from both `packages/ui/package.json` and `packages/core/package.json` with Expo-compatible versions. Expo SDK 56 apps should also include `@expo/dom-webview` for Expo DOM Components and `react-native-worklets` when using Reanimated 4. +The verse action sheet added two UI peers: `expo-clipboard` (the Copy fallback) and `expo-application` (the app's display name in the sign-in prompt). A consumer upgrading into this version must install `expo-clipboard` and rebuild the dev client. `expo-application` was already a core dependency, so no new autolinked module reaches an app that already had core. + ## Peer Dependencies See `packages/ui/package.json` and `packages/core/package.json` `peerDependencies` for the canonical list. Requires a dev build (not Expo Go). diff --git a/CONTEXT.md b/CONTEXT.md index c25a2d47..ac1dd3db 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -119,13 +119,21 @@ The **Native Wrapper** always supplying a `highlights` array to its **Expo DOM C _Avoid_: Treating an empty highlights array as "nothing to pass"; a conditional or optional `highlights` prop; "controlled mode" alone (names the Web SDK's state, not our obligation) **Verse Selection**: -The serializable payload the reader emits on every selection change, cleared selections included (`verses: []`). Carries the **Highlight Scope** triple plus `verses`, per-verse `passageIds`, a localized `reference` for display, and `shareData`. With the in-WebView verse action UI switched off (`verseActions="none"`), this is the only channel a host learns about a selection on — and **Selection Clear Signal** is the only way it dismisses one. +The serializable payload the reader emits on every selection change, cleared selections included (`verses: []`). Carries the **Highlight Scope** triple plus `verses`, per-verse `passageIds`, a localized `reference` for display, and `shareData`. On every platform but web the in-WebView verse action UI is off (`verseActions="none"`). This payload is then the only channel native learns about a selection on, and it is what raises the **Verse Action Sheet**. **Selection Clear Signal** is the only way native dismisses one. _Avoid_: Verse press, tap event; keying off the payload's location fields when `verses` is empty (a clear from navigation carries the _destination_) **Selection Clear Signal**: A serializable counter the **Native Wrapper** increments to clear the reader's current **Verse Selection** from outside the WebView. Mount value is the baseline, so mounting never clears. Same nonce idiom as **Sheet Reset Key** and `openKey`, and for the same reason: an imperative ref handle cannot cross the DOM bridge. _Avoid_: `ref.clearSelection()`; a boolean "is selected" prop; **Sheet Reset Key** (that remounts a picker tree; this one clears a selection) +**Verse Action Sheet**: +The **Native Sheet** the reader raises over a live **Verse Selection**: the localized reference, the **Verse Action Swatches**, Copy, and Share. It replaces the Web SDK's in-WebView verse action **Presentation Shell** on iOS and Android, matching what Swift and Kotlin present. Alone among our sheets it is **non-modal**. It has no backdrop, because a backdrop intercepts the second verse tap that extends a selection. The cost is that backdrop-tap-to-dismiss does not exist. The compensation is an upward drop shadow on every themed **Native Sheet**. It is internal, not exported: the reader owns it, and a host building its own action UI has **Verse Selection** plus `useHighlights`. See [ADR 0017](docs/adr/0017-native-verse-action-sheet.md). +_Avoid_: Verse popover, verse menu; "tap outside to dismiss" (there is nothing outside to tap); giving another sheet `modal={false}` for looks + +**Verse Action Swatches**: +The highlight circles in a **Verse Action Sheet**. A pure function projects them from the current **Verse Selection** and its **Server Colors**. One scrolling tray holds two rows: a _remove_ circle for every palette color present on **any** selected verse, then an _apply_ circle for each of the five palette colors. The ANY rule is ported verbatim from the Web SDK popover, and it matches what Swift and Kotlin filter on. A color covering some but not all of the selection therefore appears in both rows, which is intended: remove clears it, and apply extends it across the whole selection. Colors outside the five-swatch palette are ignored, because the reader cannot paint them either. +_Avoid_: Re-deriving the rule from the sheet's UI; an ALL rule (a color on one verse of three still earns a remove circle); counting colors the palette does not contain + **Highlight Write Outcome**: What an `apply` or `remove` resolves to: `ok` with the verses that landed, `noop` when there was nothing to write, or `error` carrying a `reason` (`not-signed-in` / `auth` / `invalid` / `transient`), a diagnostic `message`, and both `failedVerses` (reverted) and `succeededVerses` (landed — non-empty means a partial batch). The only channel a write failure reports on; the hook's `error` state is for fetches alone, so a failed write can never evict a fetch error that is still true. _Avoid_: Branching on `message` (generic outside development builds); routing write failures through the hook's `error`; a separate `partial` status (the two verse arrays already say it) @@ -178,7 +186,10 @@ _Avoid_: Persisting it (that is F1's offline queue, a different thing); keeping - **Granted Permissions** are read only from the app redirect, never from the `/auth/callback` hop, which drops them. They are **Native-Owned State** cached per user in MMKV and purged with the rest of auth state on sign-out; a stale grant can be invalidated so the next pre-flight re-prompts. - A permission pre-flight reads **Granted Permissions**; a **Highlight Write Outcome** of `reason: 'auth'` is the corrective path when that cache is wrong, not the primary signal. - **Data Exchange** is the other way to obtain **Granted Permissions** — the one that does not require a new sign-in. It writes into the same per-user cache, merging rather than replacing, and only ever on a granted return. -- A **Permission Flow** composes the permission pre-flight, sign-in, and **Data Exchange** around a single guarded `apply`; its consent confirmation is a **Native Sheet** (pending localization), whose every dismissal path routes to decline. +- A **Permission Flow** composes the permission pre-flight, sign-in, and **Data Exchange** around a single guarded `apply`; its consent confirmation is a **Native Sheet**, whose every dismissal path routes to decline. +- A **Verse Action Sheet** is open exactly while a **Verse Selection** is live and no permission prompt is up. Every exit from it increments the **Selection Clear Signal**, so the selection and the sheet cannot disagree about whether one exists. +- The **Verse Action Sheet** yields to the sign-in and consent sheets rather than competing with them. **Native Sheet** displacement would close it, and closing it clears the selection a **Pending Highlight** is waiting on. +- **Verse Action Swatches** are a projection of **Verse Selection** over **Server Colors**, the same layer the reader paints from, so the tray and the passage can never disagree. A swatch press routes to **Permission Flow**'s guarded `apply`, or straight to `remove`. - A **Pending Highlight** belongs to exactly one **Permission Flow** and one **Highlight Scope**; when the flow ends in an apply, its fate is reported through the ordinary **Highlight Write Outcome**. ## Example Dialogue @@ -198,6 +209,9 @@ _Avoid_: Persisting it (that is F1's offline queue, a different thing); keeping > **Dev:** "Should `showLanguagePicker` live on the native sheet so both panels stay in sync?" > **Domain expert:** "No — that's **DOM-Owned Sheet UI State**. Bridging it as a **Native Action** makes the first language open flash instead of cross-fading. Keep panel visibility in **Version Picker Shell Layout**; native only owns open/close, **Sheet Reset Key**, and committed `versionId`." +> **Dev:** "Tapping outside the verse action sheet doesn't close it. Can we add a backdrop?" +> **Domain expert:** "No. The **Verse Action Sheet** is non-modal on purpose. A backdrop takes the second verse tap, and adding verses to a selection is the point. Swipe down, deselect, or act on the sheet." + > **Dev:** "I wired `onClick` on `BibleVersionPickerLanguageTrigger` but the popover state still changes." > **Domain expert:** "Call `event.preventDefault()` in the DOM wrapper so the Web SDK doesn't also run `setIsLanguagesOpen`. Mobile uses the shell cross-fade, not popover layout." diff --git a/README.md b/README.md index fa5c41da..47407364 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,9 @@ A React Native SDK for displaying Bible content in Expo apps on iOS and Android. - **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`) +- **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**: footnotes, chapter, and version pickers open in native bottom sheets via `@gorhom/bottom-sheet` +- **Native presentation**: verse actions, footnotes, chapter, and version pickers open in native bottom sheets via `@gorhom/bottom-sheet` ## Requirements @@ -52,7 +53,7 @@ Install the required peer dependencies (Expo will pick versions compatible with ```bash npx expo install @gorhom/bottom-sheet @expo/dom-webview \ - expo-crypto expo-secure-store expo-web-browser \ + expo-application expo-clipboard expo-crypto expo-secure-store expo-web-browser \ react-dom \ react-native-gesture-handler react-native-mmkv \ react-native-nitro-modules react-native-reanimated \ @@ -145,9 +146,33 @@ function ReaderScreen() { `BibleReader` is stateful — it owns the current `versionId` and coordinates its built-in chapter and version picker sheets. It also paints the signed-in user's highlights on its own, provided your `auth` config requests the `highlights` permission — there is no prop to pass. +#### Verse actions + +Tapping a verse opens a native bottom sheet with the reference, the highlight colors, Copy, and Share. It is the same surface the [Swift](https://github.com/youversion/platform-sdk-swift) and [Kotlin](https://github.com/youversion/platform-sdk-kotlin) SDKs present. It is on by default and needs no props. + +The sheet has no backdrop, so a second verse tap reaches the passage and extends the selection. To dismiss the sheet, swipe down, deselect the verses, or act on the sheet. + +The highlight colors write through the same highlights service as `useHighlights`. They need an `auth` config that requests the `highlights` permission (see [Sign In](#sign-in)). The sheet asks a signed-out user, or one without the permission, for exactly what is missing. It then applies their color choice, with no reselecting of the verse. + +Copy and Share fall back to `expo-clipboard` and React Native's `Share`. To handle either one yourself, pass `onCopy` or `onShare`: + +```tsx + { + // text: verse text plus the reference line + }} + onShare={async ({ text }) => { + // your own share sheet + }} +/> +``` + +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. + #### 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. `clearSelectionSignal` dismisses the current selection from native: increment it, and note its value at mount is the baseline, so mounting never clears. +`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. ```tsx const [clearSelectionSignal, setClearSelectionSignal] = useState(0) @@ -161,7 +186,7 @@ const [clearSelectionSignal, setClearSelectionSignal] = useState(0) /> ``` -Clears arrive too, as a selection with `verses: []`. Type a handler with `BibleReaderVerseSelection` / `BibleReaderShareData`, both re-exported from this package. +Clearing the selection also closes the verse action sheet. Clears arrive on `onVerseSelect` as well, as a selection with `verses: []`. Type a handler with `BibleReaderVerseSelection` / `BibleReaderShareData`, both re-exported from this package. #### Custom picker flows @@ -299,6 +324,7 @@ Calling `useYVAuth()` requires that the surrounding `YouVersionProvider` receive Explore the [`apps/example`](./apps/example) directory for a sample Expo Router app demonstrating: - Bible reader integration +- Verse actions, including `onCopy` / `onShare` overrides - Bible card and Scripture display - Verse of the Day - PKCE sign-in, OAuth callback handling, and the Profile tab diff --git a/apps/example/app/(tabs)/_layout.tsx b/apps/example/app/(tabs)/_layout.tsx index ff2fa771..1879f705 100644 --- a/apps/example/app/(tabs)/_layout.tsx +++ b/apps/example/app/(tabs)/_layout.tsx @@ -6,8 +6,7 @@ import { NativeTabs } from 'expo-router/unstable-native-tabs' // bar's scrollEdgeAppearance transparent, so the tabs float over an invisible // background. Gate on the major OS version: opt into an opaque material bar on // pre-26, and leave the defaults untouched on 26+ so Liquid Glass can render. -const iosMajorVersion = - Platform.OS === 'ios' ? parseInt(String(Platform.Version), 10) : 0 +const iosMajorVersion = Platform.OS === 'ios' ? parseInt(String(Platform.Version), 10) : 0 const needsLegacyTabBarBackground = Platform.OS === 'ios' && iosMajorVersion < 26 // `disableTransparentOnScrollEdge` keeps the standard appearance at the scroll diff --git a/apps/example/app/(tabs)/index.tsx b/apps/example/app/(tabs)/index.tsx index 29ec375e..ca2167b1 100644 --- a/apps/example/app/(tabs)/index.tsx +++ b/apps/example/app/(tabs)/index.tsx @@ -1,22 +1,55 @@ import { BibleReader, + type BibleReaderShareData, type BibleReaderVerseSelection, } from '@youversion/platform-react-native-expo-ui' +import * as Clipboard from 'expo-clipboard' import { useCallback, useState } from 'react' -import { Pressable, StyleSheet, Text, useColorScheme, View } from 'react-native' +import { Pressable, Share, StyleSheet, Switch, Text, useColorScheme, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' +/** + * A verse tap opens the SDK's native verse action sheet: the reference, the + * highlight swatches, Copy, and Share. Nothing on this screen opens the sheet. + * The reader owns it. + * + * This screen shows the two things a host app can do around the sheet: + * + * 1. Observe the selection (`onVerseSelect`) and clear it (`clearSelectionSignal`). + * The strip under the header is app UI, not SDK UI. It sits at the top + * because the verse action sheet owns the bottom of the screen while a + * selection is live. + * 2. Replace Copy and Share (`onCopy` / `onShare`). The toggle keeps the SDK + * fallbacks reachable, so a device can test both paths. + */ export default function BibleScreen() { const isDark = useColorScheme() === 'dark' - const { top, bottom } = useSafeAreaInsets() + const { top } = useSafeAreaInsets() const [selectedVerses, setSelectedVerses] = useState(null) const [clearSelectionSignal, setClearSelectionSignal] = useState(0) + const [useCustomActions, setUseCustomActions] = useState(false) + const [lastAction, setLastAction] = useState(null) const onVerseSelect = useCallback(async (next: BibleReaderVerseSelection) => { setSelectedVerses(next.verses.length > 0 ? next : null) }, []) + // An override wins over the SDK's `expo-clipboard` and `Share.share` + // fallbacks. `data.text` is the verse text plus the reference line. The other + // fields are there so a host app can build its own string. + const onCopy = useCallback(async (data: BibleReaderShareData) => { + await Clipboard.setStringAsync(`${data.text}\n\nCopied from the example app`) + setLastAction(`Custom copy: ${data.reference}`) + }, []) + + const onShare = useCallback(async (data: BibleReaderShareData) => { + await Share.share({ message: `${data.text}\n\nShared from the example app` }) + setLastAction(`Custom share: ${data.reference}`) + }, []) + + const statusLabel = selectedVerses ? selectedVerses.reference : lastAction + return ( - - {selectedVerses ? ( - - - {selectedVerses.reference} - + + + Custom Copy / Share + + + + + {/* Always rendered, so selecting a verse does not resize the reader. */} + + + {statusLabel ?? 'Tap a verse to open the action sheet'} + + {statusLabel ? ( setClearSelectionSignal((signal) => signal + 1)} + onPress={() => { + setClearSelectionSignal((signal) => signal + 1) + setLastAction(null) + }} style={styles.clearButton} > Clear - - ) : null} + ) : null} + + + ) } @@ -51,20 +98,33 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - selectionBar: { - position: 'absolute', - left: 16, - right: 16, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + paddingHorizontal: 16, + paddingVertical: 8, + }, + headerLabel: { + fontSize: 14, + fontWeight: '600', + }, + statusBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12, + marginHorizontal: 16, + marginBottom: 8, + // Fixed height so showing the Clear button does not resize the reader. + minHeight: 49, paddingVertical: 10, paddingHorizontal: 14, borderRadius: 12, backgroundColor: '#1f2933', }, - selectionLabel: { + statusLabel: { flexShrink: 1, color: '#ffffff', fontSize: 15, diff --git a/apps/example/package.json b/apps/example/package.json index 605a307d..122e013f 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -25,6 +25,7 @@ "@youversion/platform-react-native-expo-ui": "workspace:*", "expo": "56.0.12", "expo-build-properties": "56.0.20", + "expo-clipboard": "56.0.4", "expo-dev-client": "56.0.20", "expo-linking": "56.0.14", "expo-router": "56.2.11", diff --git a/docs/adr/0013-native-highlights-optimistic-layer.md b/docs/adr/0013-native-highlights-optimistic-layer.md index 73b54759..058de298 100644 --- a/docs/adr/0013-native-highlights-optimistic-layer.md +++ b/docs/adr/0013-native-highlights-optimistic-layer.md @@ -1,4 +1,4 @@ -# 13. Native highlights are the optimistic layer, with colour-aware overlay retirement +# 13. Native highlights are the optimistic layer, with color-aware overlay retirement Date: 2026-07-27 @@ -35,7 +35,7 @@ The web machine maintains an explicit queue because xstate cannot `await`. A pro Applies collapse contiguous verses into one ranged POST per run (`[16,17,18,20]` → `JHN.3.16-18` + `JHN.3.20`); removes issue one DELETE per verse, never a range, because range DELETE is not supported server-side. Both paths route through `collapseVerseRuns`, so switching removes to one-per-run is a single call site if that changes. -### Diverged: colour-aware retirement of remove overlays +### Diverged: color-aware retirement of remove overlays Web's `reconcileOverlay` never retires a remove entry: @@ -43,15 +43,15 @@ Web's `reconcileOverlay` never retires a remove entry: if (entry.op !== 'apply') continue // remove entries never retire (vapor fix) ``` -That fixes a real bug — a stale read replica echoing back the colour just deleted repaints the verse for a beat ("vapor") — but the suppression is opaque and unbounded. It holds until a reset path runs, so a _new_ colour set on another device stays invisible until the user navigates away and back. Web's own header states this as an accepted cost. +That fixes a real bug — a stale read replica echoing back the color just deleted repaints the verse for a beat ("vapor") — but the suppression is opaque and unbounded. It holds until a reset path runs, so a _new_ color set on another device stays invisible until the user navigates away and back. Web's own header states this as an accepted cost. -`ReconcileEntry` already carries the colour, and web simply ignores it for removes. So we keep the fix and drop most of the cost: +`ReconcileEntry` already carries the color, and web simply ignores it for removes. So we keep the fix and drop most of the cost: ```ts function shouldRetire(entry: ReconcileEntry, serverColor: string | undefined): boolean { if (entry.op === 'apply') return serverColor === entry.color - // Remove: the vapor case is the server echoing back the colour we deleted. - // A DIFFERENT colour cannot be an echo of that deletion — it is newer data. + // Remove: the vapor case is the server echoing back the color we deleted. + // A DIFFERENT color cannot be an echo of that deletion — it is newer data. return serverColor !== undefined && serverColor !== entry.color } ``` @@ -67,7 +67,7 @@ Web's settle routes a 401/403 into invalidate → re-stash pending highlight → ## Consequences - Native and web agree on what the user sees mid-write, and the shared vocabulary (`claim` / `settle` / reconcile / ownership token) survives in both codebases. Anyone diffing the two files finds the divergence documented rather than having to reverse-engineer whether it was deliberate. -- The colour-aware rule needs both directions pinned by tests, because it reads like a bug in each direction: a stale GET echoing the deleted colour must **not** resurrect the verse, and a GET reporting a different colour **must** retire the overlay. +- The color-aware rule needs both directions pinned by tests, because it reads like a bug in each direction: a stale GET echoing the deleted color must **not** resurrect the verse, and a GET reporting a different color **must** retire the overlay. - Two smaller decisions follow from the same "one optimistic layer" premise and are recorded here because reviewers ask about both: - **`error` is fetch-only.** Writes report once, through their return value. With one error slot, a transient write failure would evict a fetch error that is still true (the reader is showing stale cached data _because_ the GET failed), and a consumer with both a call-site handler and an error banner would render two UIs for one event. - **Writes hold through the token-loading window** on `accessToken !== null || !isLoading`, never on `isLoading` alone — `postTokenEndpoint` has no `AbortController`, so a hung network can leave `isLoading` true indefinitely. Without the hold, a cold-start write returns `not-signed-in` for a genuinely signed-in user, which is the exact value C3 branches on to launch a sign-in prompt. diff --git a/docs/adr/0016-highlight-permission-flow.md b/docs/adr/0016-highlight-permission-flow.md index d8a2212e..9d5595b8 100644 --- a/docs/adr/0016-highlight-permission-flow.md +++ b/docs/adr/0016-highlight-permission-flow.md @@ -33,7 +33,7 @@ It runs inside `useHighlights.runWrite`, next to the existing `waitForAuthSettle | Refresh here | What the user sees when a refresh is due | | ----------------------------------- | ------------------------------------------- | | Pre-flight, before `hasPermission` | Nothing, until a token round-trip completes | -| `runWrite`, after the claim painted | The colour, on tap | +| `runWrite`, after the claim painted | The color, on tap | `hasPermission` reads the local grant cache and needs no token, so nothing about the branch decision required the refresh to come first. `runWrite` already re-reads the current token at send time — deliberately, so a mid-write refresh does not fail the write — which is the same place the fresh one lands. diff --git a/docs/adr/0017-native-verse-action-sheet.md b/docs/adr/0017-native-verse-action-sheet.md new file mode 100644 index 00000000..7a422b24 --- /dev/null +++ b/docs/adr/0017-native-verse-action-sheet.md @@ -0,0 +1,165 @@ +# 17. Verse actions are a native bottom sheet + +Date: 2026-08-05 + +## Status + +Accepted + +## Context + +Verse actions are the reference label, the highlight swatches, Copy, and Share. The Web SDK drew them inside the DOM WebView, with `VerseActionPopover`. Swift and Kotlin have always drawn the same actions as a native bottom sheet. + +React Native now matches Swift and Kotlin. The popover goes away on native. Three facts made that more than a styling change. + +1. **There was no off switch.** The Web SDK's `BibleReader.Root` always created `VerseActionPopover`. The one visibility prop, `highlightsEnabled`, toggles only the swatch row inside the popover. Copy and Share always rendered. A native sheet alone would have stacked two action surfaces. +2. **Native could not build the reference.** `onVerseSelect` emitted `book` as a USFM code (`HEB`), not "Hebrews". The human name comes from `useBooks(versionId)` inside the reader. To re-derive it natively, this package needs a direct `@youversion/platform-react-hooks` dependency and a second fetch for data the WebView already holds. +3. **Native could not clear the selection.** Once the swatch press is native, nothing inside the WebView clears the selection. A sheet dismiss and a successful write would both leave verses selected. + +`@youversion/platform-react-ui@2.5.0` fixed all three. It added `verseActions`, `clearSelectionSignal`, and `reference` / `shareData` on the selection payload. This ADR consumes that release. + +PR #104 implemented this decision once before, on an unmerged reference branch (`a23a77ad`), and verified it on device. The presentational files here are ports of that branch. Only the reader's wiring is new, written against the core highlights and **Permission Flow** work from YPE-3709 and YPE-3710. + +## Decision + +### The popover is suppressed, not styled + +`verseActions="none"` removes the popover UI only. The Web SDK reader keeps selection, painting, intent emission, and payload construction. + +Restyling the popover to look native means reproducing sheet behavior in CSS, inside a WebView, on two platforms. Sheet behavior here means pan-to-dismiss, backdrop, safe area, and displacement against the reader's other sheets. + +### Web keeps the popover, and that fork is deliberate + +On web the reader passes `verseActions="popover"`. `NativeSheet` returns `null` on web. Suppressing the popover there leaves the reader with no verse action UI at all. + +The branch is a pure function, `lib/resolve-verse-actions.ts`. Its value crosses the bridge as a required prop. The `'use dom'` file cannot read `Platform.OS` itself. That file runs inside the WebView, where `react-native`'s `Platform` is not the host's. + +**This reverses decision 5 of PR #118 (YPE-3710, U1).** PR #118 removed the same fork, on the grounds that web is not a supported target: no web job in CI, no ADR, no README mention. It also added a source-text guard pinning `verseActions="none"`. The ticket and the design discussion both require the fork. Cam settled it on 2026-08-05. The fork ships, and the guard now targets the resolver call. + +**The web branch has no runtime coverage in this repo.** There is no web CI job, no web build script, and no browser pass. Its coverage is four layer-1 cases in `lib/__tests__/resolve-verse-actions.test.ts` plus two layer-3 cases. The reader mounts no sheet on web, and it still forwards the selection there. Nobody here has watched the web popover render from this branch. Reading the source says the popover's color swatches are inert, because the reader is in controlled mode with no `onHighlightApply` wired, and that Copy and Share still work. That is an inference from source, not an observation. For that reason the changelog says web gets "the popover, until native verse actions reach it" instead of describing what the popover does. + +### Selection is native-owned. Clearing it is a counter, not a ref + +`native/bible-reader.tsx` holds the committed **Verse Selection** in state. `onVerseSelect` feeds it. A `verses: []` payload drops it. + +This extends the narrow exception CONTEXT.md already records. Native **observes** a committed selection so it can present native chrome over it. The Web SDK still owns selection state. Only a clear travels back. + +That clear is the **Selection Clear Signal**, a number that only increases. It cannot be a `ref` handle. This is an Expo DOM component, so only serializable props and async **Native Actions** cross the bridge. The mechanic matches `resetKey`, `openKey`, and `dismissKeyboardNonce`. The mount value is the baseline, so mounting never clears. + +The reader **adds** its own counter to the consumer's `clearSelectionSignal` instead of replacing it. The Web SDK reacts only to a change in the number it receives. A sum lets both the public prop and the reader's own exits clear. Both start at `0`, so mounting still forwards `0`. + +### The sheet is non-modal, and that is load-bearing + +`BibleVerseActionSheet` passes `modal={false}` to `NativeSheet`, which drops the backdrop. + +A verse selection is **built one verse at a time**. The user taps a verse, the sheet rises, and they keep tapping to add more. Gorhom's backdrop covers the screen with `pointerEvents: 'auto'` while open. It intercepted the second tap and closed the sheet instead of passing the tap to the WebView. PR #104 confirmed that on device. The fix is confirmed here on the iOS simulator: a second verse tap with the sheet open extends the selection and collapses the label to a range. + +`opacity: 0` on the backdrop does **not** fix it. Gorhom reads `enableTouchThrough` only for the backdrop's initial `pointerEvents`. An animated reaction on `animatedIndex` then overwrites it to `'auto'` as soon as the sheet opens. An invisible backdrop still takes every tap. The fix has to drop the backdrop component. On Android the sheet's outer wrapper also relaxes from `'auto'` to `'box-none'`, or it takes the taps in the backdrop's place. + +The cost is that **backdrop-tap-to-dismiss no longer exists**. The remaining exits are swipe-down, deselecting every verse (which emits `verses: []`), and acting on the sheet. A tap on blank space in the reader does not clear the selection, because the Web SDK toggles selection only on verse spans. "Tap anywhere else to dismiss" is not an exit, and any test or doc that assumes it is wrong. + +### Dropping the backdrop forced a sheet shadow + +The backdrop was the only thing separating the sheet from the passage behind it. `NativeSheet` now draws an upward drop shadow, `SHEET_TOP_SHADOW` in `lib/native-sheet-theme.ts`. + +The shadow uses **`boxShadow`**, not `shadowColor` / `shadowOffset` and not `elevation`. RN's `shadow*` family is iOS-only. Android's `elevation` casts a shadow that cannot be aimed. Neither one can put a shadow above an edge on both platforms. `boxShadow` is the CSS-spec prop RN added in 0.76. It takes a negative `offsetY`, and it works on both platforms. It requires the New Architecture, which Expo SDK 55+ makes mandatory, so every consumer of this SDK has it. This code uses the typed array form, not the string form. One gap: outset `boxShadow` needs Android API 28+. Below API 28 the sheet renders without a shadow rather than broken. + +The shadow goes on Gorhom's `backgroundStyle`. The default background component is a bare `View` that spreads that style. Nothing between that view and the window clips it. `BottomSheetBody` has no `overflow`. `BottomSheetContent`'s `overflow: hidden` wraps only the sheet's children, as a sibling of the background. The one real clip boundary, `BottomSheetHostingContainer`, spans `topInset` to `bottomInset`. A sheet snapped near the top of the screen is a different case. + +The shadow applies to **every** themed sheet, not only this one. It keys off `theme`, not the resolved surface color, so an unthemed sheet with an explicit `backgroundColor` gets no shadow instead of a guessed one. Behind a modal sheet's dimmed backdrop the shadow is invisible, which is cheaper than branching on `modal`. + +**Dark mode carries much higher alpha**: 0.5 and 0.7, against light mode's 0.06 and 0.14. A black shadow has little luminance to spend against a near-black surface. PR #104's device pass measured the reader background at `#0f0f0f` and the sheet surface at `#121212`, a 3-level step that is effectively invisible. With the shadow, the pixels immediately above the edge read `#050505`, a 13-level step. Light mode gets 36 levels. Those numbers come from that pass, not from a measurement on this branch. If dark mode ever needs to be unambiguous rather than better, the next lever is a lightened hairline along the top edge. That is a design decision. + +### The tray scrolls. It does not grow + +Overflow is routine under the ANY rule. Two verses of different colors already produce 7 swatches: 2 remove plus 5 apply. The palette's worst case is 5 plus 5, or 10. + +The tray keeps a fixed `flex: 1` width and scrolls horizontally under **a gradient fade at each end**, so clipped swatches fade instead of hard-cutting. Copy and Share sit outside the scroll area and never move. + +One component draws both fades, mirrored. They share the `x1 → x2` direction and swap only the stop opacities, so the two edges cannot drift apart. The leading fade is the only cue that swatches exist behind the scroll position. + +The fade uses `react-native-svg`. That package is already a peer dependency, and it already draws this sheet's icons. `expo-linear-gradient` is deliberately not used. It is a new native module, and it would force a dev-client rebuild on every consumer for a visual detail. + +Each fade gates on **remaining scroll distance**, not raw overflow, so it retires at the end of the strip. Gating on overflow alone leaves the final swatch permanently dimmed once scrolled to, which reads as disabled. + +#### Scrolling the tray on Android needed the sheet's pan constrained to vertical intent + +The tray did not scroll on Android at all. Every hidden swatch was unreachable by touch. That was verified on a Pixel 6 Pro API 34 emulator with `John 2:1-3,5` spanning four colors (9 swatches, 6 visible), against five different gesture drivers. The trailing fade rendered throughout, so the tray knew the swatches were there. + +`BibleVerseActionSheet` therefore passes `panActiveOffsetY={[-10, 10]}`, which `NativeSheet` forwards to Gorhom's `activeOffsetY`. + +The cause is gesture arbitration. Gorhom builds its content pan as a bare `Gesture.Pan()` with no activation criteria (`BottomSheetDraggableView.tsx`). RNGH therefore falls back to `minDist`, which starts at the platform touch slop and is **direction-agnostic** (`PanGestureHandler.kt`). A sideways drag over the tray activates the _sheet's_ pan, and activation cancels the touch stream in every native view underneath it. `RNGestureHandlerRootHelper`'s `RootViewGestureHandler.onCancel` sets `shouldIntercept` and calls `onChildStartedNativeGesture`. The `ScrollView` never sees a move event. + +Supplying **any** custom activation criterion makes RNGH drop `minDist` outright. A vertical-only threshold therefore keeps a horizontal drag away from the sheet. The handoff back is symmetric and already wired: once Android's `ScrollView` starts scrolling it calls `requestDisallowInterceptTouchEvent`, which `RNGestureHandlerRootHelper` turns into a cancel of the sheet's pan. 10 sits just above Android's ~8dp slop, so the tray claims a sideways drag first. A swipe-down clears the threshold in its opening points, so dismissal is untouched. iOS never had the bug, because its pan carries no default `minDistSq` (`RNPanHandler.m`), and the threshold is imperceptible there. + +**`enableContentPanningGesture={false}` is not an acceptable fix**, though it cures the same symptom. Device-tested: with content panning off the tray scrolls and both fades behave, but swipe-down stops working. Neither a pan nor a fling on the grabber closes the sheet. This is the one sheet with no backdrop, so swipe-down is its only exit that does not require acting on the sheet. That trade buys a reachable swatch and a sheet you cannot close. + +Two alternatives were rejected against the installed `@gorhom/bottom-sheet@5.2.14`. **`BottomSheetScrollView`** is Gorhom's own scrollable. It writes its content _height_ into the sheet's `animatedLayoutState.contentHeight` whenever `enableDynamicSizing` is on, which every `NativeSheet` sets, so a short horizontal strip would drive the whole sheet's height. It also registers itself as the sheet's scrollable and drives the pan-down lock off `contentOffset.y`, which a horizontal scroller never moves. **A `Gesture.Native()` wrapper marked simultaneous with the sheet's pan** is what Gorhom does internally, but it needs `BottomSheetDraggableContext`, which the package does not export. Reaching for it would mean a deep import of an internal module, and simultaneity would also let a horizontal fling drag the sheet. + +The shipped YouVersion Bible app does more. It has a collapsed tray with a fanned stack that expands, widens, and pushes its action tiles off-screen, plus a pinned "clear all". That design was evaluated and not ported. It manages a seven-color palette and a six-tile action row. This SDK has five colors and two tiles. The app's growing tray is a consequence of the expand gesture, so porting the growth without the gesture gives half of each design. + +### The swatch rule is ported from the Web SDK, not re-derived + +`lib/verse-action-swatches.ts` ports `activeHighlights` plus the popover's ordering logic. It is an **ANY** rule. Every distinct color present anywhere in the selection earns a remove circle, not only the colors on every selected verse. + +Research settled the question ADR 0015 left open on the reference branch. That ADR said iOS was "believed" to use an ALL rule. It does not. Both public native SDKs filter their remove list with an "is this color on any selected verse" predicate. Kotlin does it at `BibleReaderViewModel.kt:504-520`, Swift at `BibleReaderViewModel+Navigation.swift:147-160`. Web, Swift, and Kotlin agree on the remove list, so this port preserves parity rather than creating a divergence. + +One difference survives, in the **add** list. Swift and Kotlin gate it on NOT-ALL. Web, and this port, use `!allColorsActive && (unHighlightedCount > 0 || activeColors.size > 1)`. The two disagree only when all five palette colors are active in one selection. Web then shows five remove circles and an empty apply row. The native SDKs would also show five apply circles. Cam decided on 2026-08-05 to ship the web rule. That edge stays with the separately tracked ANY-vs-ALL question. + +A color covering some but not all of the selected verses appears **twice**: a checkmarked remove circle, and a plain apply circle that paints the whole selection in one tap. That is web's shipped behavior, verified against `verse-action-popover.tsx:270-284` at `ui-2.5.0`. + +Colors outside the five swatches are ignored. That matches the projection the WebView paints from, because `deriveHighlightedVerses` drops them. Counting them would size the tray against paint the user cannot see. + +### Copy and Share stop crossing the bridge + +`onCopy` and `onShare` are native-only props on `BibleReaderProps`. With no popover, no in-WebView button fires them. `shareData` rides in on `onVerseSelect`, so the native buttons build nothing and cost no round-trip. A consumer override wins. Otherwise the SDK falls back to `expo-clipboard` and RN's `Share`, the same shape `VerseOfTheDay` already ships. + +One correction to the ticket text. It said removing these props from the DOM props would "silently delete two shipped public props". They were never on the reader, only on `VerseOfTheDay`. A test in `bible-reader-highlights-bridge.test.tsx` still pins their absence from the reader's DOM file. That is the point. They are native props now, and nothing about Copy or Share crosses into the WebView. This work **adds** them. + +### The write goes through core's Permission Flow, and the reader adds only a sign-in pre-step + +The reference branch carried its own gate: `lib/highlight-tap-gate.ts`, a pure predicate, plus hold-and-replay wiring in `native/use-reader-highlights.ts`. None of that is ported. [ADR 0016](0016-highlight-permission-flow.md) makes `useHighlightPermissionFlow` the canonical guard, and core's reducer already owns the **Pending Highlight** and its replay. A second gate in the UI layer gives a highlight write two places to disagree about whether it is allowed. + +The reader calls `flow.apply(color, verses)` for an apply and `flow.highlights.remove(color, verses)` for a remove. `remove` is deliberately ungated. A user looking at a highlight already has whatever the write needs. + +The reader keeps one thing the flow does not have: **a sign-in prompt**. `useHighlightPermissionFlow` calls `auth.signIn()` directly in its sign-in branch, and its only prompt state, `isConfirming`, is the **Data Exchange** consent. Handing a signed-out tap straight to `flow.apply` launches OAuth with no explanation of why. The reader instead holds the intent in a ref and shows `SignInWithYouVersionSheet`. On confirm it hands the intent to `flow.apply`. The flow then runs sign-in, falls through to consent if the grant is still missing, and writes, without the user reselecting the verse. Every dismissal path discards the intent. + +The signed-out read is `auth !== null && !auth.isAuthenticated`, not `!auth?.isAuthenticated`. `useYVAuthOptional()` returns `null` when the consumer configured no `auth` at all. That is not the same as signed out, because there is nothing to sign in to. For a null auth, `flow.apply` warns once and falls through to the unguarded write, which reports `not-signed-in`. Prompting would open a sheet whose only outcome is the outcome the user already had. + +### One sheet at a time, by construction + +The action sheet's `isOpen` is `selection !== null && prompt === 'none' && !flow.isConfirming`. `NativeSheet`'s store allows one active sheet, and it calls `onClose` on whichever sheet it displaces. This sheet's `onClose` is `closeVerseActions`, which bumps the clear signal. Leaving both sheets open would clear the selection as a side effect of losing, and a **Pending Highlight** would then replay with nothing selected. A swatch press also closes the sheet at once, so in practice the sheet is gone before a prompt appears. The gate makes that true by construction rather than by ordering. + +## Considered alternatives + +- **Style the WebView popover to look native.** Reproduces sheet behavior in CSS, in a WebView, twice. +- **Keep the popover and add the sheet.** Two stacked action surfaces. +- **Derive the display reference natively.** A direct hooks dependency and a second network fetch for data the WebView already resolved. +- **Use a `ref` handle to clear the selection.** Not possible across the Expo DOM bridge. +- **Port `highlight-tap-gate.ts` and its hold-and-replay wiring.** It predates the **Permission Flow**. ADR 0016 makes the flow canonical, and the reducer already owns replay. +- **Drop the web fork, as PR #118 decided.** Cam reversed that on 2026-08-05. The ticket requires the fork. +- **Export `BibleVerseActionSheet`.** Kept internal, like `NativeSheetProvider`. The reader owns it. A host that wants its own UI uses `onVerseSelect` plus core's `useHighlights`. `BibleTextView`, `BibleCard`, and `VerseOfTheDay` have no verse-tap interaction, so there is no second consumer to design for. + +## Consequences + +- **Swatch labels do not name their color.** Web's two labels, `applyHighlightAriaLabel` and `clearHighlightAriaLabel`, do not either. Carrying the color means coining five color-name keys with no upstream source, which the localization rules forbid from this repo. A screen-reader user hears "Apply highlight" five times. The testIDs already carry the color. Fix this once the copy table has color names. The ticket already records accessibility criteria as blocked on the swatch aria-label i18n ticket. +- `reference` falls back to the USFM book code until `useBooks` resolves inside the WebView. Selecting immediately after a chapter load is how that shows up. The fix is to hold the payload until books load, which is upstream work. +- `clearSelectionSignal` adds a DOM prop update on every sheet exit. Android has a standing `DomWebView.injectJavaScript` rejection when a prop update reaches an unmounted WebView. This work does not cause that rejection, but it does make the rejection easier to hit. +- `expo-clipboard` is a new **peer dependency**. Consumers who take this version must install it and rebuild their dev client. `expo-application` is also now a UI peer, because the sign-in sheet reads the app's display name. Core already depended on it, so no new autolinked module reaches an app that already had core. +- The sheet is not exported, so its layout is not public API and can change without a breaking release. + +## Verification status + +The manual passes and the automated ones cover different things. The gap matters when this area is next touched. + +| Path | State | +| ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| iOS: selection, sheet, second-verse tap, swipe-down, Copy, Share, top edge in both themes | Verified by Cam on the iPhone 17 simulator, 2026-08-05 (commit `f388555`) | +| iOS: swatch apply / remove, sign-in prompt, consent prompt | Automated only. No manual pass run. | +| Android: selection, sheet, second-verse tap, Copy, Share, both themes, top edge | Run on a Pixel 6 Pro API 34 emulator, 2026-08-06. This closes the ticket's "Verified on Android" criterion. | +| Android: swatch apply / remove, sign-in prompt, consent prompt, data-exchange grant | Same pass. One green highlight written and removed against a real signed-in account. UI and MMKV cache confirmed, server state not read back. | +| Android: swatch tray scroll | Same pass. Found the dead tray fixed above, then re-run against the fix. The tray scrolls, and every hidden swatch is reachable. | +| Web: the `'popover'` branch | Never run. Layer-1 and layer-3 tests only. No web CI job, build script, or browser pass. | +| Swipe-down by hand | iOS only, in the simulator. Synthetic pan gestures against Gorhom plus Reanimated prove nothing, so there is no automated equivalent. | +| Swipe-down on Android, with `panActiveOffsetY` applied | Synthetic `adb input swipe`, from the grabber and from the sheet body. Both dismissed the sheet and cleared the selection. Not a by-hand pass. | +| Shadow contrast numbers in this ADR | Measured on the reference branch (PR #104), not re-measured here. | diff --git a/docs/bug-reports/auth-website-issues.md b/docs/bug-reports/auth-website-issues.md index e0543a5e..6f224d9c 100644 --- a/docs/bug-reports/auth-website-issues.md +++ b/docs/bug-reports/auth-website-issues.md @@ -11,17 +11,21 @@ These two issues live in the **auth website / backend**, not in the SDK. The SDK ## Bug A — `profile_picture` claim is set to a placeholder URL when the user has no photo ### Observed + The id_token returned after sign-in contains a `profile_picture` claim of `https://none/` (and `https:None` / the bare string `None` have also been seen) for users who have no profile photo. This looks like a null/`None` value being serialized into a URL string instead of being omitted. -This is **reproducible server-side**: the `api.youversion.com` login *confirmation* screen renders the user's avatar directly from the same photo field, so it shows the broken placeholder image right on that page — no client app required. Same upstream root cause feeds both the confirmation-screen `` and the id_token claim. +This is **reproducible server-side**: the `api.youversion.com` login _confirmation_ screen renders the user's avatar directly from the same photo field, so it shows the broken placeholder image right on that page — no client app required. Same upstream root cause feeds both the confirmation-screen `` and the id_token claim. ### Expected + When a user has no profile photo, **omit the `profile_picture` claim entirely**. A JSON `null` is also acceptable. Never emit a placeholder host such as `none`, `null`, `undefined`, or `false`. ### Impact + Every downstream consumer of the id_token receives a valid-looking but meaningless URL. Naive avatar rendering (``) shows a broken image; some clients attempt a network request to `https://none/`. ### SDK-side mitigation (already shipped) + `sanitizeAvatarUrl` in `packages/core/src/auth/id-token.ts` drops the claim when it is a sentinel value (bare or as the URL host) or is not an `http(s)` URL. Defensive only. --- @@ -29,6 +33,7 @@ Every downstream consumer of the id_token receives a valid-looking but meaningle ## Bug B — The Cancel button on the auth page does nothing ### Observed + On `https:///auth/authorize`, clicking **Cancel** has no effect. The button is a dead anchor: ```html @@ -38,6 +43,7 @@ On `https:///auth/authorize`, clicking **Cancel** has no effect. The bu `href="#"` just jumps to the top of the page — there is no navigation and (apparently) no JS click handler wiring it anywhere. On native/mobile clients the auth page is opened in a system browser session (iOS `ASWebAuthenticationSession` / Android Custom Tab); because Cancel does not navigate anywhere, the user is stranded on the auth page and can only escape by manually dismissing the OS browser chrome. ### Expected + The Cancel button should redirect the browser to the request's `redirect_uri` with an OAuth cancellation signal, per **RFC 6749 §4.1.2.1**: ``` @@ -48,13 +54,16 @@ The Cancel button should redirect the browser to the request's `redirect_uri` wi - **Please echo `state`** with the exact value from the authorization request. The SDK still honors a cancel that omits `state` (a cancel carries no code to exchange, so it's not gated on `state`), but echoing `state` keeps the redirect consistent with the success path, where the SDK validates `state` before exchanging the code for tokens (CSRF protection). ### Impact + Native SDK clients cannot detect an in-page cancel. The only current escape is the OS-level browser dismiss, which is not discoverable and reads as the app being stuck. ### SDK-side mitigation (already shipped) + `signInWithPKCE` in `packages/core/src/auth/pkce-flow.ts` now treats a redirect with a valid `state` and `error=access_denied` as a clean cancel (`{ kind: 'cancel' }`) instead of a thrown error. This only works once the auth page actually performs the redirect above. --- ## Suggested priority + - **Bug A**: low effort, high blast radius (affects all clients rendering avatars). Fix at the token/backend layer. - **Bug B**: small front-end change on the auth page (wire Cancel to the redirect), unblocks proper cancel UX on all native clients. diff --git a/docs/solutions/architecture-patterns/version-picker-shell-and-dom-ui-state-2026-05-18.md b/docs/solutions/architecture-patterns/version-picker-shell-and-dom-ui-state-2026-05-18.md index e58e9fe0..fbacf34b 100644 --- a/docs/solutions/architecture-patterns/version-picker-shell-and-dom-ui-state-2026-05-18.md +++ b/docs/solutions/architecture-patterns/version-picker-shell-and-dom-ui-state-2026-05-18.md @@ -21,12 +21,12 @@ tags: [expo-dom, version-picker, native-action, webview, css-transition] ## What crosses the native bridge -| Concern | Owner | Mechanism | -| --------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------- | -| Sheet open / close | Native | `isOpen` on **Native Sheet** | -| Committed `versionId` | Native → consumer | `onSelect` after DOM `onVersionChange` | -| Scroll / search / panel reset on reopen | Native → DOM | **Sheet Reset Key** (`resetKey` prop) | -| Version ↔ language panel visibility | DOM only | `useState` in shell — not serializable props, not **Native Actions** | +| Concern | Owner | Mechanism | +| --------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------- | +| Sheet open / close | Native | `isOpen` on **Native Sheet** | +| Committed `versionId` | Native → consumer | `onSelect` after DOM `onVersionChange` | +| Scroll / search / panel reset on reopen | Native → DOM | **Sheet Reset Key** (`resetKey` prop) | +| Version ↔ language panel visibility | DOM only | `useState` in shell — not serializable props, not **Native Actions** | | Keyboard overlap in search fields | DOM only | `visualViewport` → `--yv-visible-height` shrinks shell; search bar uses fixed bottom padding | ## Failure mode we hit diff --git a/packages/core/src/highlights/__tests__/optimistic.test.ts b/packages/core/src/highlights/__tests__/optimistic.test.ts index 11df2943..6abea215 100644 --- a/packages/core/src/highlights/__tests__/optimistic.test.ts +++ b/packages/core/src/highlights/__tests__/optimistic.test.ts @@ -312,7 +312,7 @@ describe('serverUpdated', () => { expect(reconciled.reconcile.has(16)).toBe(true) }) - // The other half of the colour-aware retirement pair: our deliberate + // The other half of the color-aware retirement pair: our deliberate // divergence from web, which would suppress this repaint indefinitely. it('retires a remove overlay when the server reports a DIFFERENT color', () => { const token = createWriteToken('remove') diff --git a/packages/core/src/highlights/__tests__/use-highlights.test.tsx b/packages/core/src/highlights/__tests__/use-highlights.test.tsx index d05cbbfb..9570bcdc 100644 --- a/packages/core/src/highlights/__tests__/use-highlights.test.tsx +++ b/packages/core/src/highlights/__tests__/use-highlights.test.tsx @@ -451,7 +451,7 @@ describe('fetching server truth', () => { expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) // Once the write settles and the server catches up, the overlay retires and - // the same colour is now server truth rather than optimism. + // the same color is now server truth rather than optimism. mockGetHighlights.mockResolvedValue(collection([highlight('JHN.3.16', YELLOW)])) await act(async () => { pendingWrite.resolve({ ok: true, value: highlight('JHN.3.16', YELLOW) }) diff --git a/packages/core/src/result.ts b/packages/core/src/result.ts index 87b972f5..8cf30981 100644 --- a/packages/core/src/result.ts +++ b/packages/core/src/result.ts @@ -2,9 +2,7 @@ * Local Result seam for S1 (YPE-3706). Keep callers importing from here so the * ADR outcome (better-result / neverthrow / Effect) swaps a single module. */ -export type Result = - | { ok: true; value: Value } - | { ok: false; error: Error } +export type Result = { ok: true; value: Value } | { ok: false; error: Error } export function ok(value: Value): Result { return { ok: true, value } diff --git a/packages/ui/README.md b/packages/ui/README.md index 5937d889..3117f904 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -10,6 +10,7 @@ Use `@youversion/platform-react-native-expo-ui` when you need: - ✅ Pre-built Bible components for Expo: `BibleCard`, `BibleReader`, `BibleTextView`, `VerseOfTheDay` - ✅ Version/chapter picker and reader-settings bottom sheets, plus `YouVersionAuthButton` +- ✅ Native verse actions in `BibleReader`: a bottom sheet with highlight colors, Copy, and Share, plus `onCopy` / `onShare` overrides - ✅ Light/dark theming across every component, from one `YouVersionProvider` at your app root - ✅ Minimal setup: install, wrap, render diff --git a/packages/ui/jest.setup.js b/packages/ui/jest.setup.js index e598aad4..588fa715 100644 --- a/packages/ui/jest.setup.js +++ b/packages/ui/jest.setup.js @@ -162,7 +162,28 @@ jest.mock('@youversion/platform-react-native-expo-core', () => { } } - return { + /** + * Same reason as `useHighlights` above, one layer up: the real flow calls + * `useHighlights` through a *relative* import, so stubbing the barrel export + * alone does not intercept it and the real hook still reaches core's own + * context. Signed-out-shaped, and `apply` is the guarded write — suites that + * care about what a swatch press does re-mock or spy on this themselves. + */ + function useHighlightPermissionFlow(options) { + return { + // Through the module object, not the local binding: a test that steers + // highlight data with `jest.spyOn(core, 'useHighlights')` patches the + // property, and a direct call here would sail past it. + highlights: mocked.useHighlights(options), + isConfirming: false, + apply: jest.fn(async () => ({ status: 'noop' })), + confirm: jest.fn(), + decline: jest.fn(), + flowError: null, + } + } + + const mocked = { // Babel defines the real module's `__esModule` non-enumerably, so the spread // above drops it. Without it back, `import * as core` runs through // `_interopRequireWildcard`, which hands the importer a *copy* — and a @@ -174,7 +195,10 @@ jest.mock('@youversion/platform-react-native-expo-core', () => { useYouVersion, useYVAuth, useHighlights, + useHighlightPermissionFlow, } + + return mocked }) /** diff --git a/packages/ui/package.json b/packages/ui/package.json index 46257cbf..d034aa1b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -66,6 +66,8 @@ "@expo/dom-webview": ">=56.0.0 <57.0.0", "@gorhom/bottom-sheet": ">=5.0.0", "expo": ">=56.0.0 <57.0.0", + "expo-application": ">=56.0.0 <57.0.0", + "expo-clipboard": ">=56.0.0 <57.0.0", "expo-web-browser": ">=56.0.0 <57.0.0", "react": ">=19.0.0 <20.0.0", "react-dom": ">=19.0.0 <20.0.0", diff --git a/packages/ui/src/dom/bible-reader.tsx b/packages/ui/src/dom/bible-reader.tsx index 9141b898..d9f2cc55 100644 --- a/packages/ui/src/dom/bible-reader.tsx +++ b/packages/ui/src/dom/bible-reader.tsx @@ -36,6 +36,16 @@ type BibleReaderBaseProps = { * with the token we hand it. Pass `[]` for "nothing highlighted". */ highlights: Highlight[] + /** + * Whether the reader draws its in-WebView verse action popover. The native + * wrapper supplies it from `lib/resolve-verse-actions.ts`: `'none'` on iOS and + * Android, where the native sheet takes over, and `'popover'` on web. + * + * It is a prop instead of a `Platform.OS` read, because this file runs inside + * the WebView. It is required instead of defaulted, because the inherited + * default of `'popover'` is wrong on native. + */ + verseActions: 'popover' | 'none' /** * Fires on every selection change, clears included (`verses: []`). Carries the * selected verses, their passage ids, a localized `reference`, and `shareData`. @@ -88,6 +98,7 @@ export default function BibleReaderDOM(props: BibleReaderProps) { installationId, accessToken, highlights, + verseActions, onVerseSelect, clearSelectionSignal, theme = 'light', @@ -208,7 +219,7 @@ export default function BibleReaderDOM(props: BibleReaderProps) {
{ input.focus() const blurSpy = jest.spyOn(input, 'blur') - const { rerender } = renderHook(({ isOpen }: { isOpen: boolean }) => useDismissKeyboardOnClose(isOpen), { - initialProps: { isOpen: true }, - }) + const { rerender } = renderHook( + ({ isOpen }: { isOpen: boolean }) => useDismissKeyboardOnClose(isOpen), + { + initialProps: { isOpen: true }, + }, + ) expect(blurSpy).not.toHaveBeenCalled() diff --git a/packages/ui/src/lib/__tests__/resolve-verse-actions.test.ts b/packages/ui/src/lib/__tests__/resolve-verse-actions.test.ts new file mode 100644 index 00000000..3052d324 --- /dev/null +++ b/packages/ui/src/lib/__tests__/resolve-verse-actions.test.ts @@ -0,0 +1,32 @@ +/** + * Layer 1 — the platform fork behind `verseActions`. + * + * Layer 3 cannot see this: a jest run is one platform, and the value is consumed + * inside a `'use dom'` file that no native test renders. These four cases are the + * whole coverage the branch gets. + */ +import { resolveVerseActions } from '../resolve-verse-actions' + +describe('resolveVerseActions', () => { + it('switches the in-WebView popover off on iOS', () => { + expect(resolveVerseActions('ios')).toBe('none') + }) + + it('switches the in-WebView popover off on Android', () => { + expect(resolveVerseActions('android')).toBe('none') + }) + + it('keeps the popover on web, where NativeSheet renders nothing', () => { + // The regression guard. Hardcoding `'none'` for every platform leaves web + // with no verse action UI at all — the native sheet cannot replace it there, + // because `NativeSheet` returns null on web. + expect(resolveVerseActions('web')).toBe('popover') + }) + + it('treats an unknown platform as native, not web', () => { + // Fail toward the native sheet: an unrecognized platform is far more likely + // to be a new native target than a second web runtime, and `'popover'` is + // the branch that hands verse actions back to the WebView. + expect(resolveVerseActions('windows')).toBe('none') + }) +}) diff --git a/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts b/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts new file mode 100644 index 00000000..5711819a --- /dev/null +++ b/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts @@ -0,0 +1,128 @@ +import type { ServerColors } from '@youversion/platform-react-native-expo-core' +import { HIGHLIGHT_COLORS } from '@youversion/platform-react-native-expo-core' + +import { buildVerseActionSwatches } from '../verse-action-swatches' + +const [YELLOW, GREEN, BLUE, ORANGE, PINK] = HIGHLIGHT_COLORS + +/** `['remove:fffe00', 'apply:5dff79', …]` — order matters, so assert it as a list. */ +function summarize(input: { verses: number[]; colors: ServerColors }): string[] { + return buildVerseActionSwatches(input).map(({ state, color }) => `${state}:${color}`) +} + +describe('buildVerseActionSwatches', () => { + it('offers the whole palette for a single unhighlighted verse', () => { + expect(summarize({ verses: [1], colors: {} })).toEqual([ + `apply:${YELLOW}`, + `apply:${GREEN}`, + `apply:${BLUE}`, + `apply:${ORANGE}`, + `apply:${PINK}`, + ]) + }) + + it('offers the whole palette for an empty selection', () => { + // The sheet never opens on an empty selection, but the projection must not + // depend on that — a `verses: []` payload arrives on every clear. + expect(summarize({ verses: [], colors: {} })).toHaveLength(5) + }) + + it('drops the applied color from the apply row for one highlighted verse', () => { + // One verse, one color, nothing bare: the only thing to offer is removing + // it, plus the four colors it could become. + expect(summarize({ verses: [1], colors: { 1: YELLOW } })).toEqual([ + `remove:${YELLOW}`, + `apply:${GREEN}`, + `apply:${BLUE}`, + `apply:${ORANGE}`, + `apply:${PINK}`, + ]) + }) + + it('keeps a color in the apply row when part of the selection is still bare', () => { + // `unHighlightedCount > 0` — yellow is offered again because applying it + // would extend it over verse 2. + expect(summarize({ verses: [1, 2], colors: { 1: YELLOW } })).toEqual([ + `remove:${YELLOW}`, + `apply:${YELLOW}`, + `apply:${GREEN}`, + `apply:${BLUE}`, + `apply:${ORANGE}`, + `apply:${PINK}`, + ]) + }) + + it('offers a remove circle per distinct color present — the ANY rule, not ALL', () => { + // Web's `activeHighlights`: yellow is on verse 1 only and cyan on verse 2 + // only, yet both get a remove circle. An ALL rule would show neither. + expect(summarize({ verses: [1, 2], colors: { 1: YELLOW, 2: BLUE } })).toEqual([ + `remove:${YELLOW}`, + `remove:${BLUE}`, + `apply:${YELLOW}`, + `apply:${GREEN}`, + `apply:${BLUE}`, + `apply:${ORANGE}`, + `apply:${PINK}`, + ]) + }) + + it('re-offers every color once more than one is present, even with nothing bare', () => { + // `activeHighlights.size > 1` is the other half of `showAllApplyColors`: + // with two colors in play, "make it all green" is a real action. + const swatches = summarize({ verses: [1, 2], colors: { 1: YELLOW, 2: GREEN } }) + expect(swatches.filter((s) => s.startsWith('apply:'))).toHaveLength(5) + }) + + it('shows only remove circles when all five colors are present', () => { + expect( + summarize({ + verses: [1, 2, 3, 4, 5], + colors: { 1: YELLOW, 2: GREEN, 3: BLUE, 4: ORANGE, 5: PINK }, + }), + ).toEqual([ + `remove:${YELLOW}`, + `remove:${GREEN}`, + `remove:${BLUE}`, + `remove:${ORANGE}`, + `remove:${PINK}`, + ]) + }) + + it('orders remove circles before apply circles, both in canonical palette order', () => { + // Seeded out of palette order on purpose: the output must not inherit the + // insertion order of the colors map. + expect(summarize({ verses: [1, 2], colors: { 1: PINK, 2: BLUE } })).toEqual([ + `remove:${BLUE}`, + `remove:${PINK}`, + `apply:${YELLOW}`, + `apply:${GREEN}`, + `apply:${BLUE}`, + `apply:${ORANGE}`, + `apply:${PINK}`, + ]) + }) + + it('ignores colors outside the five swatches', () => { + // The WebView paints from a projection that drops these, so counting them + // would size the tray against paint the user cannot see. Verse 1 therefore + // reads as bare, which is why yellow stays in the apply row. + expect(summarize({ verses: [1, 2], colors: { 1: '123456', 2: YELLOW } })).toEqual([ + `remove:${YELLOW}`, + `apply:${YELLOW}`, + `apply:${GREEN}`, + `apply:${BLUE}`, + `apply:${ORANGE}`, + `apply:${PINK}`, + ]) + }) + + it('ignores colors on verses outside the selection', () => { + expect(summarize({ verses: [1], colors: { 1: YELLOW, 9: PINK } })).toEqual([ + `remove:${YELLOW}`, + `apply:${GREEN}`, + `apply:${BLUE}`, + `apply:${ORANGE}`, + `apply:${PINK}`, + ]) + }) +}) diff --git a/packages/ui/src/lib/app-name.ts b/packages/ui/src/lib/app-name.ts new file mode 100644 index 00000000..725ab83d --- /dev/null +++ b/packages/ui/src/lib/app-name.ts @@ -0,0 +1,20 @@ +import * as Application from 'expo-application' + +/** + * The integrating app's display name, interpolated into the sign-in sheet's + * `signInParagraph` ("{appName} wants to connect to your YouVersion Bible App + * account…"). `expo-application` reads the iOS display name or the Android app + * label, so consumers configure nothing. + * + * Returns `null` when the platform reports no name, which in practice is only + * web. Callers interpolate an empty string instead of an untranslatable English + * default. + */ +export function resolveAppName(): string | null { + const name = Application.applicationName + if (typeof name !== 'string') { + return null + } + const trimmed = name.trim() + return trimmed.length > 0 ? trimmed : null +} diff --git a/packages/ui/src/lib/native-sheet-theme.ts b/packages/ui/src/lib/native-sheet-theme.ts index 6545c2e5..eb7439fe 100644 --- a/packages/ui/src/lib/native-sheet-theme.ts +++ b/packages/ui/src/lib/native-sheet-theme.ts @@ -1,3 +1,5 @@ +import type { BoxShadowValue } from 'react-native' + import type { Theme } from './resolve-theme' /** @@ -35,3 +37,57 @@ export const SHEET_MUTED_BACKGROUND: Record = { light: '#f6f4f4', dark: '#353333', } + +/** + * Primary on-surface color: body text, icons, and the fill of a solid button. + * Mirrors the Web SDK --yv-foreground. + */ +export const SHEET_FOREGROUND: Record = { + light: '#121212', + dark: '#ffffff', +} + +/** + * Label color for a button filled with {@link SHEET_FOREGROUND}, which inverts + * the surface and so needs the surface's own color back for its text. + */ +export const SHEET_INVERSE_FOREGROUND: Record = { + light: '#ffffff', + dark: '#121212', +} + +/** Secondary on-surface color: supporting paragraphs and eyebrow labels. Mirrors --yv-muted-foreground. */ +export const SHEET_MUTED_FOREGROUND: Record = { + light: '#6b6a6a', + dark: '#a8a5a5', +} + +/** Hairline border on an outlined control drawn over the sheet surface. {@link SHEET_FOREGROUND} at 20%. */ +export const SHEET_STROKE: Record = { + light: 'rgba(18, 18, 18, 0.2)', + dark: 'rgba(255, 255, 255, 0.2)', +} + +/** + * Upward drop shadow separating a sheet's top edge from the content behind it. + * Two layers, the way Material fakes elevation: a tight contact shadow for the + * edge and a wide ambient one for depth. + * + * The shadow uses `boxShadow` in its typed array form, not the string form. + * `shadowColor` is iOS-only, and Android's `elevation` cannot be aimed above an + * edge. Outset shadows need the New Architecture (mandatory from Expo SDK 55) + * and Android API 28 or later. Below that the sheet renders unshadowed. + * + * Dark mode carries much higher alpha, because a black shadow has little + * luminance to spend against a near-black surface. + */ +export const SHEET_TOP_SHADOW: Record = { + light: [ + { offsetX: 0, offsetY: -2, blurRadius: 4, color: 'rgba(18, 18, 18, 0.06)' }, + { offsetX: 0, offsetY: -16, blurRadius: 32, color: 'rgba(18, 18, 18, 0.14)' }, + ], + dark: [ + { offsetX: 0, offsetY: -2, blurRadius: 4, color: 'rgba(0, 0, 0, 0.5)' }, + { offsetX: 0, offsetY: -16, blurRadius: 32, color: 'rgba(0, 0, 0, 0.7)' }, + ], +} diff --git a/packages/ui/src/lib/resolve-verse-actions.ts b/packages/ui/src/lib/resolve-verse-actions.ts new file mode 100644 index 00000000..57b91bf3 --- /dev/null +++ b/packages/ui/src/lib/resolve-verse-actions.ts @@ -0,0 +1,17 @@ +import type { PlatformOSType } from 'react-native' + +/** + * Which verse-action UI the WebView reader runs on a given platform. + * + * iOS and Android get `'none'`, because `BibleVerseActionSheet` renders the + * reference, swatch tray, Copy, and Share natively over the passage. Web gets + * `'popover'`, because `NativeSheet` renders nothing there, and suppressing the + * in-WebView popover would leave no verse action UI at all. + * + * The platform is an argument instead of a `Platform.OS` read, which makes the + * branch testable at layer 1. A layer-3 test always runs as one platform, so it + * cannot see the fork. + */ +export function resolveVerseActions(platformOS: PlatformOSType): 'popover' | 'none' { + return platformOS === 'web' ? 'popover' : 'none' +} diff --git a/packages/ui/src/lib/verse-action-swatches.ts b/packages/ui/src/lib/verse-action-swatches.ts new file mode 100644 index 00000000..a08568f3 --- /dev/null +++ b/packages/ui/src/lib/verse-action-swatches.ts @@ -0,0 +1,65 @@ +import type { HighlightColor, ServerColors } from '@youversion/platform-react-native-expo-core' +import { HIGHLIGHT_COLORS, isHighlightColor } from '@youversion/platform-react-native-expo-core' + +/** + * One circle in the verse action sheet's swatch tray. + * + * `state` is what a press does, not what the swatch looks like. `'remove'` + * renders the checkmark and clears that color. `'apply'` renders the bare circle + * and paints it. + */ +export type VerseActionSwatch = { color: HighlightColor; state: 'apply' | 'remove' } + +export type BuildVerseActionSwatchesInput = { + /** The verses currently selected in the reader. */ + verses: readonly number[] + /** + * Verse to color for the chapter on screen, optimistic paint included. It is + * `deriveServerColors(highlights, scope)` over what `useHighlights` returns. + */ + colors: ServerColors +} + +/** + * Builds the swatch tray for a selection: a remove circle for every color + * present on *any* selected verse, then the apply circles, both in canonical + * palette order. + * + * The apply row offers the whole palette when part of the selection is + * unhighlighted, or when the selection already carries more than one color. + * Otherwise it offers only the colors not already present. Colors outside + * `HIGHLIGHT_COLORS` are ignored, because the reader does not paint them either. + */ +export function buildVerseActionSwatches( + input: BuildVerseActionSwatchesInput, +): VerseActionSwatch[] { + const { verses, colors } = input + + const activeColors = new Set() + let highlightedVerseCount = 0 + for (const verse of verses) { + const color = colors[verse] + if (color === undefined || !isHighlightColor(color)) { + continue + } + activeColors.add(color) + highlightedVerseCount += 1 + } + + const unHighlightedCount = verses.length - highlightedVerseCount + const allColorsActive = activeColors.size === HIGHLIGHT_COLORS.length + // The whole palette is offered when part of the selection is bare, or when the + // selection already carries more than one color. In both cases "apply this + // everywhere" still means something for a color already present somewhere. + const showAllApplyColors = !allColorsActive && (unHighlightedCount > 0 || activeColors.size > 1) + const colorsToApply = showAllApplyColors + ? HIGHLIGHT_COLORS + : HIGHLIGHT_COLORS.filter((color) => !activeColors.has(color)) + + return [ + ...HIGHLIGHT_COLORS.filter((color) => activeColors.has(color)).map( + (color): VerseActionSwatch => ({ color, state: 'remove' }), + ), + ...colorsToApply.map((color): VerseActionSwatch => ({ color, state: 'apply' })), + ] +} diff --git a/packages/ui/src/native/__tests__/bible-card.test.tsx b/packages/ui/src/native/__tests__/bible-card.test.tsx index 5eae97b7..d552c9d3 100644 --- a/packages/ui/src/native/__tests__/bible-card.test.tsx +++ b/packages/ui/src/native/__tests__/bible-card.test.tsx @@ -1,17 +1,17 @@ -import { fireEvent, render } from "@testing-library/react-native"; -import type { FootnoteData } from "@youversion/platform-react-ui"; -import { mmkvStorage } from "@youversion/platform-react-native-expo-core"; -import * as ReactNative from "react-native"; -import { Platform } from "react-native"; -import type { ReactNode } from "react"; - -import { BibleCard } from "../bible-card"; +import { fireEvent, render } from '@testing-library/react-native' +import type { FootnoteData } from '@youversion/platform-react-ui' +import { mmkvStorage } from '@youversion/platform-react-native-expo-core' +import * as ReactNative from 'react-native' +import { Platform } from 'react-native' +import type { ReactNode } from 'react' + +import { BibleCard } from '../bible-card' import { bibleCardVersionStoreInitialState, useBibleCardVersionStore, -} from "../../stores/bible-card-version-store"; -import { BIBLE_CARD_VERSION_PERSIST_KEY } from "../../lib/constants"; -import { youVersionProviderWrapper as wrapper } from "../../test-utils/youversion-provider-wrapper"; +} from '../../stores/bible-card-version-store' +import { BIBLE_CARD_VERSION_PERSIST_KEY } from '../../lib/constants' +import { youVersionProviderWrapper as wrapper } from '../../test-utils/youversion-provider-wrapper' const sampleFootnote: FootnoteData = { verseNum: '3', @@ -120,11 +120,11 @@ describe('BibleCard', () => { const originalOs = Platform.OS beforeEach(async () => { - latestDomProps = {}; - mmkvStorage.remove(BIBLE_CARD_VERSION_PERSIST_KEY); - useBibleCardVersionStore.setState(bibleCardVersionStoreInitialState); - await useBibleCardVersionStore.persist.rehydrate(); - }); + latestDomProps = {} + mmkvStorage.remove(BIBLE_CARD_VERSION_PERSIST_KEY) + useBibleCardVersionStore.setState(bibleCardVersionStoreInitialState) + await useBibleCardVersionStore.persist.rehydrate() + }) afterEach(() => { Object.defineProperty(Platform, 'OS', { @@ -156,11 +156,11 @@ describe('BibleCard', () => { containerStyle: { flex: 0, width: '100%' }, scrollEnabled: false, bounces: false, - overScrollMode: "never", + overScrollMode: 'never', showsVerticalScrollIndicator: false, showsHorizontalScrollIndicator: false, - }); - }); + }) + }) it('merges a consumer containerStyle after the embed defaults', () => { render( diff --git a/packages/ui/src/native/__tests__/bible-reader-bottom-scroll-padding.test.tsx b/packages/ui/src/native/__tests__/bible-reader-bottom-scroll-padding.test.tsx index 9d8a4099..c67f3309 100644 --- a/packages/ui/src/native/__tests__/bible-reader-bottom-scroll-padding.test.tsx +++ b/packages/ui/src/native/__tests__/bible-reader-bottom-scroll-padding.test.tsx @@ -2,7 +2,10 @@ import { render } from '@testing-library/react-native' import type { ReactNode } from 'react' import { Platform } from 'react-native' -import { IOS_TAB_BAR_CLEARANCE, READER_SCROLL_END_GAP } from '../../lib/reader-bottom-scroll-padding' +import { + IOS_TAB_BAR_CLEARANCE, + READER_SCROLL_END_GAP, +} from '../../lib/reader-bottom-scroll-padding' import { BibleReader } from '../bible-reader' import { YouVersionProvider } from '../youversion-provider' diff --git a/packages/ui/src/native/__tests__/bible-reader-highlights-bridge.test.tsx b/packages/ui/src/native/__tests__/bible-reader-highlights-bridge.test.tsx index a5f71690..d0ebc680 100644 --- a/packages/ui/src/native/__tests__/bible-reader-highlights-bridge.test.tsx +++ b/packages/ui/src/native/__tests__/bible-reader-highlights-bridge.test.tsx @@ -30,7 +30,7 @@ type CapturedDomProps = { book?: string chapter?: string clearSelectionSignal?: number - onVerseSelect?: (selection: BibleReaderVerseSelection) => Promise + onVerseSelect?: (verseSelection: BibleReaderVerseSelection) => Promise onChapterChange?: (chapter: string) => Promise } @@ -259,9 +259,19 @@ describe('the verse-action event set', () => { expect(props).not.toHaveProperty('onShare') }) - it('does not let a consumer choose the verse-action UI', () => { + it('switches the in-WebView popover off on native', () => { + // jest-expo runs as a native platform (ios). Web is the exception, and is + // covered at layer 1 in `lib/__tests__/resolve-verse-actions.test.ts` — a + // platform fork is invisible to a suite that only ever runs as one platform. render(, { wrapper }) - expect(lastDomProps()).not.toHaveProperty('verseActions') + expect(lastDomProps()).toHaveProperty('verseActions', 'none') + }) + + it('does not let a consumer choose the verse-action UI', () => { + // @ts-expect-error — `verseActions` is omitted from BibleReaderProps; native + // owns it per platform. This asserts the omission is real, not just documented. + render(, { wrapper }) + expect(lastDomProps()).toHaveProperty('verseActions', 'none') }) }) @@ -294,9 +304,13 @@ describe('onVerseSelect', () => { expect(received.reference).toBe('Hebrews 11:4-5') }) - it('is absent from the DOM props when the consumer passes no handler', () => { + it('is supplied by the reader even when the consumer passes no handler', () => { + // This was `undefined` until the native verse action sheet landed. The + // reader now mirrors every selection into its own state to raise that sheet, + // so it always hands the DOM component a handler and calls the consumer's + // through it. render(, { wrapper }) - expect(lastDomProps().onVerseSelect).toBeUndefined() + expect(lastDomProps().onVerseSelect).toBeDefined() }) }) @@ -333,23 +347,28 @@ describe('clearSelectionSignal', () => { }) /** - * `verseActions="none"` is set inside the `'use dom'` file, on the Web SDK root - * — it never crosses the bridge, so no test that mocks the DOM component (i.e. - * every layer-3 test) can observe it, and this repo has no jsdom project to - * render the real thing in. This reads the source instead. Crude, but the line - * it guards is the one that keeps a second verse-action popover from stacking - * over the native sheet and keeps the Web SDK's own highlight writes switched - * off; leaving it with no regression guard at all was the worse trade. + * `verseActions` is applied inside the `'use dom'` file, on the Web SDK root — + * it is consumed there rather than re-emitted, so no test that mocks the DOM + * component (i.e. every layer-3 test) can observe what the reader root receives, + * and this repo has no jsdom project to render the real thing in. This reads the + * source instead. Crude, but the line it guards is the one that keeps a second + * verse-action popover from stacking over the native sheet on iOS and Android, + * keeps the Web SDK's own highlight writes switched off, and leaves web with + * verse actions at all; leaving it with no regression guard was the worse trade. */ describe('the DOM component source (unobservable from layer 3)', () => { const source = readFileSync(join(__dirname, '../../dom/bible-reader.tsx'), 'utf8') - it('hardcodes verseActions="none" on the Web SDK reader root', () => { - // Anchored to a line that is nothing but the JSX prop. A plain substring - // check would also be satisfied by the JSDoc in that file that mentions - // `verseActions="none"` in prose, so deleting the real prop would leave - // this green. - expect(source).toMatch(/^\s*verseActions="none"$/m) + it('forwards the host-chosen verseActions to the Web SDK reader root', () => { + // Both assertions are anchored to a line that is nothing but the JSX prop. + // A plain substring check would also be satisfied by that file's JSDoc, + // which discusses `verseActions` and both of its values in prose — so + // deleting the real prop, or re-hardcoding it, would leave this green. + expect(source).toMatch(/^\s*verseActions=\{verseActions\}$/m) + // Re-hardcoding the literal is the specific regression: it un-fixes web, + // where `NativeSheet` renders nothing and the popover is the only verse + // action UI, and `resolveVerseActions` goes dead with no test failing. + expect(source).not.toMatch(/^\s*verseActions="none"$/m) }) it('wires no Web SDK highlight-intent or copy/share handlers', () => { diff --git a/packages/ui/src/native/__tests__/bible-reader-highlights-prompts.test.tsx b/packages/ui/src/native/__tests__/bible-reader-highlights-prompts.test.tsx new file mode 100644 index 00000000..1edd5298 --- /dev/null +++ b/packages/ui/src/native/__tests__/bible-reader-highlights-prompts.test.tsx @@ -0,0 +1,486 @@ +/** + * Layer 3 — the two prompts that stand between a swatch press and a write. + * + * The reader owns the sign-in pre-step (the Permission Flow has no sign-in + * prompt state of its own, so `highlightPermissionFlow.apply` would launch + * OAuth unannounced), and the flow owns the consent step via `isConfirming`. + * The invariant across both: exactly one sheet is active, so a prompt never + * displaces the action sheet and fires `closeVerseActions` as a side effect. + */ +import { act, render, screen, userEvent } from '@testing-library/react-native' +import type { Highlight } from '@youversion/platform-react-native-expo-core' +import * as core from '@youversion/platform-react-native-expo-core' +import type { BibleReaderShareData, BibleReaderVerseSelection } from '@youversion/platform-react-ui' +import type { ReactNode } from 'react' + +import { + readerLocationStoreInitialState, + useReaderLocationStore, +} from '../../stores/reader-location-store' +import { BibleReader } from '../bible-reader' +import { YouVersionProvider } from '../youversion-provider' + +jest.mock('expo-clipboard', () => ({ + setStringAsync: jest.fn(() => Promise.resolve(true)), +})) + +// The sign-in sheet interpolates the host app's display name into its +// paragraph. The real module reads a native constant that jest-expo does not +// supply, and the copy is not what these tests are about. +jest.mock('expo-application', () => ({ applicationName: 'Test App' })) + +const VERSION_ID = 111 + +const SHARE_DATA: BibleReaderShareData = { + text: '“In the beginning was the Word...”\n\nJohn 1:1-2 BSB', + reference: 'John 1:1-2 BSB', + verseText: '“In the beginning was the Word...”', + verses: [1, 2], + book: 'JHN', + chapter: '1', + versionId: VERSION_ID, +} + +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: SHARE_DATA, +} + +const GREEN = '5dff79' +const BLUE = '00d6ff' + +function highlight(verse: number, color: string): Highlight { + return { version_id: VERSION_ID, passage_id: `JHN.1.${verse}`, color } +} + +const highlightPermissionFlowApply = jest.fn(async () => ({ status: 'noop' }) as const) +const highlightPermissionFlowConfirm = jest.fn() +const highlightPermissionFlowDecline = jest.fn() +const rawApply = jest.fn(async () => ({ status: 'noop' }) as const) +const rawRemove = jest.fn(async () => ({ status: 'noop' }) as const) + +/** + * `jest.setup.js` stubs this hook globally (the real one needs core's own + * provider, which UI tests replace). Steer it per test rather than re-mocking + * the whole package and losing that passthrough provider. + */ +function stubHighlightPermissionFlow({ + highlights = [] as Highlight[], + isConfirming = false, +} = {}) { + jest + .spyOn(core, 'useHighlightPermissionFlow') + .mockImplementation(({ versionId, book, chapter }) => ({ + highlights: { + highlights, + scope: { versionId, book, chapter }, + isRefreshing: false, + error: null, + refresh: jest.fn(async () => undefined), + apply: rawApply, + remove: rawRemove, + }, + isConfirming, + apply: highlightPermissionFlowApply, + confirm: highlightPermissionFlowConfirm, + decline: highlightPermissionFlowDecline, + flowError: null, + })) +} + +type AuthValue = NonNullable> + +/** + * A consumer *with* `auth` configured. `null` — the default in UI tests, since + * the passthrough provider mounts no `AuthProvider` — means something different + * and is covered by its own case below. + */ +function stubAuth(isAuthenticated: boolean) { + const value: AuthValue = { + isAuthenticated, + accessToken: isAuthenticated ? 'test-token' : null, + userInfo: null, + error: null, + signIn: jest.fn(async () => undefined), + signOut: jest.fn(async () => undefined), + refreshNow: jest.fn(async () => undefined), + ensureFreshToken: jest.fn(async () => undefined), + isLoading: false, + requestedPermissions: ['highlights'], + grantedPermissions: null, + hasPermission: () => false, + invalidatePermissions: jest.fn(), + requestPermissions: jest.fn(async () => ({ status: 'cancel' }) as const), + } + jest.spyOn(core, 'useYVAuthOptional').mockReturnValue(value) +} + +let mockNextVerseSelection: BibleReaderVerseSelection = SELECTION + +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?.(mockNextVerseSelection)} + > + 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: () => , + } +}) + +/** + * Same stand-in as the verse-action suite. `sheet-dismiss` is every + * non-button exit — swipe-down, backdrop tap, and displacement by another sheet + * all land on `NativeSheet`'s single `onClose`. + * + * Because each open sheet renders one `sheet` testID, counting them is how these + * tests assert "one sheet at a time". + */ +jest.mock('../native-sheet', () => { + const actual = jest.requireActual('../native-sheet') + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View, Text, Pressable } = require('react-native') + return { + ...actual, + NativeSheet: ({ + isOpen, + onClose, + children, + }: { + isOpen: boolean + onClose: () => void + children: ReactNode + }) => + isOpen ? ( + + + Dismiss + + {children} + + ) : null, + } +}) + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +) + +const user = userEvent.setup() + +async function selectVerses(verseSelection: BibleReaderVerseSelection = SELECTION) { + mockNextVerseSelection = verseSelection + await user.press(screen.getByTestId('trigger-verse-select')) +} + +async function press(testID: string) { + await user.press(screen.getByTestId(testID)) +} + +function openSheetCount() { + return screen.queryAllByTestId('sheet').length +} + +beforeEach(() => { + mockNextVerseSelection = SELECTION + highlightPermissionFlowApply.mockClear() + highlightPermissionFlowConfirm.mockClear() + highlightPermissionFlowDecline.mockClear() + rawApply.mockClear() + rawRemove.mockClear() + stubHighlightPermissionFlow() + useReaderLocationStore.setState(readerLocationStoreInitialState) +}) + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe('BibleReader — the sign-in pre-step', () => { + it('trades the action sheet for the sign-in sheet, one at a time', async () => { + stubAuth(false) + render(, { wrapper }) + + await selectVerses() + expect(screen.getByTestId('bible-verse-action-sheet')).toBeTruthy() + + await press(`bible-verse-action-swatch-apply-${GREEN}`) + + expect(screen.queryByTestId('bible-verse-action-sheet')).toBeNull() + expect(screen.getByTestId('sign-in-with-youversion-sheet')).toBeTruthy() + // Two live sheets means the loser was displaced, and displacement fires + // `closeVerseActions` — which would bump the clear signal underneath a + // prompt that has not been answered yet. + expect(openSheetCount()).toBe(1) + }) + + it('writes nothing until the user says yes', async () => { + stubAuth(false) + render(, { wrapper }) + + await selectVerses() + await press(`bible-verse-action-swatch-apply-${GREEN}`) + + expect(highlightPermissionFlowApply).not.toHaveBeenCalled() + expect(rawApply).not.toHaveBeenCalled() + }) + + it('replays the stashed intent through the flow on confirm', async () => { + stubAuth(false) + render(, { wrapper }) + + await selectVerses() + await press(`bible-verse-action-swatch-apply-${GREEN}`) + await press('sign-in-with-youversion-confirm') + + // The color and verses survive the round-trip: the user does not reselect. + expect(highlightPermissionFlowApply).toHaveBeenCalledWith(GREEN, [1, 2]) + expect(screen.queryByTestId('sign-in-with-youversion-sheet')).toBeNull() + }) + + it('discards the intent on "No Thanks"', async () => { + stubAuth(false) + render(, { wrapper }) + + await selectVerses() + await press(`bible-verse-action-swatch-apply-${GREEN}`) + await press('sign-in-with-youversion-decline') + + expect(screen.queryByTestId('sign-in-with-youversion-sheet')).toBeNull() + expect(highlightPermissionFlowApply).not.toHaveBeenCalled() + expect(rawApply).not.toHaveBeenCalled() + expect(openSheetCount()).toBe(0) + }) + + it('discards the intent on a swipe-down or backdrop tap too', async () => { + stubAuth(false) + render(, { wrapper }) + + await selectVerses() + await press(`bible-verse-action-swatch-apply-${GREEN}`) + await press('sheet-dismiss') + + expect(screen.queryByTestId('sign-in-with-youversion-sheet')).toBeNull() + expect(highlightPermissionFlowApply).not.toHaveBeenCalled() + }) + + it('leaves a dismissed intent behind — a later press starts over', async () => { + stubAuth(false) + render(, { wrapper }) + + await selectVerses() + await press(`bible-verse-action-swatch-apply-${GREEN}`) + await press('sign-in-with-youversion-decline') + + await selectVerses() + await press(`bible-verse-action-swatch-apply-${BLUE}`) + await press('sign-in-with-youversion-confirm') + + // The green intent is gone, not queued behind the blue one. + expect(highlightPermissionFlowApply).toHaveBeenCalledTimes(1) + expect(highlightPermissionFlowApply).toHaveBeenCalledWith(BLUE, [1, 2]) + }) + + /** + * The pending intent outlives the selection, but verse numbers alone are not + * a passage. A controlled consumer can change book / chapter / versionId + * while the sign-in sheet is open; replaying the old verses through the new + * location-scoped flow would paint text the user never selected — the same + * bug ADR 0016 pins inside the Permission Flow. + */ + /** + * All three scope fields, because the prompt's scope is compared field by + * field — a comparison that dropped `versionId` or `book` would still pass a + * chapter-only case. + */ + it.each([ + ['chapter', { book: 'JHN', chapter: '2', versionId: VERSION_ID }], + ['book', { book: 'LUK', chapter: '1', versionId: VERSION_ID }], + ['versionId', { book: 'JHN', chapter: '1', versionId: 206 }], + ])('discards the intent when %s changes while the prompt is up', async (_field, next) => { + stubAuth(false) + const { rerender } = render(, { + wrapper, + }) + + await selectVerses() + await press(`bible-verse-action-swatch-apply-${GREEN}`) + expect(screen.getByTestId('sign-in-with-youversion-sheet')).toBeTruthy() + + await act(async () => { + rerender() + }) + + expect(screen.queryByTestId('sign-in-with-youversion-sheet')).toBeNull() + expect(highlightPermissionFlowApply).not.toHaveBeenCalled() + expect(rawApply).not.toHaveBeenCalled() + }) + + it('keeps the intent when the reader stays in the same passage', async () => { + stubAuth(false) + const { rerender } = render(, { + wrapper, + }) + + await selectVerses() + await press(`bible-verse-action-swatch-apply-${GREEN}`) + + // An unrelated re-render must not trip the discard — `NO_PROMPT`'s stable + // identity is what keeps this from closing on every frame. + await act(async () => { + rerender() + }) + + expect(screen.getByTestId('sign-in-with-youversion-sheet')).toBeTruthy() + await press('sign-in-with-youversion-confirm') + expect(highlightPermissionFlowApply).toHaveBeenCalledWith(GREEN, [1, 2]) + }) + + it('never prompts for a remove', async () => { + stubAuth(false) + stubHighlightPermissionFlow({ highlights: [highlight(1, BLUE), highlight(2, BLUE)] }) + render(, { wrapper }) + + await selectVerses() + await press(`bible-verse-action-swatch-remove-${BLUE}`) + + // ADR 0016: a user looking at their own highlight already has whatever the + // write needs, so removal skips both prompts. + expect(screen.queryByTestId('sign-in-with-youversion-sheet')).toBeNull() + expect(screen.queryByTestId('highlight-consent-sheet')).toBeNull() + expect(rawRemove).toHaveBeenCalledWith(BLUE, [1, 2]) + }) + + it('goes straight to the flow for a signed-in user', async () => { + stubAuth(true) + render(, { wrapper }) + + await selectVerses() + await press(`bible-verse-action-swatch-apply-${GREEN}`) + + expect(screen.queryByTestId('sign-in-with-youversion-sheet')).toBeNull() + expect(highlightPermissionFlowApply).toHaveBeenCalledWith(GREEN, [1, 2]) + }) + + /** + * No `auth` config is not "signed out" — there is nothing to sign in to, and + * the flow's own `apply` warns and falls through to the unguarded write. A + * prompt here would open a sheet whose only outcome is the one the user + * already had. + */ + it('does not prompt a consumer who configured no auth', async () => { + render(, { wrapper }) + + await selectVerses() + await press(`bible-verse-action-swatch-apply-${GREEN}`) + + expect(screen.queryByTestId('sign-in-with-youversion-sheet')).toBeNull() + expect(highlightPermissionFlowApply).toHaveBeenCalledWith(GREEN, [1, 2]) + }) +}) + +describe('BibleReader — the consent step', () => { + it('opens on isConfirming and closes the action sheet with it', async () => { + stubAuth(true) + stubHighlightPermissionFlow({ isConfirming: true }) + render(, { wrapper }) + + await selectVerses() + + expect(screen.getByTestId('highlight-consent-sheet')).toBeTruthy() + expect(screen.queryByTestId('bible-verse-action-sheet')).toBeNull() + expect(openSheetCount()).toBe(1) + }) + + it('hands Continue to confirm()', async () => { + stubAuth(true) + stubHighlightPermissionFlow({ isConfirming: true }) + render(, { wrapper }) + + await press('highlight-consent-confirm') + + expect(highlightPermissionFlowConfirm).toHaveBeenCalledTimes(1) + expect(highlightPermissionFlowDecline).not.toHaveBeenCalled() + }) + + it('hands Cancel to decline()', async () => { + stubAuth(true) + stubHighlightPermissionFlow({ isConfirming: true }) + render(, { wrapper }) + + await press('highlight-consent-cancel') + + expect(highlightPermissionFlowDecline).toHaveBeenCalledTimes(1) + expect(highlightPermissionFlowConfirm).not.toHaveBeenCalled() + }) + + /** + * The backdrop tap, the pan-down, and displacement by another sheet are one + * handler on `NativeSheet`. A path that skipped `decline()` would strand the + * flow with `isConfirming` still true and no sheet on screen. + */ + it('hands the backdrop, pan-down and displacement paths to decline()', async () => { + stubAuth(true) + stubHighlightPermissionFlow({ isConfirming: true }) + render(, { wrapper }) + + await press('sheet-dismiss') + + expect(highlightPermissionFlowDecline).toHaveBeenCalledTimes(1) + expect(highlightPermissionFlowConfirm).not.toHaveBeenCalled() + }) +}) diff --git a/packages/ui/src/native/__tests__/bible-reader-verse-actions.test.tsx b/packages/ui/src/native/__tests__/bible-reader-verse-actions.test.tsx new file mode 100644 index 00000000..652f1389 --- /dev/null +++ b/packages/ui/src/native/__tests__/bible-reader-verse-actions.test.tsx @@ -0,0 +1,647 @@ +/** + * Layer 3 — the native verse action sheet, from a verse tap to Copy and Share. + * + * The reader mirrors the Web SDK's committed selection into native state so it + * can raise a bottom sheet over the passage. Nothing about that sheet lives in + * the WebView, so every assertion here is on the native side of the bridge plus + * the one value travelling back: the clear signal. + */ +import { fireEvent, render, screen, userEvent } from '@testing-library/react-native' +import type { Highlight } from '@youversion/platform-react-native-expo-core' +import * as core from '@youversion/platform-react-native-expo-core' +import type { BibleReaderShareData, BibleReaderVerseSelection } from '@youversion/platform-react-ui' +import * as Clipboard from 'expo-clipboard' +import type { ReactNode } from 'react' +import { Platform, Share } from 'react-native' + +import { + readerLocationStoreInitialState, + useReaderLocationStore, +} from '../../stores/reader-location-store' +import { BibleReader } from '../bible-reader' +import { YouVersionProvider } from '../youversion-provider' + +jest.mock('expo-clipboard', () => ({ + setStringAsync: jest.fn(() => Promise.resolve(true)), +})) + +const VERSION_ID = 111 + +const SHARE_DATA: BibleReaderShareData = { + text: '“In the beginning was the Word...”\n\nJohn 1:1-2 BSB', + reference: 'John 1:1-2 BSB', + verseText: '“In the beginning was the Word...”', + verses: [1, 2], + book: 'JHN', + chapter: '1', + versionId: VERSION_ID, +} + +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: SHARE_DATA, +} + +const CLEARED_SELECTION: BibleReaderVerseSelection = { + ...SELECTION, + verses: [], + passageIds: [], + reference: '', + shareData: null, +} + +const YELLOW = 'fffe00' +const GREEN = '5dff79' +const BLUE = '00d6ff' +const PINK = 'ff95ef' + +function highlight(verse: number, color: string): Highlight { + return { version_id: VERSION_ID, passage_id: `JHN.1.${verse}`, color } +} + +/** + * The two writes a swatch press can reach. `highlightPermissionFlowApply` is the guarded one — the + * Permission Flow's wrapper, which may run sign-in or consent first — and + * `rawRemove` is `useHighlights.remove`, deliberately ungated (ADR 0016). + * Keeping them as separate stable mocks is what lets each test say which path a + * press took. + */ +const highlightPermissionFlowApply = jest.fn(async () => ({ status: 'noop' }) as const) +const rawApply = jest.fn(async () => ({ status: 'noop' }) as const) +const rawRemove = jest.fn(async () => ({ status: 'noop' }) as const) + +/** + * `jest.setup.js` already stubs this hook globally (the real one needs core's own + * provider, which UI tests replace). Steer it per test rather than re-mocking the + * whole package and losing that passthrough provider. + */ +function stubHighlightPermissionFlow(highlights: Highlight[] = []) { + jest + .spyOn(core, 'useHighlightPermissionFlow') + .mockImplementation(({ versionId, book, chapter }) => ({ + highlights: { + highlights, + scope: { versionId, book, chapter }, + isRefreshing: false, + error: null, + refresh: jest.fn(async () => undefined), + apply: rawApply, + remove: rawRemove, + }, + isConfirming: false, + apply: highlightPermissionFlowApply, + confirm: jest.fn(), + decline: jest.fn(), + flowError: null, + })) +} + +/** Which verse-selection payload the mocked DOM component emits on the next press. */ +let mockNextVerseSelection: BibleReaderVerseSelection = SELECTION + +let latestDomProps: { + clearSelectionSignal?: number + onCopy?: unknown + onShare?: unknown + onVerseSelect?: (verseSelection: BibleReaderVerseSelection) => Promise +} = {} + +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 + }) { + latestDomProps = props + return ( + + void props.onVerseSelect?.(mockNextVerseSelection)} + > + 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: () => , + } +}) + +/** + * Real `NativeSheet` drives a Gorhom bottom sheet through a portal host; the + * only thing these tests need from it is "renders its children while open, and + * calls `onClose` when dismissed". `sheet-dismiss` stands in for the swipe-down + * and displacement paths, both of which reach the same handler. + * + * `sheet-modal-` surfaces the `modal` prop so the verse action sheet's + * non-modal contract is pinned. That is not cosmetic: a modal sheet renders a + * backdrop that swallows taps on the passage, which makes it impossible to add a + * second verse to the selection. + * + * `latestSheetProps` captures the gesture props for the same reason: the swatch + * tray's horizontal scroll and the sheet's swipe-down both depend on how the + * sheet's pan is configured, and only one configuration keeps both. + */ +let latestSheetProps: { + enableContentPanningGesture?: boolean + panActiveOffsetY?: [number, number] +} = {} + +jest.mock('../native-sheet', () => { + const actual = jest.requireActual('../native-sheet') + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View, Text, Pressable } = require('react-native') + return { + ...actual, + NativeSheet: ({ + isOpen, + onClose, + modal, + enableContentPanningGesture, + panActiveOffsetY, + children, + }: { + isOpen: boolean + onClose: () => void + modal?: boolean + enableContentPanningGesture?: boolean + panActiveOffsetY?: [number, number] + children: ReactNode + }) => { + if (isOpen) { + // Module-level capture cell for the open sheet's gesture props — + // intentional test infra, the same pattern as `latestDomProps` above. + latestSheetProps = { enableContentPanningGesture, panActiveOffsetY } + } + return isOpen ? ( + + + + Dismiss + + {children} + + ) : null + }, + } +}) + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +) + +const user = userEvent.setup() + +/** Emit a verse selection from the WebView, the way a verse tap would. */ +async function selectVerses(verseSelection: BibleReaderVerseSelection = SELECTION) { + mockNextVerseSelection = verseSelection + await user.press(screen.getByTestId('trigger-verse-select')) +} + +beforeEach(() => { + latestDomProps = {} + latestSheetProps = {} + mockNextVerseSelection = SELECTION + highlightPermissionFlowApply.mockClear() + rawApply.mockClear() + rawRemove.mockClear() + stubHighlightPermissionFlow() + useReaderLocationStore.setState(readerLocationStoreInitialState) + jest.spyOn(Share, 'share').mockResolvedValue({ action: 'sharedAction' }) +}) + +afterEach(() => { + jest.restoreAllMocks() + ;(Clipboard.setStringAsync as jest.Mock).mockClear() +}) + +describe('BibleReader verse action sheet — visibility', () => { + it('stays closed until a selection arrives', () => { + render(, { wrapper }) + + expect(screen.queryByTestId('bible-verse-action-sheet')).toBeNull() + }) + + it('opens on a populated selection and labels itself with the localized reference', async () => { + render(, { wrapper }) + + await selectVerses() + + expect(screen.getByTestId('bible-verse-action-sheet')).toBeTruthy() + // `John 1:1-2`, not `JHN 1:1-2` — the human-readable half of the 2.5.0 payload. + expect(screen.getByTestId('bible-verse-action-reference').children).toContain('John 1:1-2') + }) + + /** + * Regression guard. A modal sheet draws a backdrop over the passage that eats + * the next tap and closes the sheet, so the user can never select a second + * verse. The passage has to stay interactive while the selection is still + * being built. + */ + it('is non-modal, so the passage behind it stays tappable', async () => { + render(, { wrapper }) + + await selectVerses() + + expect(screen.getByTestId('sheet-modal-false')).toBeTruthy() + }) + + /** + * Regression guard, from a Pixel 6 Pro pass where the swatch tray would not + * scroll at all. Gorhom's pan has no activation criteria by default, 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. Every swatch past + * the sixth was unreachable — routine, since a selection spanning two colors + * already produces seven. + * + * Both halves of this assertion matter. Constraining the pan to vertical + * intent is the fix; disabling content panning cures the same symptom but + * removes swipe-down, and this backdrop-less sheet has no tap-outside exit to + * fall back on. + */ + it('constrains the sheet pan to vertical intent so the swatch tray can scroll', async () => { + render(, { wrapper }) + + await selectVerses() + + expect(latestSheetProps.panActiveOffsetY).toEqual([-10, 10]) + expect(latestSheetProps.enableContentPanningGesture).toBeUndefined() + }) + + it('closes when the selection clears', async () => { + render(, { wrapper }) + + await selectVerses() + await selectVerses(CLEARED_SELECTION) + + expect(screen.queryByTestId('bible-verse-action-sheet')).toBeNull() + }) + + it('still forwards every selection payload to the consumer, clear included', async () => { + const onVerseSelect = jest.fn() + render( + , + { wrapper }, + ) + + await selectVerses() + await selectVerses(CLEARED_SELECTION) + + expect(onVerseSelect).toHaveBeenNthCalledWith(1, SELECTION) + expect(onVerseSelect).toHaveBeenNthCalledWith(2, CLEARED_SELECTION) + }) +}) + +describe('BibleReader verse action sheet — the bridge', () => { + it('always supplies its own onVerseSelect, even with no consumer handler', () => { + // This used to be `undefined` when the consumer passed nothing. The sheet + // cannot open without it, so the SDK now owns the handler. + render(, { wrapper }) + + expect(latestDomProps.onVerseSelect).toBeDefined() + }) + + it('sends no copy/share Native Actions', () => { + render(, { wrapper }) + + // Dead Native Actions would cost a bridge round-trip per copy for nothing: + // with no popover there is no in-WebView button left to fire them. + expect(latestDomProps.onCopy).toBeUndefined() + expect(latestDomProps.onShare).toBeUndefined() + }) + + it('does not clear the selection at mount', () => { + render(, { wrapper }) + + expect(latestDomProps.clearSelectionSignal).toBe(0) + }) + + it('adds its own clears to the consumer’s signal rather than replacing it', async () => { + render(, { + wrapper, + }) + expect(latestDomProps.clearSelectionSignal).toBe(7) + + await selectVerses() + await user.press(screen.getByTestId('sheet-dismiss')) + + expect(latestDomProps.clearSelectionSignal).toBe(8) + }) + + it('bumps clearSelectionSignal on a sheet dismiss', async () => { + render(, { wrapper }) + + await selectVerses() + const before = latestDomProps.clearSelectionSignal + await user.press(screen.getByTestId('sheet-dismiss')) + + expect(latestDomProps.clearSelectionSignal).toBe((before ?? 0) + 1) + expect(screen.queryByTestId('bible-verse-action-sheet')).toBeNull() + }) +}) + +describe('BibleReader verse action sheet — swatches', () => { + it('projects the swatch tray from the painted highlights', async () => { + stubHighlightPermissionFlow([highlight(1, YELLOW)]) + render(, { wrapper }) + + await selectVerses() + + // Verse 1 is yellow, verse 2 is bare — so yellow gets a remove circle and + // stays in the apply row (it can still extend over verse 2). + expect(screen.getByTestId(`bible-verse-action-swatch-remove-${YELLOW}`)).toBeTruthy() + expect(screen.getByTestId(`bible-verse-action-swatch-apply-${YELLOW}`)).toBeTruthy() + }) + + it('routes an apply swatch through the Permission Flow, never the raw write', async () => { + render(, { wrapper }) + + await selectVerses() + await user.press(screen.getByTestId(`bible-verse-action-swatch-apply-${GREEN}`)) + + // The flow's `apply`, not `useHighlights.apply`: a signed-out or + // unpermitted user must get the sign-in / consent step, not a failed write. + expect(highlightPermissionFlowApply).toHaveBeenCalledWith(GREEN, [1, 2]) + expect(rawApply).not.toHaveBeenCalled() + expect(rawRemove).not.toHaveBeenCalled() + }) + + it('routes a remove swatch straight to the ungated write', async () => { + stubHighlightPermissionFlow([highlight(1, BLUE), highlight(2, BLUE)]) + render(, { wrapper }) + + await selectVerses() + await user.press(screen.getByTestId(`bible-verse-action-swatch-remove-${BLUE}`)) + + // ADR 0016: a user looking at their own highlight already has whatever the + // write needs, so removal never runs the flow. + expect(rawRemove).toHaveBeenCalledWith(BLUE, [1, 2]) + expect(highlightPermissionFlowApply).not.toHaveBeenCalled() + }) + + it('closes the sheet and clears the selection after a write', async () => { + render(, { wrapper }) + + await selectVerses() + const before = latestDomProps.clearSelectionSignal + await user.press(screen.getByTestId(`bible-verse-action-swatch-apply-${YELLOW}`)) + + expect(screen.queryByTestId('bible-verse-action-sheet')).toBeNull() + expect(latestDomProps.clearSelectionSignal).toBe((before ?? 0) + 1) + }) + + it('closes the sheet and clears the selection after a remove too', async () => { + stubHighlightPermissionFlow([highlight(1, BLUE), highlight(2, BLUE)]) + render(, { wrapper }) + + await selectVerses() + const before = latestDomProps.clearSelectionSignal + await user.press(screen.getByTestId(`bible-verse-action-swatch-remove-${BLUE}`)) + + expect(screen.queryByTestId('bible-verse-action-sheet')).toBeNull() + expect(latestDomProps.clearSelectionSignal).toBe((before ?? 0) + 1) + }) + + it('writes nothing when the sheet is dismissed', async () => { + render(, { wrapper }) + + await selectVerses() + await user.press(screen.getByTestId('sheet-dismiss')) + + expect(highlightPermissionFlowApply).not.toHaveBeenCalled() + expect(rawRemove).not.toHaveBeenCalled() + }) +}) + +/** + * The tray is a fixed-width window over a horizontally scrolling strip: two + * verses of different colors already produce seven circles (2 remove + 5 + * apply), and the palette's worst case is ten. `measureTray` stands in for the + * layout pass, which never runs under jest. + */ +describe('BibleReader verse action sheet — swatch tray overflow', () => { + // `fireEvent`, not `userEvent`, on purpose: `layout`, `contentSizeChange`, and + // the scroll offset they feed are the layout pass standing in for itself, not + // a gesture. `userEvent.scroll` would need the measurements this is supplying. + function measureTray(trayWidth: number, contentWidth: number) { + const scroll = screen.getByTestId('bible-verse-action-swatch-scroll') + fireEvent(scroll, 'layout', { nativeEvent: { layout: { width: trayWidth } } }) + fireEvent(scroll, 'contentSizeChange', contentWidth, 56) + } + + function scrollTray(x: number) { + fireEvent.scroll(screen.getByTestId('bible-verse-action-swatch-scroll'), { + nativeEvent: { contentOffset: { x, y: 0 } }, + }) + } + + it('draws neither fade while every swatch fits the tray', async () => { + render(, { wrapper }) + + await selectVerses() + measureTray(200, 200) + + expect(screen.queryByTestId('bible-verse-action-swatch-fade-trailing')).toBeNull() + expect(screen.queryByTestId('bible-verse-action-swatch-fade-leading')).toBeNull() + }) + + it('fades the trailing edge once the swatches overflow, without swallowing their taps', async () => { + stubHighlightPermissionFlow([highlight(1, YELLOW), highlight(2, BLUE)]) + render(, { wrapper }) + + await selectVerses() + measureTray(200, 320) + + expect(screen.getByTestId('bible-verse-action-swatch-fade-trailing')).toBeTruthy() + + // The fade is `pointerEvents="none"`: a swatch beneath it is still live. + await user.press(screen.getByTestId(`bible-verse-action-swatch-apply-${PINK}`)) + expect(highlightPermissionFlowApply).toHaveBeenCalledWith(PINK, [1, 2]) + }) + + /** + * Each fade tracks what is left to scroll *toward its own edge*, not raw + * overflow. Gating on overflow alone left the outermost swatch permanently + * dimmed once the user had scrolled to it, which reads as disabled. + */ + it('retires the trailing fade once the strip is scrolled to its end', async () => { + stubHighlightPermissionFlow([highlight(1, YELLOW), highlight(2, BLUE)]) + render(, { wrapper }) + + await selectVerses() + measureTray(200, 320) + scrollTray(120) + + expect(screen.queryByTestId('bible-verse-action-swatch-fade-trailing')).toBeNull() + }) + + /** + * The leading fade is the mirror: it is the only cue that swatches exist back + * the way you came, since the tray hard-cuts its left edge otherwise. + */ + it('draws no leading fade at the head of the strip, and fades it in once scrolled', async () => { + stubHighlightPermissionFlow([highlight(1, YELLOW), highlight(2, BLUE)]) + render(, { wrapper }) + + await selectVerses() + measureTray(200, 320) + + expect(screen.queryByTestId('bible-verse-action-swatch-fade-leading')).toBeNull() + + scrollTray(60) + + expect(screen.getByTestId('bible-verse-action-swatch-fade-leading')).toBeTruthy() + }) + + it('draws both fades mid-strip, and neither swallows a swatch tap', async () => { + stubHighlightPermissionFlow([highlight(1, YELLOW), highlight(2, BLUE)]) + render(, { wrapper }) + + await selectVerses() + measureTray(200, 320) + scrollTray(60) + + expect(screen.getByTestId('bible-verse-action-swatch-fade-leading')).toBeTruthy() + expect(screen.getByTestId('bible-verse-action-swatch-fade-trailing')).toBeTruthy() + + await user.press(screen.getByTestId(`bible-verse-action-swatch-apply-${PINK}`)) + expect(highlightPermissionFlowApply).toHaveBeenCalledWith(PINK, [1, 2]) + }) +}) + +/** + * Web keeps the in-WebView popover, so the native sheet must stay out of its + * way. `NativeSheet` already returns `null` there, but the reader also declines + * to mount the sheet at all — the two have to agree, or a future `NativeSheet` + * that renders something on web would put two verse-action UIs on screen. + * + * The other half of this fork, `verseActions="popover"` reaching the WebView, is + * pinned at layer 1 in `lib/__tests__/resolve-verse-actions.test.ts`. It is read + * once at module load, so flipping `Platform.OS` inside a test cannot move it. + */ +describe('BibleReader verse action sheet — web', () => { + const originalOs = Platform.OS + + afterEach(() => { + Object.defineProperty(Platform, 'OS', { + configurable: true, + enumerable: true, + value: originalOs, + }) + }) + + it('renders no verse action sheet on web, even with a live selection', async () => { + Object.defineProperty(Platform, 'OS', { configurable: true, enumerable: true, value: 'web' }) + + render(, { wrapper }) + + await selectVerses() + + expect(screen.queryByTestId('bible-verse-action-sheet')).toBeNull() + }) + + it('still forwards the selection to the consumer on web', async () => { + Object.defineProperty(Platform, 'OS', { configurable: true, enumerable: true, value: 'web' }) + const onVerseSelect = jest.fn() + + render( + , + { wrapper }, + ) + + await selectVerses() + + // The sheet is the only thing web gives up. `onVerseSelect` is a public prop + // on every platform, and the popover fires it exactly as the sheet path does. + expect(onVerseSelect).toHaveBeenCalledWith(SELECTION) + }) +}) + +describe('BibleReader verse action sheet — copy and share', () => { + it('runs copy and share off the selection payload, then clears the selection', async () => { + render(, { wrapper }) + + await selectVerses() + const before = latestDomProps.clearSelectionSignal + await user.press(screen.getByTestId('bible-verse-action-copy')) + expect(Clipboard.setStringAsync).toHaveBeenCalledWith(SHARE_DATA.text) + expect(latestDomProps.clearSelectionSignal).toBe((before ?? 0) + 1) + expect(screen.queryByTestId('bible-verse-action-sheet')).toBeNull() + + await selectVerses() + await user.press(screen.getByTestId('bible-verse-action-share')) + expect(Share.share).toHaveBeenCalledWith({ message: SHARE_DATA.text }) + expect(latestDomProps.clearSelectionSignal).toBe((before ?? 0) + 2) + }) + + it('lets a consumer override replace the SDK fallback', async () => { + const onCopy = jest.fn() + const onShare = jest.fn() + render( + , + { wrapper }, + ) + + await selectVerses() + await user.press(screen.getByTestId('bible-verse-action-copy')) + + await selectVerses() + await user.press(screen.getByTestId('bible-verse-action-share')) + + expect(onCopy).toHaveBeenCalledWith(SHARE_DATA) + expect(onShare).toHaveBeenCalledWith(SHARE_DATA) + expect(Clipboard.setStringAsync).not.toHaveBeenCalled() + expect(Share.share).not.toHaveBeenCalled() + }) +}) diff --git a/packages/ui/src/native/__tests__/native-sheet.test.tsx b/packages/ui/src/native/__tests__/native-sheet.test.tsx index fbb36a12..f02eed89 100644 --- a/packages/ui/src/native/__tests__/native-sheet.test.tsx +++ b/packages/ui/src/native/__tests__/native-sheet.test.tsx @@ -3,7 +3,7 @@ import type { ReactElement, ReactNode } from 'react' import { Platform, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native' import { SHEET_MAX_WIDTH } from '../../lib/native-sheet-max-width' -import { SHEET_HANDLE, SHEET_SURFACE } from '../../lib/native-sheet-theme' +import { SHEET_HANDLE, SHEET_SURFACE, SHEET_TOP_SHADOW } from '../../lib/native-sheet-theme' import { NativeSheet } from '../native-sheet' import { YouVersionProvider } from '../youversion-provider' @@ -342,7 +342,10 @@ describe('NativeSheet', () => { , ) - expect(latestBottomSheetProps.backgroundStyle).toEqual({ backgroundColor: '#121212' }) + expect(latestBottomSheetProps.backgroundStyle).toEqual({ + backgroundColor: '#121212', + boxShadow: SHEET_TOP_SHADOW.dark, + }) expect(latestBottomSheetProps.handleIndicatorStyle).toEqual([ { backgroundColor: '#ccc' }, { backgroundColor: '#5a5757' }, @@ -366,6 +369,29 @@ describe('NativeSheet', () => { , ) + expect(latestBottomSheetProps.backgroundStyle).toEqual({ + backgroundColor: '#123456', + boxShadow: SHEET_TOP_SHADOW.dark, + }) + }) + + it('omits the top shadow when a backgroundColor is given with no theme to color it', () => { + Object.defineProperty(Platform, 'OS', { + configurable: true, + enumerable: true, + value: 'ios', + }) + + render( + + + {}} backgroundColor="#123456"> + Sheet content + + + , + ) + expect(latestBottomSheetProps.backgroundStyle).toEqual({ backgroundColor: '#123456' }) }) @@ -382,6 +408,105 @@ describe('NativeSheet', () => { expect(latestBottomSheetProps.handleIndicatorStyle).toEqual({ backgroundColor: '#ccc' }) }) + /** + * A non-modal sheet drops the backdrop component outright rather than making + * it invisible. Gorhom's backdrop reads `enableTouchThrough` only for its + * initial `pointerEvents`, then an animated reaction overwrites it to 'auto' + * once the sheet opens — so an invisible backdrop would still eat every tap on + * the content behind. Rendering nothing is the only version that works. + */ + it('renders no backdrop when modal is false', () => { + Object.defineProperty(Platform, 'OS', { + configurable: true, + enumerable: true, + value: 'ios', + }) + + render( + + + {}} modal={false}> + Sheet content + + + , + ) + + expect(typeof latestBottomSheetProps.backdropComponent).toBe('function') + expect(renderLatestBackdrop()).toBeNull() + }) + + /** + * `panActiveOffsetY` is how a sheet keeps a horizontally scrolling child + * usable on Android. Gorhom leaves `activeOffsetY` unset, which drops RNGH's + * pan back to a direction-agnostic touch slop — the sheet then claims sideways + * drags and cancels the nested scrollable's touches. Supplying a vertical-only + * threshold is what keeps swipe-down *and* the scroll, so both the default + * (absent) and the forwarded value are pinned here. + */ + it('leaves the sheet pan unconstrained by default', () => { + Object.defineProperty(Platform, 'OS', { + configurable: true, + enumerable: true, + value: 'ios', + }) + + render() + + expect(latestBottomSheetProps.activeOffsetY).toBeUndefined() + expect(latestBottomSheetProps.enableContentPanningGesture).toBe(true) + }) + + it('forwards panActiveOffsetY to the Gorhom pan as activeOffsetY', () => { + Object.defineProperty(Platform, 'OS', { + configurable: true, + enumerable: true, + value: 'ios', + }) + + render( + + + {}} panActiveOffsetY={[-10, 10]}> + Sheet content + + + , + ) + + expect(latestBottomSheetProps.activeOffsetY).toEqual([-10, 10]) + // Constraining activation must not be confused with disabling the gesture: + // `enableContentPanningGesture={false}` would take swipe-down with it. + expect(latestBottomSheetProps.enableContentPanningGesture).toBe(true) + }) + + /** + * Android's active host is normally `auto`, which would put the dropped + * backdrop's job back on the absoluteFill wrapper. `box-none` lets the taps + * through to the content behind while the sheet itself stays interactive. + */ + it('lets taps through the Android host wrapper when modal is false', async () => { + Object.defineProperty(Platform, 'OS', { + configurable: true, + enumerable: true, + value: 'android', + }) + + const { getByTestId } = render( + + + {}} modal={false}> + Sheet content + + + , + ) + + await act(async () => {}) + + expect(getByTestId('native-sheet-inert-host').props.pointerEvents).toBe('box-none') + }) + it('notifies a displaced sheet via onClose when another sheet claims activeSheetId', async () => { Object.defineProperty(Platform, 'OS', { configurable: true, @@ -588,6 +713,7 @@ describe('NativeSheet', () => { expect(latestBottomSheetProps.backgroundStyle).toEqual({ backgroundColor: SHEET_SURFACE[theme], + boxShadow: SHEET_TOP_SHADOW[theme], }) expect(latestBottomSheetProps.handleIndicatorStyle).toEqual([ { backgroundColor: '#ccc' }, diff --git a/packages/ui/src/native/bible-reader.tsx b/packages/ui/src/native/bible-reader.tsx index 6dacb9b0..2903f3e1 100644 --- a/packages/ui/src/native/bible-reader.tsx +++ b/packages/ui/src/native/bible-reader.tsx @@ -1,17 +1,23 @@ import { useControllableState } from '@radix-ui/react-use-controllable-state' import { - useHighlights, + deriveServerColors, + useHighlightPermissionFlow, useYouVersion, useYVAuthOptional, + type HighlightColor, + type HighlightScope, } from '@youversion/platform-react-native-expo-core' import type { BibleChapterPickerPressData, + BibleReaderShareData, + BibleReaderVerseSelection, BibleVersionPickerPressData, FootnoteData, } from '@youversion/platform-react-ui' +import * as Clipboard from 'expo-clipboard' import * as WebBrowser from 'expo-web-browser' -import { useCallback, useMemo, useState } from 'react' -import { Platform, StyleSheet, View } from 'react-native' +import { useCallback, 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' @@ -22,12 +28,17 @@ 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 { resolveVerseActions } from '../lib/resolve-verse-actions' +import { buildVerseActionSwatches, type VerseActionSwatch } from '../lib/verse-action-swatches' import { useReaderLocationStore } from '../stores/reader-location-store' import { useReaderSettingsStore } from '../stores/reader-settings-store' import { BibleChapterPickerSheet } from './bible-chapter-picker-sheet' import { BibleReaderSettingsSheet } from './bible-reader-settings-sheet' +import { BibleVerseActionSheet } from './bible-verse-action-sheet' 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' const EMPTY_FOOTNOTE: FootnoteData = { verseNum: '', @@ -38,6 +49,36 @@ const EMPTY_FOOTNOTE: FootnoteData = { const DEFAULT_BOOK = 'JHN' const DEFAULT_CHAPTER = '1' +// Computed once: `Platform.OS` cannot change at runtime. +const VERSE_ACTIONS = resolveVerseActions(Platform.OS) + +/** + * A swatch press the reader holds while it asks the user something. The press + * outlives the selection. The action sheet closes before the prompt opens, so + * `verseSelection` is already `null` when the answer comes back. + * + * `scope` is load-bearing, because verse numbers alone are not a passage. + * Replayed through a location the reader has since left, they would paint text + * the user never selected. Core's Pending Highlight carries the same contract + * (ADR 0016). + */ +type PendingSwatchIntent = { color: HighlightColor; verses: number[]; scope: HighlightScope } + +/** + * Which prompt the reader is showing on its own account. For `'sign-in'` it also + * carries the passage the prompt was raised for. The scope is held here instead + * of read off {@link PendingSwatchIntent}, because the discard below runs during + * render, and `react-hooks/refs` forbids touching a ref there. + */ +type PromptState = { kind: 'none' } | { kind: 'sign-in'; scope: HighlightScope } + +/** Stable identity, so the render-time discard cannot re-trigger itself. */ +const NO_PROMPT: PromptState = { kind: 'none' } + +function sameScope(a: HighlightScope, b: HighlightScope): boolean { + return a.versionId === b.versionId && a.book === b.book && a.chapter === b.chapter +} + /** * Re-exported so an `onVerseSelect` handler can be typed without depending on * `@youversion/platform-react-ui` directly. @@ -48,6 +89,8 @@ export type BibleReaderProps = Omit< DomBibleReaderProps, | 'appKey' | 'highlights' + // Picked per platform by `VERSE_ACTIONS` above, not a consumer choice. + | 'verseActions' | 'fontSize' | 'fontFamily' | 'lineSpacing' @@ -68,8 +111,10 @@ export type BibleReaderProps = Omit< | 'userInfo' // The reader owns its bottom scroll padding (tab bar + home indicator on iOS). | 'bottomScrollPadding' - // `onVerseSelect` and `clearSelectionSignal` are deliberately kept — they are - // the consumer's only handle on a selection. + // `onVerseSelect` and `clearSelectionSignal` are deliberately kept. They are + // the consumer's only handle on a selection. The reader taps both on the way + // past: it mirrors the payload to raise the native verse action sheet, and it + // adds its own clears to the consumer's counter. > & { theme?: 'light' | 'dark' | 'system' defaultBook?: string @@ -77,6 +122,16 @@ export type BibleReaderProps = Omit< defaultVersionId?: number onFootnotePress?: (data: FootnoteData) => Promise onVersionPickerPress?: (data: BibleVersionPickerPressData) => Promise + /** + * Handle Copy yourself instead of the SDK's `expo-clipboard` fallback. The + * native verse action sheet's Copy button fires it, with the same payload + * `onVerseSelect` already carried. + * + * Native only. On web the in-WebView popover handles Copy itself. + */ + onCopy?: (data: BibleReaderShareData) => void | Promise + /** Share's counterpart to {@link BibleReaderProps.onCopy}. Falls back to RN's `Share.share`. */ + onShare?: (data: BibleReaderShareData) => void | Promise } export function BibleReader({ @@ -102,6 +157,8 @@ export function BibleReader({ // fires a spurious clear on their first render with the prop. The prop stays // optional in the public type — this is a default, not a requirement. clearSelectionSignal = 0, + onCopy: consumerOnCopy, + onShare: consumerOnShare, backgroundColor, foregroundColor, dom, @@ -160,7 +217,12 @@ export function BibleReader({ }, }) - const { highlights } = useHighlights({ versionId, book, chapter }) + const highlightPermissionFlow = useHighlightPermissionFlow({ versionId, book, chapter }) + const { + highlights, + scope: highlightScope, + remove: removeHighlight, + } = highlightPermissionFlow.highlights const [footnoteData, setFootnoteData] = useState(null) // footnoteData can remain non-null across repeated taps, so track each tap as an open event. @@ -169,6 +231,132 @@ export function BibleReader({ const [isVersionPickerOpen, setIsVersionPickerOpen] = useState(false) const [isSettingsSheetOpen, setIsSettingsSheetOpen] = useState(false) + // ── Verse actions ──────────────────────────────────────────────────────── + // The reader owns the committed selection so it can raise a native sheet over + // it. The Web SDK still owns selection *state*. This is a mirror of what the + // Web SDK committed, and the only thing traveling back is the clear signal. + const [verseSelection, setVerseSelection] = useState(null) + // One-way DOM command, bumped on every exit from the sheet. With the popover + // suppressed, nothing inside the WebView clears the selection any more. The + // count is *added* to the consumer's `clearSelectionSignal` instead of + // replacing it, so both the public prop and the reader's own exits can clear. + const [internalClearCount, setInternalClearCount] = useState(0) + + // The consent prompt is not in here. The flow owns that one, gated on + // `highlightPermissionFlow.isConfirming`. + const [prompt, setPrompt] = useState(NO_PROMPT) + // A ref, not state: nothing renders from it, and the sign-in sheet's confirm + // needs the value on the same tick it fires. + const pendingIntentRef = useRef(null) + + // Discard a pending sign-in prompt when the reader leaves the passage the + // prompt belonged to. A controlled consumer can change book, chapter, or + // versionId while the prompt is up. This is the same "adjust state when props + // change" pattern as the Permission Flow's RESET. An effect would leave one + // frame where the sheet for the old chapter is still open over the new one. + // `renderedPrompt` is what this frame paints, because `setPrompt` alone would + // still leave the sheet open for one render. + // + // The stale intent on the ref is left alone. Closing the sheet means nothing + // can fire `onConfirm`, the next swatch press overwrites the intent, and the + // confirm handler re-checks the scope anyway. + const currentScope: HighlightScope = { versionId, book, chapter } + let renderedPrompt = prompt + if (prompt.kind === 'sign-in' && !sameScope(prompt.scope, currentScope)) { + renderedPrompt = NO_PROMPT + setPrompt(NO_PROMPT) + } + + const handleVerseSelect = useCallback( + async (next: BibleReaderVerseSelection) => { + setVerseSelection(next.verses.length > 0 ? next : null) + await onVerseSelect?.(next) + }, + [onVerseSelect], + ) + + const closeVerseActions = useCallback(() => { + setVerseSelection(null) + setInternalClearCount((count) => count + 1) + }, []) + + // Which circles the tray shows, projected from the same painted array the + // WebView renders. A swatch can never disagree with the passage behind it. + const swatches = useMemo( + () => + buildVerseActionSwatches({ + verses: verseSelection?.verses ?? [], + colors: deriveServerColors(highlights, highlightScope), + }), + [verseSelection, highlights, highlightScope], + ) + + const applyHighlight = highlightPermissionFlow.apply + + // A `null` auth means the consumer configured no auth at all, which is not the + // same as signed out. There is nothing to sign in to, so there is no prompt. + const needsSignIn = auth !== null && !auth.isAuthenticated + + const handleSwatchPress = useCallback( + (swatch: VerseActionSwatch) => { + const verses = verseSelection?.verses ?? [] + // Read the selection first: closing drops the mirror this reads from. + closeVerseActions() + if (verses.length === 0) return + // `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) + return + } + if (needsSignIn) { + // The flow calls `signIn()` with no UI of its own, so the reader owns + // this pre-step: hold the intent, ask, hand it over on confirm. + pendingIntentRef.current = { + color: swatch.color, + verses, + scope: { versionId, book, chapter }, + } + setPrompt({ kind: 'sign-in', scope: { versionId, book, chapter } }) + return + } + // 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) + }, + [ + verseSelection, + closeVerseActions, + removeHighlight, + applyHighlight, + needsSignIn, + versionId, + book, + chapter, + ], + ) + + const handleSignInConfirm = useCallback(() => { + const pending = pendingIntentRef.current + pendingIntentRef.current = null + setPrompt(NO_PROMPT) + // Straight back into the flow, which signs in, asks for consent if the grant + // is still missing, and writes. The user never reselects the verse. + if (!pending) return + // Backstop for the during-render discard above. A confirm that races a + // 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]) + + // "No Thanks", a swipe-down, a backdrop tap, and displacement all land here. + // Every one discards the intent, and nothing is written. + const handleSignInDismiss = useCallback(() => { + pendingIntentRef.current = null + setPrompt(NO_PROMPT) + }, []) + const handleOpenBibleThemeSettings = useCallback(() => { setIsSettingsSheetOpen(true) }, []) @@ -227,6 +415,55 @@ export function BibleReader({ [consumerOnVersionPickerPress, showToolbar], ) + // The consumer override wins. Otherwise the native fallback runs, because + // browser defaults do not work inside an Expo DOM WebView. + const handleCopy = useCallback( + async (data: BibleReaderShareData) => { + try { + if (consumerOnCopy) { + await consumerOnCopy(data) + return + } + await Clipboard.setStringAsync(data.text) + } catch (error) { + // Swallowed. A failed copy reads to the user like a dismissed sheet, + // and there is nothing useful to say about it. + console.error('BibleReader copy failed:', error) + } + }, + [consumerOnCopy], + ) + + const handleShare = useCallback( + async (data: BibleReaderShareData) => { + try { + if (consumerOnShare) { + await consumerOnShare(data) + return + } + await Share.share({ message: data.text }) + } catch (error) { + console.error('BibleReader share failed:', error) + } + }, + [consumerOnShare], + ) + + // `shareData` rides in on `onVerseSelect`, so these handlers need no round-trip + // back into the WebView. Read the data before `closeVerseActions` drops the + // selection. + const handleCopyPress = useCallback(() => { + const data = verseSelection?.shareData + closeVerseActions() + if (data) void handleCopy(data) + }, [verseSelection, handleCopy, closeVerseActions]) + + const handleSharePress = useCallback(() => { + const data = verseSelection?.shareData + closeVerseActions() + if (data) void handleShare(data) + }, [verseSelection, handleShare, closeVerseActions]) + const onExternalLinkPress = useCallback(async (url: string) => { try { await WebBrowser.openBrowserAsync(url, { @@ -273,8 +510,9 @@ export function BibleReader({ installationId={context.installationId} accessToken={accessToken} highlights={highlights} - onVerseSelect={onVerseSelect} - clearSelectionSignal={clearSelectionSignal} + verseActions={VERSE_ACTIONS} + onVerseSelect={handleVerseSelect} + clearSelectionSignal={clearSelectionSignal + internalClearCount} onSignInPress={signIn} onSignOutPress={signOut} userInfo={userInfo} @@ -311,6 +549,41 @@ export function BibleReader({ onClose={() => setIsSettingsSheetOpen(false)} /> )} + {Platform.OS !== 'web' && ( + + )} + {Platform.OS !== 'web' && ( + + )} + {Platform.OS !== 'web' && ( + + )} {showFootnoteSheet && ( = { light: 1, dark: 0.3 } + +/** + * Row metrics. The swatch tray and both action tiles share one row height, and + * the tiles set it. The row is `alignItems: 'stretch'`, so only the tile (icon + * over label) is measured. `ROW_HEIGHT` records that height, so the sizes + * derived from it stay in sync when the tile's contents change. + */ +const ACTION_ICON_SIZE = 20 +const ACTION_LABEL_LINE_HEIGHT = 16 +const ACTION_LABEL_GAP = 2 +const ACTION_PADDING_VERTICAL = 9 +const ROW_HEIGHT = + ACTION_PADDING_VERTICAL * 2 + ACTION_ICON_SIZE + ACTION_LABEL_GAP + ACTION_LABEL_LINE_HEIGHT + +/** A swatch is about half the tray's height, and the tray is a rounded rect (~14% radius), not a pill. */ +const SWATCH_SIZE = Math.round(ROW_HEIGHT / 2) +const CORNER_RADIUS = Math.round(ROW_HEIGHT * 0.14) +const CHECK_ICON_SIZE = 18 + +/** + * Width of the gradient mask at each end of the swatch tray, so clipped swatches + * fade instead of being hard-cut. One swatch wide. + */ +const FADE_WIDTH = SWATCH_SIZE + +/** Minimum spacing between swatches once the tray overflows and `space-evenly` has no slack left. */ +const SWATCH_GAP = 8 + +/** + * Vertical travel, in points, before the sheet's pan gesture may take over. + * + * Without it the tray does not scroll on Android at all. Gorhom's pan has no + * activation criteria by default, so RNGH falls back to a direction-agnostic + * touch slop and the sheet claims a sideways drag — which cancels the touch + * stream in the `ScrollView` underneath it. The trailing fade still rendered, so + * the tray knew swatches were hidden; they were simply unreachable. Overflow is + * routine here (seven swatches the moment a selection spans two colors), which + * made that a common case, not an edge one. + * + * A vertical-only threshold is the fix that keeps both halves. Turning the + * content pan off also scrolls the tray, but takes swipe-down with it — and this + * is the one sheet with no backdrop, so swipe-down is its only exit that does + * not require acting on the sheet. Verified on device: with content panning off, + * neither a pan nor a fling on the grabber closed it. + * + * 10 sits just above Android's ~8dp touch slop, so a horizontal drag reliably + * reaches the tray first, while a swipe-down clears it in its opening points. + */ +const PAN_ACTIVE_OFFSET_Y: [number, number] = [-10, 10] + +/** `fffe00` → `rgba(255, 254, 0, 0.3)`. Input is always 6-char hex, no `#`. */ +function hexToRgba(hex: string, alpha: number): string { + const value = Number.parseInt(hex, 16) + return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})` +} + +export type BibleVerseActionSheetProps = { + isOpen: boolean + /** Localized display reference for the selection, e.g. `Hebrews 11:4`. */ + reference: string + swatches: VerseActionSwatch[] + onSwatchPress: (swatch: VerseActionSwatch) => void + onCopyPress: () => void + onSharePress: () => void + /** Swipe-down, or displacement by another sheet. Both are a cancel. */ + onClose: () => void + theme: Theme +} + +/** + * The verse action sheet raised over a verse selection: reference label, + * highlight swatch tray, Copy, and Share. + * + * Presentational only. `lib/verse-action-swatches.ts` decides which swatches to + * show. Acting on a press, which means writing the highlight and clearing the + * selection, is the reader's job. + */ +export function BibleVerseActionSheet({ + isOpen, + reference, + swatches, + onSwatchPress, + onCopyPress, + onSharePress, + onClose, + theme, +}: BibleVerseActionSheetProps) { + const { t } = useSdkTranslation() + + // Each edge shows its fade only while swatches are hidden under it. The gate + // is *remaining* scroll distance, not raw overflow, so a fade retires at the + // end it guards. Gating on overflow leaves the outermost swatch permanently + // dimmed, which reads as disabled. + const [trayWidth, setTrayWidth] = useState(0) + const [contentWidth, setContentWidth] = useState(0) + const [scrollX, setScrollX] = useState(0) + const hasMoreToScroll = contentWidth - trayWidth - scrollX > 1 + const hasScrolledPast = scrollX > 1 + + return ( + // Non-modal: the user is still building the selection, so the passage behind + // stays bright and tappable. A backdrop would take the tap that extends the + // selection. The exits are swipe-down, deselection, and the sheet's own + // buttons. + // + // `panActiveOffsetY` keeps the swatch tray scrollable without giving any of + // that up: the sheet's pan needs vertical intent, so a sideways drag belongs + // to the tray. See PAN_ACTIVE_OFFSET_Y. + + + + {reference} + + + + + setTrayWidth(event.nativeEvent.layout.width)} + onContentSizeChange={(width) => setContentWidth(width)} + onScroll={(event) => setScrollX(event.nativeEvent.contentOffset.x)} + scrollEventThrottle={16} + style={styles.swatchScroll} + contentContainerStyle={styles.swatchTrayContent} + > + {swatches.map((swatch) => ( + onSwatchPress(swatch)} + style={[ + styles.swatch, + { + backgroundColor: hexToRgba(swatch.color, FILL_OPACITY[theme]), + borderColor: SHEET_STROKE[theme], + }, + ]} + > + {/* + * The check takes the on-surface foreground, not a contrast + * pick against the fill. Light mode paints a full-strength + * swatch in Text/Everdark, and dark mode fades the fill to + * 30%. White reads on both. + */} + {swatch.state === 'remove' && ( + + )} + + ))} + + + {hasScrolledPast && } + {hasMoreToScroll && } + + + + + + {t('copy')} + + + + + + + {t('share')} + + + + + + ) +} + +/** + * One end of the swatch tray, fading the scrolling strip into the tray surface. + * It is opaque at the tray's outer edge and transparent where the swatches are + * legible. + * + * Both edges share one `x1 → x2` direction and swap only the stop opacities, so + * they cannot drift apart. `theme` is in the gradient id because both fades can + * be on screen at once, and SVG defs share one id namespace. + */ +function SwatchTrayFade({ edge, theme }: { edge: 'leading' | 'trailing'; theme: Theme }) { + const isLeading = edge === 'leading' + const gradientId = `yv-verse-swatch-fade-${edge}-${theme}` + + return ( + + + + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + gap: 12, + paddingHorizontal: 20, + paddingTop: 8, + paddingBottom: 16, + }, + reference: { + fontSize: 16, + fontWeight: '600', + }, + /** Tray plus both tiles, one row. `stretch` is what gives them a shared height. */ + row: { + alignItems: 'stretch', + flexDirection: 'row', + gap: 8, + }, + /** + * A fixed-width window that clips a horizontally scrolling strip of swatches. + * `flex: 1` takes the row's slack. The tray does not grow or animate. + */ + swatchTray: { + borderRadius: CORNER_RADIUS, + flex: 1, + overflow: 'hidden', + }, + /** Fills the tray's stretched height so the content container can center the swatches in it. */ + swatchScroll: { + flex: 1, + }, + /** + * `flexGrow: 1` stretches the content to the tray, so `space-evenly` spreads + * the swatches across it. Past that point the content outgrows the tray and + * scrolls, and `gap` supplies the spacing `space-evenly` has no slack for. + */ + swatchTrayContent: { + alignItems: 'center', + flexGrow: 1, + gap: SWATCH_GAP, + justifyContent: 'space-evenly', + paddingHorizontal: 6, + }, + swatchFade: { + bottom: 0, + position: 'absolute', + top: 0, + width: FADE_WIDTH, + }, + swatchFadeLeading: { + left: 0, + }, + swatchFadeTrailing: { + right: 0, + }, + swatch: { + alignItems: 'center', + borderRadius: 999, + borderWidth: 1, + height: SWATCH_SIZE, + justifyContent: 'center', + width: SWATCH_SIZE, + }, + /** Compact, roughly square, and *not* `flex: 1`. The tray takes the slack. */ + action: { + alignItems: 'center', + borderRadius: CORNER_RADIUS, + gap: ACTION_LABEL_GAP, + justifyContent: 'center', + paddingHorizontal: 14, + paddingVertical: ACTION_PADDING_VERTICAL, + }, + actionLabel: { + fontSize: 13, + fontWeight: '500', + lineHeight: ACTION_LABEL_LINE_HEIGHT, + }, +}) diff --git a/packages/ui/src/native/highlight-consent-sheet.tsx b/packages/ui/src/native/highlight-consent-sheet.tsx new file mode 100644 index 00000000..a7d52988 --- /dev/null +++ b/packages/ui/src/native/highlight-consent-sheet.tsx @@ -0,0 +1,95 @@ +import { StyleSheet, Text, View } from 'react-native' + +import { useSdkTranslation } from '../i18n/use-sdk-translation' +import { SHEET_FOREGROUND } from '../lib/native-sheet-theme' +import type { Theme } from '../lib/resolve-theme' +import { NativeSheet } from './native-sheet' +import { PromptSheetButton, PromptSheetParagraph } from './prompt-sheet' + +export type HighlightConsentSheetProps = { + isOpen: boolean + /** "Continue". Hands off to the just-in-time Data Exchange grant. */ + onConfirm: () => void + /** + * "Cancel", a swipe-down, a backdrop tap, or displacement by another sheet. + * Every one is a decline, so route all of them here. A dismissal path that + * skips `decline()` strands the flow with `isConfirming` still true. + */ + onDismiss: () => void + theme: Theme +} + +/** + * Just-in-time permission prompt, shown when a signed-in user without the + * `highlights` permission taps a highlight color. + * + * Presentational only. It runs no Data Exchange itself. `useHighlightPermissionFlow` + * drives every prop: `isConfirming` is `isOpen`, `confirm()` is `onConfirm`, and + * `decline()` is `onDismiss`. Its buttons and paragraph come from + * `prompt-sheet.tsx`, shared with `SignInWithYouVersionSheet` so the two prompts + * in the highlight flow read as one family. + */ +export function HighlightConsentSheet({ + isOpen, + onConfirm, + onDismiss, + theme, +}: HighlightConsentSheetProps) { + const { t } = useSdkTranslation() + + return ( + + + + {t('dataExchangeHighlightsQuestion')} + + + + {t('dataExchangeHighlightsExplanation')} + + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + gap: 12, + paddingHorizontal: 24, + paddingTop: 24, + paddingBottom: 32, + }, + heading: { + fontSize: 18, + fontWeight: '600', + lineHeight: 24, + textAlign: 'center', + }, + actions: { + alignItems: 'center', + gap: 12, + paddingTop: 12, + }, +}) diff --git a/packages/ui/src/native/icons/check-icon.tsx b/packages/ui/src/native/icons/check-icon.tsx new file mode 100644 index 00000000..f5ad49e7 --- /dev/null +++ b/packages/ui/src/native/icons/check-icon.tsx @@ -0,0 +1,25 @@ +import Svg, { Path, type SvgProps } from 'react-native-svg' + +/** + * The checkmark overlaid on an already-applied highlight swatch. + * + * Decorative: the swatch button around it carries the localized label. + */ +export function CheckIcon({ color, size = 24, ...props }: SvgProps & { size?: number }) { + return ( + + + + ) +} diff --git a/packages/ui/src/native/icons/copy-icon.tsx b/packages/ui/src/native/icons/copy-icon.tsx new file mode 100644 index 00000000..9a71fbcc --- /dev/null +++ b/packages/ui/src/native/icons/copy-icon.tsx @@ -0,0 +1,32 @@ +import Svg, { Path, type SvgProps } from 'react-native-svg' + +/** + * The Copy glyph on the verse action sheet. + * + * Decorative: it always sits inside a button that carries its own localized + * label, so it is hidden from assistive tech instead of labeled twice. + */ +export function CopyIcon({ color, size = 20, ...props }: SvgProps & { size?: number }) { + return ( + + + + + ) +} diff --git a/packages/ui/src/native/icons/index.ts b/packages/ui/src/native/icons/index.ts new file mode 100644 index 00000000..491c8a77 --- /dev/null +++ b/packages/ui/src/native/icons/index.ts @@ -0,0 +1,3 @@ +export { CheckIcon } from './check-icon' +export { CopyIcon } from './copy-icon' +export { ShareIcon } from './share-icon' diff --git a/packages/ui/src/native/icons/share-icon.tsx b/packages/ui/src/native/icons/share-icon.tsx new file mode 100644 index 00000000..0e70ef0c --- /dev/null +++ b/packages/ui/src/native/icons/share-icon.tsx @@ -0,0 +1,31 @@ +import Svg, { Path, type SvgProps } from 'react-native-svg' + +/** + * The Share glyph on the verse action sheet: the box-arrow-up matching the + * shipped YouVersion Bible app, not the "share nodes" glyph in the mock. + * + * Decorative, for the same reason as {@link CopyIcon}: the button around it + * carries the localized label. + */ +export function ShareIcon({ color, size = 20, ...props }: SvgProps & { size?: number }) { + return ( + + + + + ) +} diff --git a/packages/ui/src/native/native-sheet.tsx b/packages/ui/src/native/native-sheet.tsx index 4dea70b6..156c6381 100644 --- a/packages/ui/src/native/native-sheet.tsx +++ b/packages/ui/src/native/native-sheet.tsx @@ -29,7 +29,7 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context' import { create } from 'zustand' import { sheetHorizontalMargin } from '../lib/native-sheet-max-width' -import { SHEET_HANDLE, SHEET_SURFACE } from '../lib/native-sheet-theme' +import { SHEET_HANDLE, SHEET_SURFACE, SHEET_TOP_SHADOW } from '../lib/native-sheet-theme' import { useSdkTranslation } from '../i18n/use-sdk-translation' import type { Theme } from '../lib/resolve-theme' @@ -53,6 +53,32 @@ type NativeSheetProps = { onClose: () => void // Fired when a backdrop tap or pan-down close animation starts, before onClose. onDismissKeyboardStart?: () => void + // false drops the backdrop entirely, so content behind the sheet stays bright + // and interactive. The backdrop is removed instead of made transparent because + // Gorhom reads `enableTouchThrough` only for the initial pointerEvents, then + // overwrites it to 'auto' on open. An invisible backdrop would still swallow + // every tap. Tap-to-dismiss goes with the backdrop, so a non-modal caller owns + // its own dismissal. + modal?: boolean + // Vertical travel, in points, before the sheet's pan gesture may activate. + // Unset by default, and that default is what breaks a horizontally scrolling + // child on Android: RNGH's PanGestureHandler falls back to `minDist`, the + // platform touch slop, which is direction-agnostic — so a sideways drag over a + // nested ScrollView activates the *sheet's* pan. Activation cancels the touch + // stream in every native view underneath (RNGestureHandlerRootHelper's + // RootViewGestureHandler.onCancel → onChildStartedNativeGesture), so the + // ScrollView never scrolls at all. + // + // Supplying any custom activation criterion makes RNGH drop `minDist` + // entirely (PanGestureHandler.kt), so a vertical-only threshold means a + // horizontal drag can no longer reach the sheet. The child keeps its touches, + // and once it starts scrolling it calls requestDisallowInterceptTouchEvent, + // which RNGH turns into a cancel of the sheet's pan — a clean handoff. + // + // Reach for this instead of `enableContentPanningGesture={false}`, which cures + // the same symptom by removing swipe-down. Gorhom applies the value to the + // handle pan as well as the content pan; both still open on a deliberate drag. + panActiveOffsetY?: [number, number] children: React.ReactNode // iOS pre-warms matchContents and ignores this flag. showAndroidLoader?: boolean @@ -77,6 +103,8 @@ export function NativeSheet({ enableContentPanningGesture, onClose, onDismissKeyboardStart, + modal = true, + panActiveOffsetY, children, showAndroidLoader = false, loaderMinHeight = DEFAULT_LOADER_MIN_HEIGHT, @@ -124,6 +152,8 @@ export function NativeSheet({ enableContentPanningGesture={enableContentPanningGesture} onClose={onClose} onDismissKeyboardStart={onDismissKeyboardStart} + modal={modal} + panActiveOffsetY={panActiveOffsetY} showAndroidLoader={showAndroidLoader} loaderMinHeight={loaderMinHeight} theme={theme} @@ -146,6 +176,8 @@ function SheetHost({ enableContentPanningGesture, onClose, onDismissKeyboardStart, + modal, + panActiveOffsetY, children, showAndroidLoader, loaderMinHeight, @@ -162,6 +194,8 @@ function SheetHost({ enableContentPanningGesture?: boolean onClose: () => void onDismissKeyboardStart?: () => void + modal: boolean + panActiveOffsetY?: [number, number] children: React.ReactNode showAndroidLoader: boolean loaderMinHeight: number @@ -198,10 +232,16 @@ function SheetHost({ }, [windowWidth]) const surfaceColor = backgroundColor ?? (theme ? SHEET_SURFACE[theme] : undefined) - const backgroundStyle = useMemo>( - () => (surfaceColor ? { backgroundColor: surfaceColor } : undefined), - [surfaceColor], - ) + // The shadow is keyed off `theme`, not `surfaceColor`. An explicit + // `backgroundColor` on an unthemed sheet gets no shadow instead of a guessed + // one. The shadow rides on `backgroundStyle` because Gorhom's default + // background is a bare View that spreads it, with nothing between that View + // and the window to clip it. + const backgroundStyle = useMemo>(() => { + if (!surfaceColor) return undefined + if (!theme) return { backgroundColor: surfaceColor } + return { backgroundColor: surfaceColor, boxShadow: SHEET_TOP_SHADOW[theme] } + }, [surfaceColor, theme]) const handleIndicatorStyle = useMemo>( () => (theme ? [styles.handle, { backgroundColor: SHEET_HANDLE[theme] }] : styles.handle), [theme], @@ -238,8 +278,10 @@ function SheetHost({ const suppressInactiveSheet = Platform.OS === 'android' && !isActive // iOS uses box-none so the full-screen wrapper doesn't swallow taps; Android locks inactive sheets to none (ADR 0006). + // A non-modal active sheet needs box-none on Android too. Without it the + // wrapper eats the taps the dropped backdrop was supposed to let through. const outerPointerEvents: 'none' | 'box-none' | 'auto' = - Platform.OS === 'android' ? (isActive ? 'auto' : 'none') : 'box-none' + Platform.OS === 'android' ? (isActive ? (modal ? 'auto' : 'box-none') : 'none') : 'box-none' useEffect(() => { // A second footnote tap may keep isActive=true, so use openKey to snap open @@ -311,7 +353,8 @@ function SheetHost({ enableContentPanningGesture={ suppressInactiveSheet ? false : (enableContentPanningGesture ?? true) } - backdropComponent={suppressInactiveSheet ? renderNoBackdrop : renderSheetBackdrop} + activeOffsetY={panActiveOffsetY} + backdropComponent={suppressInactiveSheet || !modal ? renderNoBackdrop : renderSheetBackdrop} backgroundComponent={suppressInactiveSheet ? null : undefined} backgroundStyle={backgroundStyle} handleComponent={suppressInactiveSheet ? null : undefined} diff --git a/packages/ui/src/native/prompt-sheet.tsx b/packages/ui/src/native/prompt-sheet.tsx new file mode 100644 index 00000000..7c2242ec --- /dev/null +++ b/packages/ui/src/native/prompt-sheet.tsx @@ -0,0 +1,96 @@ +import type { ReactNode } from 'react' +import { Pressable, StyleSheet, Text } from 'react-native' + +import { + SHEET_FOREGROUND, + SHEET_INVERSE_FOREGROUND, + SHEET_MUTED_FOREGROUND, + SHEET_STROKE, +} from '../lib/native-sheet-theme' +import type { Theme } from '../lib/resolve-theme' + +/** + * The shared body parts of a **prompt sheet**: a `NativeSheet` that asks one + * question and offers a confirm and a decline. + * + * `SignInWithYouVersionSheet` and `HighlightConsentSheet` are the two prompt + * sheets, and they run back to back in the highlight flow. A signed-out swatch + * tap can raise the first and then the second, so they have to read as one + * family. The button shape and the supporting paragraph therefore live here + * instead of in both files. Each sheet still owns its own `NativeSheet`, + * container, and headline, which is where they differ. + */ + +/** Wide enough for the longest localized label, narrow enough to leave margin at the sheet's edge. */ +const BUTTON_WIDTH = 260 + +export type PromptSheetButtonProps = { + label: string + onPress: () => void + /** `'primary'` fills with the foreground and inverts its label. `'secondary'` is outlined. */ + variant: 'primary' | 'secondary' + theme: Theme + testID: string +} + +/** One stacked, full-width action button in a prompt sheet's confirm/decline pair. */ +export function PromptSheetButton({ + label, + onPress, + variant, + theme, + testID, +}: PromptSheetButtonProps) { + const isPrimary = variant === 'primary' + + return ( + + + {label} + + + ) +} + +/** Centered supporting copy under a prompt sheet's headline. */ +export function PromptSheetParagraph({ children, theme }: { children: ReactNode; theme: Theme }) { + return ( + {children} + ) +} + +const styles = StyleSheet.create({ + paragraph: { + fontSize: 14, + lineHeight: 20, + textAlign: 'center', + }, + button: { + alignItems: 'center', + borderRadius: 999, + borderWidth: 1, + borderColor: 'transparent', + justifyContent: 'center', + paddingVertical: 14, + width: BUTTON_WIDTH, + }, + buttonLabel: { + fontSize: 16, + fontWeight: '600', + }, +}) diff --git a/packages/ui/src/native/sign-in-with-youversion-sheet.tsx b/packages/ui/src/native/sign-in-with-youversion-sheet.tsx new file mode 100644 index 00000000..02169ce7 --- /dev/null +++ b/packages/ui/src/native/sign-in-with-youversion-sheet.tsx @@ -0,0 +1,102 @@ +import { useMemo } from 'react' +import { StyleSheet, Text, View } from 'react-native' + +import { useSdkTranslation } from '../i18n/use-sdk-translation' +import { resolveAppName } from '../lib/app-name' +import { SHEET_MUTED_FOREGROUND } from '../lib/native-sheet-theme' +import type { Theme } from '../lib/resolve-theme' +import { NativeSheet } from './native-sheet' +import { PromptSheetButton, PromptSheetParagraph } from './prompt-sheet' +import { YouVersionPlatformLogo, youVersionPlatformLogoSize } from './youversion-platform-logo' + +/** Wide enough to read at the sheet's width, narrow enough to leave margin. */ +const WORDMARK_WIDTH = 190 + +export type SignInWithYouVersionSheetProps = { + isOpen: boolean + /** "Yes Please". Launches the OAuth sign-in flow. */ + onConfirm: () => void + /** "No Thanks", a swipe-down, or a backdrop tap. Every one is a cancel. */ + onDismiss: () => void + theme: Theme +} + +/** + * "Sign in with YouVersion" introduction sheet, shown when a signed-out user + * taps a highlight color, before OAuth launches. + * + * Presentational only. It runs no OAuth and reads no config beyond the app's own + * display name. Its buttons and paragraph come from `prompt-sheet.tsx`, shared + * with `HighlightConsentSheet` so the two prompts in the highlight flow read as + * one family. + */ +export function SignInWithYouVersionSheet({ + isOpen, + onConfirm, + onDismiss, + theme, +}: SignInWithYouVersionSheetProps) { + const { t } = useSdkTranslation() + const appName = resolveAppName() + const logoSize = useMemo(() => youVersionPlatformLogoSize(WORDMARK_WIDTH), []) + + return ( + + + + {t('signInIntroducing')} + + + + + + {t('signInParagraph', { appName: appName ?? '' })} + + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + gap: 16, + paddingHorizontal: 24, + paddingTop: 24, + paddingBottom: 32, + }, + eyebrow: { + fontSize: 12, + fontWeight: '600', + letterSpacing: 2, + textTransform: 'uppercase', + }, + actions: { + alignItems: 'center', + gap: 12, + paddingTop: 8, + }, +}) diff --git a/packages/ui/src/native/youversion-platform-logo.tsx b/packages/ui/src/native/youversion-platform-logo.tsx new file mode 100644 index 00000000..68dd6023 --- /dev/null +++ b/packages/ui/src/native/youversion-platform-logo.tsx @@ -0,0 +1,43 @@ +import Svg, { Path, type SvgProps } from 'react-native-svg' + +/** + * The "YouVersion Platform" wordmark that heads the sign-in sheet. Not the same + * mark as `BibleAppLogo` (`native/bible-app-logo.tsx`), which is the Bible App's + * icon on the auth button. + */ + +const FILL = { + light: '#121212', + dark: '#EBDBC8', +} as const + +/** + * 238 ÷ 20. The mark is very wide, so callers size it by width and let the + * height follow. {@link youVersionPlatformLogoSize} does that. + */ +export const YOUVERSION_PLATFORM_LOGO_ASPECT_RATIO = 238 / 20 + +/** Width-driven dimensions for the wordmark, preserving its aspect ratio. */ +export function youVersionPlatformLogoSize(width: number): { width: number; height: number } { + return { width, height: width / YOUVERSION_PLATFORM_LOGO_ASPECT_RATIO } +} + +const WORDMARK_PATH = + 'M32.7334 14.2031C32.7334 15.2113 32.903 15.9364 33.2363 16.3613C33.5669 16.7805 34.0893 16.9941 34.7891 16.9941C35.2084 16.9941 35.6111 16.9161 35.9805 16.7578C37.1831 16.2466 37.9883 15.0357 37.9883 13.7441V5.38379H41.4023V19.6504H38.4805L38.1553 17.2939C38.1268 17.3388 38.0929 17.3955 38.0537 17.46L38.0527 17.4609V17.4619H38.0518V17.4639H38.0508C37.8639 17.7711 37.5651 18.262 37.2383 18.6025C36.7467 19.108 36.2218 19.4304 35.6357 19.6582C35.0498 19.8859 34.4167 19.9999 33.7559 20C32.7783 20 31.9503 19.7944 31.292 19.3945C30.6309 18.9945 30.1336 18.3995 29.8086 17.6328C29.4865 16.8746 29.3223 15.9277 29.3223 14.8252V5.38379H32.7334V14.2031ZM20.0967 5.03906C20.9826 5.03906 21.8468 5.2081 22.6689 5.54688C23.4884 5.88576 24.2165 6.38051 24.8359 7.0166C25.4525 7.65264 25.9552 8.44427 26.333 9.36914C26.708 10.2913 26.8994 11.3554 26.8994 12.5303C26.8994 13.7024 26.708 14.7664 26.333 15.6914C25.9553 16.6163 25.4525 17.4079 24.8359 18.0439C24.2165 18.6828 23.4884 19.1722 22.6689 19.5C21.8496 19.8277 20.9827 19.9941 20.0967 19.9941C19.208 19.9941 18.35 19.8277 17.5391 19.5C16.728 19.1694 15.9998 18.6801 15.3721 18.0439C14.7444 17.4079 14.2416 16.6163 13.875 15.6914C13.5084 14.7664 13.3223 13.7024 13.3223 12.5303C13.3223 11.3581 13.5084 10.2941 13.875 9.36914C14.2417 8.44426 14.7444 7.65265 15.3721 7.0166C15.9998 6.3805 16.728 5.88576 17.5391 5.54688C18.3499 5.21092 19.2108 5.03911 20.0967 5.03906ZM60.2383 6.58887C61.8299 5.21943 64 4.68357 66.125 5.27246C67.9579 5.78083 69.0718 6.96101 69.6885 8.56348C69.8274 8.93014 69.9445 9.32214 70.0361 9.73047C70.3084 10.9527 70.3185 12.1863 70.1074 13.5029H61.0273C61.1551 14.5388 61.4552 15.4552 62.0771 16.1523C62.3327 16.4412 62.6437 16.6948 63.0215 16.9004C64.3659 17.6309 66.0551 17.2634 66.9551 16.0439C67.0967 15.8551 67.2304 15.6444 67.3525 15.4111C68.2746 15.6555 69.1495 15.886 70.2021 16.1582C69.9383 16.8415 69.5857 17.4891 69.0996 18.0391C68.2246 19.0307 67.0076 19.6304 65.7188 19.8721C62.9966 20.3831 60.2221 19.1498 58.7832 16.7832C57.2527 14.2638 57.3604 10.7777 58.8408 8.2666C59.2241 7.61664 59.6967 7.05551 60.2383 6.58887ZM108.344 5.02246C109.23 5.02246 110.094 5.19144 110.916 5.53027C111.735 5.86916 112.464 6.36389 113.083 7C113.7 7.63605 114.202 8.42767 114.58 9.35254C114.955 10.2747 115.146 11.3387 115.146 12.5137C115.146 13.6858 114.955 14.7498 114.58 15.6748C114.202 16.5997 113.7 17.3913 113.083 18.0273C112.464 18.6662 111.735 19.1556 110.916 19.4834C110.097 19.8111 109.23 19.9775 108.344 19.9775C107.455 19.9775 106.596 19.8112 105.785 19.4834C104.974 19.1528 104.247 18.6634 103.619 18.0273C102.991 17.3912 102.489 16.5998 102.122 15.6748C101.755 14.7498 101.569 13.6859 101.569 12.5137C101.569 11.3415 101.755 10.2775 102.122 9.35254C102.489 8.42767 102.991 7.63604 103.619 7C104.247 6.36401 104.974 5.86914 105.785 5.53027C106.596 5.19418 107.458 5.02248 108.344 5.02246ZM87.3779 5.04688C88.725 4.99696 90.1357 5.33104 91.2217 6.15039C92.0939 6.80039 92.6916 7.71959 93.1055 8.7168C92.6535 8.82742 92.1865 8.95452 91.7207 9.08203C91.2275 9.21704 90.735 9.35253 90.2607 9.4668C90.2607 9.4668 90.1022 9.14978 90.0244 9.01367C89.4661 8.0112 88.3416 7.43633 87.2002 7.67773C86.5142 7.82215 85.767 8.30542 85.6641 9.04688C85.6085 9.45515 85.7553 9.88592 86.0469 10.1748C86.4081 10.5311 87.0153 10.6709 87.5254 10.7881H87.5264C87.5847 10.8015 87.642 10.815 87.6973 10.8281C87.9269 10.8826 88.1623 10.9336 88.4004 10.9854C89.631 11.253 90.9339 11.5361 91.8857 12.3691C92.9134 13.2691 93.4054 14.5443 93.2139 15.9053C93.0833 16.8136 92.6667 17.6892 92.0195 18.3447C90.9362 19.4391 89.3721 19.9416 87.8555 19.9639C86.8139 19.9778 85.7444 19.7694 84.8027 19.3223C83.4444 18.6778 82.5193 17.5526 81.9609 16.1748C82.5713 16.0144 83.1298 15.8651 83.6719 15.7197L83.6748 15.7188L83.6758 15.7178C84.0522 15.6168 84.4211 15.5178 84.7939 15.4189C85.6273 16.9828 86.8359 17.6032 88.4609 17.2754C89.1803 17.1282 89.8521 16.6361 89.9883 15.8779C90.1744 14.8335 89.3162 14.2054 88.4023 13.9971C88.2542 13.9631 88.1057 13.93 87.957 13.8965C86.8288 13.6419 85.6901 13.3848 84.6299 12.9111C81.6025 11.561 81.8555 7.66341 84.1582 6.06348C85.097 5.41082 86.2419 5.08854 87.3779 5.04688ZM202.797 4.66309C203.78 4.66309 204.676 4.83227 205.484 5.16895C206.306 5.49215 207.006 5.97676 207.585 6.62305C208.164 7.25597 208.615 8.04372 208.938 8.98633C209.262 9.91553 209.423 10.9862 209.423 12.1982C209.423 13.4101 209.262 14.4941 208.938 15.4502C208.629 16.3927 208.184 17.1876 207.605 17.834C207.026 18.4804 206.325 18.9719 205.504 19.3086C204.696 19.6452 203.793 19.8135 202.797 19.8135C201.814 19.8134 200.912 19.6522 200.091 19.3291C199.283 18.9924 198.582 18.507 197.989 17.874C197.41 17.2278 196.96 16.4335 196.637 15.4912C196.314 14.5485 196.151 13.4708 196.151 12.2588C196.151 11.0468 196.314 9.96904 196.637 9.02637C196.973 8.08405 197.431 7.2898 198.01 6.64355C198.602 5.99727 199.303 5.50561 200.11 5.16895C200.932 4.83233 201.827 4.66314 202.797 4.66309ZM50.375 15.6387L54.5049 1.0498H58.1855L52.3828 19.6504H48.3662L42.5635 1.0498H46.2412L50.375 15.6387ZM7.27441 9.86426L10.8359 1.03613H14.5498L9.02734 13.6553V19.6387H5.51953V13.6553L0 1.03613H3.71094L7.27441 9.86426ZM99.1436 19.6328H95.6943V5.36621H99.1436V19.6328ZM125.211 5.02246C126.216 5.0225 127.063 5.21979 127.732 5.61133C128.402 6.00577 128.916 6.59173 129.255 7.3584C129.591 8.11951 129.764 9.06423 129.767 10.167V19.6309H126.377V10.8135C126.377 9.79423 126.196 9.05776 125.841 8.63281C125.485 8.21093 124.938 7.9971 124.208 7.99707C123.225 7.99707 122.269 8.45832 121.655 9.23047C121.197 9.80543 120.955 10.5221 120.955 11.2998V19.6328H117.566V5.36621H120.508L120.819 7.66406C121.55 6.12246 122.638 5.59407 123.344 5.34961C123.944 5.14128 124.569 5.02246 125.211 5.02246ZM79.2695 5.08008C80.5286 4.91478 81.2701 5.23434 81.3291 5.25977C81.331 5.26059 81.3326 5.2606 81.333 5.26074L80.8721 8.5166C80.8721 8.5166 80.1632 8.34149 79.5576 8.33594C78.6217 8.33048 77.7076 8.64178 77.041 9.375C76.4467 10.0278 76.1133 11.1555 76.1133 12.0859V19.6143H72.6748V5.34766H75.6387L75.9746 7.79199C76.5996 6.36977 77.839 5.26619 79.2695 5.08008ZM144.186 5.02734C145.249 5.02734 146.125 5.14123 146.812 5.37012C147.512 5.59905 148.058 5.90927 148.448 6.2998C148.852 6.69021 149.128 7.14789 149.276 7.67285C149.438 8.18459 149.519 8.7305 149.519 9.30957C149.519 9.91556 149.438 10.4947 149.276 11.0469C149.115 11.5989 148.832 12.0835 148.428 12.501C148.024 12.9184 147.478 13.2488 146.791 13.4912C146.104 13.7335 145.236 13.8545 144.186 13.8545H141.196V19.4502H139.357V5.02734H144.186ZM153.458 17.7939H160.933L160.649 19.4502H151.6V5.02734H153.458V17.7939ZM174.729 19.4502H172.71L171.194 15.4307H165.014L163.498 19.4502H161.56L167.175 5.02734H169.135L174.729 19.4502ZM184.31 6.68359H179.543V19.4502H177.664V6.68359H172.896V5.02734H184.31V6.68359ZM194.805 6.66309H187.715V11.3906H194.36V13.0664H187.715V19.4502H185.856V5.02734H194.805V6.66309ZM216.427 5.02734C217.504 5.02734 218.386 5.14822 219.073 5.39062C219.76 5.63302 220.299 5.94934 220.689 6.33984C221.093 6.71689 221.369 7.14805 221.518 7.63281C221.666 8.11758 221.739 8.60919 221.739 9.10742C221.739 10.0232 221.544 10.8048 221.153 11.4512C220.776 12.0973 220.15 12.6023 219.275 12.9658L222.305 19.4502H220.225L217.497 13.4102C217.322 13.4371 217.134 13.4572 216.932 13.4707H213.538V19.4502H211.7V5.02734H216.427ZM231.008 17.2285L235.735 5.02734H237.957V19.4502H236.24V8.07715L231.715 19.4502H230.2L225.756 8.11816V19.4502H224.06V5.02734H226.362L231.008 17.2285ZM202.797 6.2793C202.016 6.27935 201.33 6.42141 200.737 6.7041C200.158 6.9869 199.667 7.39082 199.263 7.91602C198.872 8.44122 198.575 9.07378 198.373 9.81445C198.185 10.5416 198.091 11.3496 198.091 12.2383C198.091 13.1406 198.185 13.9625 198.373 14.7031C198.575 15.4303 198.872 16.0568 199.263 16.582C199.653 17.0936 200.138 17.4907 200.717 17.7734C201.309 18.0562 202.003 18.1972 202.797 18.1973C203.564 18.1973 204.238 18.0562 204.817 17.7734C205.41 17.4907 205.901 17.0937 206.292 16.582C206.683 16.0569 206.979 15.4303 207.181 14.7031C207.383 13.9624 207.483 13.1406 207.483 12.2383C207.483 11.3496 207.383 10.5416 207.181 9.81445C206.979 9.07385 206.675 8.44119 206.271 7.91602C205.881 7.39085 205.396 6.9869 204.817 6.7041C204.238 6.4213 203.564 6.2793 202.797 6.2793ZM20.0967 7.88379C19.0913 7.8839 18.294 8.30612 17.7246 9.13379C17.1526 9.96981 16.8614 11.114 16.8613 12.5303C16.8613 13.9303 17.1496 15.0643 17.7246 15.9004C18.2912 16.7307 19.0914 17.1503 20.0967 17.1504C21.1022 17.1504 21.9056 16.7308 22.4834 15.9004C23.0667 15.0615 23.3613 13.9275 23.3613 12.5303C23.3613 11.114 23.0665 9.97259 22.4834 9.13379C21.9056 8.30324 21.1022 7.88379 20.0967 7.88379ZM108.341 7.86621C107.335 7.8663 106.538 8.28867 105.969 9.11621C105.397 9.95228 105.105 11.0971 105.105 12.5137C105.105 13.9134 105.394 15.0467 105.969 15.8828C106.535 16.7133 107.335 17.1327 108.341 17.1328C109.346 17.1328 110.15 16.7134 110.728 15.8828C111.311 15.044 111.608 13.9106 111.605 12.5137C111.605 11.0971 111.311 9.95506 110.728 9.11621C110.15 8.28576 109.346 7.86621 108.341 7.86621ZM165.579 13.8145H170.629L168.084 7.12793L165.579 13.8145ZM141.196 12.2793H144.165C144.865 12.2793 145.445 12.2118 145.902 12.0771C146.36 11.9425 146.718 11.7471 146.974 11.4912C147.229 11.2354 147.404 10.9321 147.498 10.582C147.606 10.2184 147.66 9.81452 147.66 9.37012C147.66 8.91225 147.606 8.50834 147.498 8.1582C147.39 7.80808 147.202 7.51799 146.933 7.28906C146.663 7.06028 146.299 6.89187 145.842 6.78418C145.397 6.66305 144.832 6.60256 144.146 6.60254H141.196V12.2793ZM213.538 11.875H216.608C217.255 11.875 217.787 11.8145 218.204 11.6934C218.622 11.5587 218.952 11.3764 219.194 11.1475C219.437 10.9051 219.605 10.6158 219.699 10.2793C219.793 9.94263 219.841 9.56493 219.841 9.14746C219.841 8.74364 219.786 8.38677 219.679 8.07715C219.571 7.76751 219.383 7.50448 219.113 7.28906C218.858 7.06032 218.508 6.89188 218.063 6.78418C217.619 6.66298 217.053 6.60254 216.366 6.60254H213.538V11.875ZM66.0469 8.12207C64.9774 7.48597 63.8549 7.51389 62.791 8.125C62.7722 8.13529 62.7547 8.14711 62.7373 8.1582C62.7266 8.16502 62.7156 8.17142 62.7051 8.17773C62.2024 8.48602 61.8361 8.87786 61.5723 9.33887C61.2639 9.87219 61.0942 10.4944 61.0137 11.1777H67.2441C67.3719 10.2444 67.0578 9.15015 66.4912 8.50293C66.3551 8.34737 66.208 8.21651 66.0469 8.12207ZM97.4053 0C97.9912 2.27994e-05 98.4826 0.177726 98.8604 0.530273C99.2409 0.88305 99.4355 1.34175 99.4355 1.89453C99.4355 2.44727 99.2409 2.89971 98.8604 3.24414C98.4826 3.58576 97.9913 3.75877 97.4053 3.75879C96.8192 3.75879 96.3298 3.58581 95.9492 3.24414C95.5689 2.89976 95.375 2.44709 95.375 1.89453C95.375 1.34179 95.5687 0.883042 95.9492 0.530273C96.3298 0.177497 96.8192 0 97.4053 0Z' + +export type YouVersionPlatformLogoProps = SvgProps & { + theme?: 'light' | 'dark' + /** + * Required, with no default, so no hardcoded English label can ship. Source it + * from `useSdkTranslation()`. + */ + accessibilityLabel: string +} + +export function YouVersionPlatformLogo({ theme = 'light', ...props }: YouVersionPlatformLogoProps) { + return ( + + + + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71284799..be52a1db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: expo-build-properties: specifier: 56.0.20 version: 56.0.20(expo@56.0.12) + expo-clipboard: + specifier: 56.0.4 + version: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) expo-dev-client: specifier: 56.0.20 version: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3)) @@ -130,23 +133,8 @@ importers: packages/core: dependencies: '@youversion/platform-core': -<<<<<<< HEAD -<<<<<<< HEAD -<<<<<<< HEAD - specifier: 2.4.0 - version: 2.4.0 -======= - specifier: 2.3.0 - version: 2.3.0 ->>>>>>> 7b27845 (feat(core): wrap platform-core HighlightsClient with RN token auth (YPE-4169) (1/3) (#97)) -======= - specifier: 2.4.0 - version: 2.4.0 ->>>>>>> e36cf62 (chore(deps): update Web SDK packages to 2.4.0 (#103)) -======= specifier: 2.5.0 version: 2.5.0 ->>>>>>> 6ec4534 (chore(deps): update Web SDK packages to 2.5.0 (#116)) expo: specifier: '>=56.0.0 <57.0.0' version: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) @@ -217,6 +205,12 @@ importers: expo: specifier: '>=56.0.0 <57.0.0' version: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + expo-application: + specifier: '>=56.0.0 <57.0.0' + version: 56.0.3(expo@56.0.12) + expo-clipboard: + specifier: '>=56.0.0 <57.0.0' + version: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) expo-localization: specifier: 56.0.6 version: 56.0.6(expo@56.0.12)(react@19.2.5) @@ -2984,10 +2978,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@xstate/react@6.1.0': resolution: {integrity: sha512-ep9F0jGTI63B/jE8GHdMpUqtuz7yRebNaKv8EMUaiSi29NOglywc2X2YSOV/ygbIK+LtmgZ0q9anoEA2iBSEOw==} @@ -3006,31 +3002,8 @@ packages: linkedom: optional: true -<<<<<<< HEAD -<<<<<<< HEAD -<<<<<<< HEAD - '@youversion/platform-react-hooks@2.4.0': - resolution: {integrity: sha512-DNFOxTBtNoeOP6BlwPf7RhmruizSa/oNB9NVKexaNSUpXpFML+X0wjM4US2VLYPa28YQ0i2kdy2AOg6w/y8R+Q==} -======= - '@youversion/platform-core@2.3.0': - resolution: {integrity: sha512-bsneE3s7jpoetWem+1gr+tZJe3Dryq7ywmdoKCPGpaLffP5GGYl0hS2hj+QAyHo0Jk9LwCGuJVtPVCDkLMPSHg==} - peerDependencies: - linkedom: ^0.18.12 - peerDependenciesMeta: - linkedom: - optional: true - - '@youversion/platform-react-hooks@2.2.0': - resolution: {integrity: sha512-QrPe2g6Lg0IM1D2LSh2OFWO4f1DBlhXZtvpSRYTt36lPaaXkV89RxJEJYk3G0eJ1ZyrzkwuxYGvfQYJetSLTfA==} ->>>>>>> 7b27845 (feat(core): wrap platform-core HighlightsClient with RN token auth (YPE-4169) (1/3) (#97)) -======= - '@youversion/platform-react-hooks@2.4.0': - resolution: {integrity: sha512-DNFOxTBtNoeOP6BlwPf7RhmruizSa/oNB9NVKexaNSUpXpFML+X0wjM4US2VLYPa28YQ0i2kdy2AOg6w/y8R+Q==} ->>>>>>> e36cf62 (chore(deps): update Web SDK packages to 2.4.0 (#103)) -======= '@youversion/platform-react-hooks@2.5.0': resolution: {integrity: sha512-wy/q31uQBHwJLdOYYsLCGBZHEGnWOfZOlXGYRP/Ln+RdfUnm1AcY1d35i+XGuxmnuzb9hFCocyU+21sQpGZstQ==} ->>>>>>> 6ec4534 (chore(deps): update Web SDK packages to 2.5.0 (#116)) peerDependencies: react: '>=19.1.0 <20.0.0' @@ -4038,6 +4011,11 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + expo-application@56.0.3: + resolution: {integrity: sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==} + peerDependencies: + expo: '*' + expo-asset@56.0.19: resolution: {integrity: sha512-huGY0bVfYUivNOir+iUEjjW9IbNHzLFNo8d6FGh22u1OsXcFR7Za3vpu5V1gMp0dc31wiqmsXkTazjm+Rro3vA==} peerDependencies: @@ -4050,6 +4028,13 @@ packages: peerDependencies: expo: '*' + expo-clipboard@56.0.4: + resolution: {integrity: sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-constants@56.0.20: resolution: {integrity: sha512-4HgoVvUiMvcqujr/CUj7L1tumLWj8WX0PLM77OriDPoaJrMEwUHd841m4o4IoUyMPVT8XTiTiPFwJtGali8+9Q==} peerDependencies: @@ -9068,6 +9053,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.14)(react@19.2.5)': dependencies: react: 19.2.5 @@ -9098,30 +9089,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-context@1.2.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-context@1.2.2(@types/react@19.2.14)(react@19.2.3)': dependencies: - react: 19.2.5 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-dialog@1.1.15(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-context@1.2.2(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-focus-scope': 1.1.7(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-portal': 1.1.9(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) - aria-hidden: 1.2.6 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 @@ -9146,6 +9122,28 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-dialog@1.1.23(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.16(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-portal': 1.1.17(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.3) + aria-hidden: 1.2.6 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-dialog@1.1.23(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -9186,18 +9184,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.14 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -9210,6 +9196,18 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-dismissable-layer@1.1.19(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-dismissable-layer@1.1.19(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -9236,15 +9234,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': dependencies: - react: 19.2.3 + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.14)(react@19.2.3)': dependencies: - react: 19.2.5 + react: 19.2.3 optionalDependencies: '@types/react': 19.2.14 @@ -9254,6 +9252,16 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-focus-scope@1.1.16(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-focus-scope@1.1.16(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) @@ -9264,16 +9272,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-focus-scope@1.1.7(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.14 - '@radix-ui/react-focus-scope@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) @@ -9327,6 +9325,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-id@1.1.4(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-id@1.1.4(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) @@ -9517,6 +9522,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-portal@1.1.17(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-portal@1.1.17(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -9526,15 +9540,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-portal@1.1.9(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.14 - '@radix-ui/react-portal@1.1.9(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -9544,6 +9549,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-presence@1.1.10(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-presence@1.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) @@ -9570,6 +9583,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-primitive@2.1.10(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-primitive@2.1.10(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.5) @@ -9777,6 +9798,13 @@ snapshots: '@types/react': 19.2.14 optional: true + '@radix-ui/react-slot@1.3.3(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-slot@1.3.3(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.5) @@ -9931,6 +9959,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.14)(react@19.2.5)': dependencies: react: 19.2.5 @@ -9953,6 +9987,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -9976,17 +10019,17 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.14)(react@19.2.3)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.3)': + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.14)(react@19.2.5)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) - react: 19.2.3 + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 @@ -10022,6 +10065,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.14)(react@19.2.5)': dependencies: react: 19.2.5 @@ -10640,28 +10689,9 @@ snapshots: dependencies: zod: 4.1.12 -<<<<<<< HEAD -<<<<<<< HEAD -<<<<<<< HEAD - '@youversion/platform-react-hooks@2.4.0(react@19.2.5)': -======= - '@youversion/platform-core@2.3.0': - dependencies: - zod: 4.1.12 - - '@youversion/platform-react-hooks@2.2.0(react@19.2.5)': ->>>>>>> 7b27845 (feat(core): wrap platform-core HighlightsClient with RN token auth (YPE-4169) (1/3) (#97)) - dependencies: -======= - '@youversion/platform-react-hooks@2.4.0(react@19.2.5)': - dependencies: ->>>>>>> e36cf62 (chore(deps): update Web SDK packages to 2.4.0 (#103)) - '@youversion/platform-core': 2.4.0 -======= '@youversion/platform-react-hooks@2.5.0(react@19.2.5)': dependencies: '@youversion/platform-core': 2.5.0 ->>>>>>> 6ec4534 (chore(deps): update Web SDK packages to 2.5.0 (#116)) react: 19.2.5 transitivePeerDependencies: - linkedom @@ -11973,6 +12003,10 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 + expo-application@56.0.3(expo@56.0.12): + dependencies: + expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + expo-asset@56.0.19(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.2(typescript@6.0.3) @@ -12002,6 +12036,18 @@ snapshots: resolve-from: 5.0.0 semver: 7.8.1 + expo-clipboard@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3): + dependencies: + expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react: 19.2.3 + react-native: 0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3) + + expo-clipboard@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5): + dependencies: + expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + react: 19.2.5 + react-native: 0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5) + expo-constants@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3)): dependencies: '@expo/env': 2.3.1 @@ -15487,7 +15533,7 @@ snapshots: vaul@1.1.2(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - '@radix-ui/react-dialog': 1.1.15(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-dialog': 1.1.23(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: @@ -15496,7 +15542,7 @@ snapshots: vaul@1.1.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: - '@radix-ui/react-dialog': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-dialog': 1.1.23(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) transitivePeerDependencies: diff --git a/scripts/test-native-i18n-eslint.mjs b/scripts/test-native-i18n-eslint.mjs index deb0d0f1..b9b438fe 100644 --- a/scripts/test-native-i18n-eslint.mjs +++ b/scripts/test-native-i18n-eslint.mjs @@ -65,7 +65,10 @@ test('native i18n ESLint uses jsx-only mode so included JSX attributes are check ]) const i18nMessages = i18nLiteralMessages(messages) - assert.ok(i18nMessages.length > 0, 'expected violations.tsx to report i18next/no-literal-string errors') + assert.ok( + i18nMessages.length > 0, + 'expected violations.tsx to report i18next/no-literal-string errors', + ) const reportedAttributes = new Set( i18nMessages @@ -90,8 +93,7 @@ test('native i18n ESLint ignores __tests__ and *.test.tsx under native scope', a ], }) - const testsDirFile = - 'scripts/eslint-fixtures/native-i18n/simulated-native/__tests__/excluded.tsx' + const testsDirFile = 'scripts/eslint-fixtures/native-i18n/simulated-native/__tests__/excluded.tsx' const testFile = 'scripts/eslint-fixtures/native-i18n/simulated-native/excluded.test.tsx' const testsDirMessages = i18nLiteralMessages(await lintWithConfig(testsDirFile, [config])) @@ -127,9 +129,7 @@ test('native i18n ESLint does not apply outside packages/ui/src/native', async ( }) test('native i18n JSX attribute include list matches eslint.config.mjs', () => { - const productionBlock = eslintConfig.find( - (block) => block.rules?.['i18next/no-literal-string'], - ) + const productionBlock = eslintConfig.find((block) => block.rules?.['i18next/no-literal-string']) const ruleOptions = productionBlock?.rules?.['i18next/no-literal-string']?.[1] assert.equal(ruleOptions?.mode, 'jsx-only', 'production config must use jsx-only mode') From b48250b6f466ddcacd52ec1a35d8747187a713eb Mon Sep 17 00:00:00 2001 From: Dustin Kelley <141975656+Dustin-Kelley@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:39:03 -0500 Subject: [PATCH 14/43] feat(example): restore the example app to a plain SDK showcase (YPE-3713) (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(example): restore the example app to a plain SDK showcase (YPE-3713) The example app had accumulated demo scaffolding around the highlights epic. Strip it back so a partner evaluating the SDK sees the components as they would use them, not a development harness. - The Bible tab is `` again. The custom copy/share toggle and the selection status bar were wired to the same block of state (`onVerseSelect`, `clearSelectionSignal`, `onCopy`, `onShare`), so they come out together. The reader still owns the verse action sheet, which falls back to `expo-clipboard` and RN `Share` on its own. - Delete the `highlight-flow` tab and its harness. It existed to reach the permission flow before the reader could (YPE-3709 subtask 3); the reader now drives the whole flow, so it only duplicated coverage that lives in unit tests. `expo-clipboard` stays in the example's dependencies — it is a UI peer for the action sheet's Copy button, not just the removed override. Co-Authored-By: Claude Fable 5 * refactor(example): clean up layout and profile components for clarity --------- Co-authored-by: Claude Fable 5 --- AGENTS.md | 1 - apps/example/app/(tabs)/_layout.tsx | 6 - apps/example/app/(tabs)/bible-card.tsx | 1 - apps/example/app/(tabs)/highlight-flow.tsx | 305 --------------------- apps/example/app/(tabs)/index.tsx | 127 +-------- apps/example/app/(tabs)/profile.tsx | 44 +-- apps/example/app/_layout.tsx | 7 +- 7 files changed, 34 insertions(+), 457 deletions(-) delete mode 100644 apps/example/app/(tabs)/highlight-flow.tsx diff --git a/AGENTS.md b/AGENTS.md index 86de5915..d56a37ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,7 +220,6 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider - 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. - `flowError` is for terminal _flow_ failures only (a failed grant, a still-refused write). Cancels and declines resolve `{ status: 'noop' }` — a user choice is not an error and must not surface as one. - **The flow's two prompts are `HighlightConsentSheet` and `SignInWithYouVersionSheet`** (`packages/ui/src/native/`). Both are presentational, and both are internal. `BibleReader` wires them. Consent's `isOpen` is the hook's `isConfirming`, and `onConfirm` is `confirm()`. Route **every** dismissal path (button, backdrop, pan-down, displacement) to `decline()`. A path that skips it strands the flow with `isConfirming` still true. The sign-in prompt is the reader's own, in front of the flow, because the hook calls `signIn()` with no UI of its own. Neither sheet runs any auth itself. -- `apps/example/app/(tabs)/highlight-flow.tsx` is a temporary harness from the hook's own subtask. The reader now drives the whole flow, so the harness only exercises the hook in isolation. Deleting it is YPE-3711's call, not something to do in passing. ## Runtime Dependencies diff --git a/apps/example/app/(tabs)/_layout.tsx b/apps/example/app/(tabs)/_layout.tsx index 1879f705..32d1cde2 100644 --- a/apps/example/app/(tabs)/_layout.tsx +++ b/apps/example/app/(tabs)/_layout.tsx @@ -31,12 +31,6 @@ export default function Layout() { Card - {/* TEMPORARY: dev harness for the highlight permission flow (YPE-3709). - Removed together with `highlight-flow.tsx` by U2 / YPE-3711. */} - - Flow - - Profile diff --git a/apps/example/app/(tabs)/bible-card.tsx b/apps/example/app/(tabs)/bible-card.tsx index bdb231af..afd6e5c0 100644 --- a/apps/example/app/(tabs)/bible-card.tsx +++ b/apps/example/app/(tabs)/bible-card.tsx @@ -14,7 +14,6 @@ export default function BibleCardScreen() { ]} > - {/* showVersionPicker defaults to false (Web SDK parity); the sample opts in. */} diff --git a/apps/example/app/(tabs)/highlight-flow.tsx b/apps/example/app/(tabs)/highlight-flow.tsx deleted file mode 100644 index bb0b5299..00000000 --- a/apps/example/app/(tabs)/highlight-flow.tsx +++ /dev/null @@ -1,305 +0,0 @@ -// ⚠️ TEMPORARY DEV HARNESS — delete this file (and its tab trigger in -// `_layout.tsx`) as part of U2 / YPE-3711, which wires the permission flow into -// the real reader. -// -// It exists because the flow shipped by YPE-3709 subtask 3 is complete and -// unit-tested but NOT reachable from the reader: U1 has to forward the verse -// intent across the DOM boundary and U2 has to subscribe it. This screen calls -// `apply()` directly instead, which is the only way to prove the -// `youversionauth://` consent return actually works on device — especially on -// Android, where it resolves through a deep link. -// -// The inline Confirm / Decline panel below stands in for `HighlightConsentSheet`, -// which cannot be written yet: its localized keys (subtask 4) have not synced, and -// `SdkTranslationKey` is generated, so `t('dataExchangeHighlightsQuestion')` does -// not type-check in this repo today. The hook's contract is UI-agnostic, so the -// sheet is a drop-in replacement for this panel when the keys land. -// -// The example provider is deliberately still scopes-only (no `permissions` on -// `auth`), so signing in never grants `highlights`. That makes this screen walk -// the longest path every time — sign-in, then fall through to consent, then apply -// — which is exactly the path worth verifying. -import { - HIGHLIGHT_COLORS, - useHighlightPermissionFlow, - useYVAuth, - type HighlightWriteOutcome, -} from '@youversion/platform-react-native-expo-core' -import { useCallback, useState } from 'react' -import { Pressable, ScrollView, StyleSheet, Text, useColorScheme, View } from 'react-native' - -const VERSION_ID = 111 // NIV -const BOOK = 'JHN' -const CHAPTER = '3' -const VERSES = [16, 17, 18] - -export default function HighlightFlowScreen() { - const isDark = useColorScheme() === 'dark' - const c = isDark ? dark : light - - const { isAuthenticated, isLoading, userInfo, grantedPermissions, signOut } = useYVAuth() - const flow = useHighlightPermissionFlow({ - versionId: VERSION_ID, - book: BOOK, - chapter: CHAPTER, - }) - - const [selected, setSelected] = useState([16]) - const [lastOutcome, setLastOutcome] = useState(null) - const [inFlight, setInFlight] = useState(0) - const [lastSettleMs, setLastSettleMs] = useState(null) - - const toggleVerse = (verse: number) => { - setSelected((prev) => - prev.includes(verse) - ? prev.filter((v) => v !== verse) - : [...prev, verse].sort((a, b) => a - b), - ) - } - - // Deliberately does NOT lock the controls until the write settles. The paint - // is optimistic and lands on tap; the network round-trip behind it is the - // slow part, and gating the swatches on it would hide the one behaviour this - // harness exists to demonstrate. `useHighlights` supports concurrent writes, - // so overlapping taps are a supported case rather than one to prevent. - // - // `lastSettleMs` is the round-trip, measured from the tap. Compare it against - // how fast the Highlights section below repaints: the gap between the two is - // the optimistic window. - // - // `useCallback` is not here for memoization. `Date.now()` is impure, and the - // React Compiler's purity rule rejects it in a function declared bare in the - // component body — it cannot see that this one only ever runs from an `onPress`. - const run = useCallback(async (action: () => Promise) => { - const startedAt = Date.now() - setInFlight((count) => count + 1) - try { - setLastOutcome(await action()) - } finally { - setLastSettleMs(Date.now() - startedAt) - setInFlight((count) => count - 1) - } - }, []) - - return ( - - - Temporary dev harness — removed by U2 (YPE-3711) - - -
- - - - {isAuthenticated ? ( - void signOut()} - > - Sign out (reset the flow) - - ) : null} -
- -
- - {VERSES.map((verse) => { - const on = selected.includes(verse) - return ( - toggleVerse(verse)} - > - v{verse} - - ) - })} - -
- -
- - {HIGHLIGHT_COLORS.map((color) => ( - void run(() => flow.apply(color, selected))} - /> - ))} - - - {HIGHLIGHT_COLORS.map((color) => ( - void run(() => flow.highlights.remove(color, selected))} - /> - ))} - - - Top row applies (guarded by the flow); bottom row removes (passes straight through). - -
- - {/* Stand-in for HighlightConsentSheet — see the header comment. */} - {flow.isConfirming ? ( - - - Allow this app to save highlights with YouVersion? - - - YouVersion will ask you to grant access before highlights can be saved. - - - - Continue - - - Cancel - - - - ) : null} - -
- - - - - -
- -
- - - {flow.highlights.highlights.length === 0 ? ( - none - ) : ( - flow.highlights.highlights.map((h) => ( - - )) - )} -
-
- ) -} - -function formatGrant(granted: readonly string[] | null): string { - if (granted === null) { - return 'null (nothing requested / unknown)' - } - return granted.length === 0 ? '[] (asked and denied)' : granted.join(', ') -} - -type Palette = typeof light - -function Section({ - title, - color, - children, -}: { - title: string - color: Palette - children: React.ReactNode -}) { - return ( - - {title.toUpperCase()} - {children} - - ) -} - -function Row({ label, value, color }: { label: string; value: string; color: Palette }) { - return ( - - {label} - - {value} - - - ) -} - -const light = { - bg: '#ffffff', - fg: '#000000', - muted: '#6b6b6b', - border: '#d8d8d8', - chipOn: '#e6e6e6', - warn: '#a35200', -} -const dark = { - bg: '#000000', - fg: '#ffffff', - muted: '#9b9b9b', - border: '#333333', - chipOn: '#2a2a2a', - warn: '#ffb964', -} - -const styles = StyleSheet.create({ - container: { padding: 16, gap: 16 }, - banner: { - borderWidth: 1, - borderRadius: 8, - padding: 8, - fontSize: 12, - fontWeight: '600', - textAlign: 'center', - }, - section: { borderWidth: 1, borderRadius: 10, padding: 12, gap: 8 }, - sectionTitle: { fontSize: 11, fontWeight: '700', letterSpacing: 0.8 }, - row: { flexDirection: 'row', gap: 8, flexWrap: 'wrap', alignItems: 'center' }, - chip: { - borderWidth: 1, - borderRadius: 8, - paddingVertical: 8, - paddingHorizontal: 12, - minWidth: 44, - }, - swatch: { width: 44, height: 32, borderRadius: 8, borderWidth: 1 }, - button: { borderWidth: 1, borderRadius: 8, paddingVertical: 10, paddingHorizontal: 14 }, - hint: { fontSize: 12 }, - consent: { borderWidth: 2, borderRadius: 10, padding: 12, gap: 10 }, - consentTitle: { fontSize: 16, fontWeight: '600' }, - kv: { flexDirection: 'row', gap: 8, alignItems: 'flex-start' }, - key: { fontSize: 13, width: 104 }, - value: { fontSize: 13, flex: 1 }, -}) diff --git a/apps/example/app/(tabs)/index.tsx b/apps/example/app/(tabs)/index.tsx index ca2167b1..a55d23e1 100644 --- a/apps/example/app/(tabs)/index.tsx +++ b/apps/example/app/(tabs)/index.tsx @@ -1,54 +1,10 @@ -import { - BibleReader, - type BibleReaderShareData, - type BibleReaderVerseSelection, -} from '@youversion/platform-react-native-expo-ui' -import * as Clipboard from 'expo-clipboard' -import { useCallback, useState } from 'react' -import { Pressable, Share, StyleSheet, Switch, Text, useColorScheme, View } from 'react-native' +import { BibleReader } from '@youversion/platform-react-native-expo-ui' +import { StyleSheet, useColorScheme, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -/** - * A verse tap opens the SDK's native verse action sheet: the reference, the - * highlight swatches, Copy, and Share. Nothing on this screen opens the sheet. - * The reader owns it. - * - * This screen shows the two things a host app can do around the sheet: - * - * 1. Observe the selection (`onVerseSelect`) and clear it (`clearSelectionSignal`). - * The strip under the header is app UI, not SDK UI. It sits at the top - * because the verse action sheet owns the bottom of the screen while a - * selection is live. - * 2. Replace Copy and Share (`onCopy` / `onShare`). The toggle keeps the SDK - * fallbacks reachable, so a device can test both paths. - */ export default function BibleScreen() { - const isDark = useColorScheme() === 'dark' const { top } = useSafeAreaInsets() - - const [selectedVerses, setSelectedVerses] = useState(null) - const [clearSelectionSignal, setClearSelectionSignal] = useState(0) - const [useCustomActions, setUseCustomActions] = useState(false) - const [lastAction, setLastAction] = useState(null) - - const onVerseSelect = useCallback(async (next: BibleReaderVerseSelection) => { - setSelectedVerses(next.verses.length > 0 ? next : null) - }, []) - - // An override wins over the SDK's `expo-clipboard` and `Share.share` - // fallbacks. `data.text` is the verse text plus the reference line. The other - // fields are there so a host app can build its own string. - const onCopy = useCallback(async (data: BibleReaderShareData) => { - await Clipboard.setStringAsync(`${data.text}\n\nCopied from the example app`) - setLastAction(`Custom copy: ${data.reference}`) - }, []) - - const onShare = useCallback(async (data: BibleReaderShareData) => { - await Share.share({ message: `${data.text}\n\nShared from the example app` }) - setLastAction(`Custom share: ${data.reference}`) - }, []) - - const statusLabel = selectedVerses ? selectedVerses.reference : lastAction + const isDark = useColorScheme() === 'dark' return ( - - - Custom Copy / Share - - - - - {/* Always rendered, so selecting a verse does not resize the reader. */} - - - {statusLabel ?? 'Tap a verse to open the action sheet'} - - {statusLabel ? ( - { - setClearSelectionSignal((signal) => signal + 1) - setLastAction(null) - }} - style={styles.clearButton} - > - Clear - - ) : null} - - - + ) } @@ -98,47 +22,4 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - headerRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: 12, - paddingHorizontal: 16, - paddingVertical: 8, - }, - headerLabel: { - fontSize: 14, - fontWeight: '600', - }, - statusBar: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: 12, - marginHorizontal: 16, - marginBottom: 8, - // Fixed height so showing the Clear button does not resize the reader. - minHeight: 49, - paddingVertical: 10, - paddingHorizontal: 14, - borderRadius: 12, - backgroundColor: '#1f2933', - }, - statusLabel: { - flexShrink: 1, - color: '#ffffff', - fontSize: 15, - fontWeight: '600', - }, - clearButton: { - paddingVertical: 6, - paddingHorizontal: 12, - borderRadius: 8, - backgroundColor: '#3e4c59', - }, - clearButtonLabel: { - color: '#ffffff', - fontSize: 14, - fontWeight: '600', - }, }) diff --git a/apps/example/app/(tabs)/profile.tsx b/apps/example/app/(tabs)/profile.tsx index f93c3e7c..01024815 100644 --- a/apps/example/app/(tabs)/profile.tsx +++ b/apps/example/app/(tabs)/profile.tsx @@ -4,34 +4,46 @@ import { Image, StyleSheet, Text, useColorScheme, View } from 'react-native' export default function ProfileScreen() { const { isAuthenticated, isLoading, userInfo } = useYVAuth() - const isDark = useColorScheme() === 'dark' - const c = isDark ? dark : light + const colorScheme = useColorScheme() === 'dark' ? 'dark' : 'light' + const theme = colorTheme[colorScheme] - return ( - - {isLoading ? ( - Loading… - ) : isAuthenticated ? ( + if (isLoading) { + return ( + + Loading… + + ) + } + + if (isAuthenticated) { + return ( + {userInfo?.avatarUrl ? ( ) : null} - You are signed in as - {userInfo?.name ?? '(no name)'} - {userInfo?.email ?? '(no email)'} + You are signed in as + {userInfo?.name ?? '(no name)'} + {userInfo?.email ?? '(no email)'} - + - ) : ( - - )} + + ) + } + + return ( + + ) } -const light = { bg: '#ffffff', fg: '#000000', muted: '#6b6b6b', email: '#3c3c3c' } -const dark = { bg: '#000000', fg: '#ffffff', muted: '#9b9b9b', email: '#c8c8c8' } +const colorTheme = { + light: { bg: '#ffffff', fg: '#000000', muted: '#6b6b6b', email: '#3c3c3c' }, + dark: { bg: '#000000', fg: '#ffffff', muted: '#9b9b9b', email: '#c8c8c8' }, +} as const const styles = StyleSheet.create({ container: { diff --git a/apps/example/app/_layout.tsx b/apps/example/app/_layout.tsx index 34b7ff40..2d24ab8e 100644 --- a/apps/example/app/_layout.tsx +++ b/apps/example/app/_layout.tsx @@ -4,9 +4,6 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler' import MissingAppKey from './_components/missing-app-key' /** - * The app's one callback URL, using the value the Swift and Kotlin SDKs use - * (`DEFAULT_AUTH_CALLBACK` in Kotlin's `YouVersionPlatformConfiguration`). - * * An app key has exactly one registered callback URL, and both flows that come * back through the browser — sign-in and the data-exchange permission grant — * use it. Register this exact value in the YouVersion Platform console, and @@ -16,7 +13,7 @@ const REDIRECT_URI = 'youversionauth://callback' export default function RootLayout() { const appKey = process.env.EXPO_PUBLIC_YOUVERSION_APP_KEY - const redirectUri = REDIRECT_URI + return ( @@ -25,7 +22,7 @@ export default function RootLayout() { appKey={appKey} theme="system" auth={{ - redirectUri, + redirectUri: REDIRECT_URI, scopes: ['profile', 'email'], permissions: ['highlights'], }} From a523cb56b0d022d288b0324ab185235be235bc04 Mon Sep 17 00:00:00 2001 From: Dustin Kelley <141975656+Dustin-Kelley@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:51:38 -0500 Subject: [PATCH 15/43] fix(core): report whether the token refresh actually worked(YPE-4297) (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core): report whether the token refresh actually worked `refreshToken` swallows failure by design — a transient failure must not sign anyone out — and returns nothing. So a caller reading the token afterwards cannot tell a refresh that worked from one that did not, and `useHighlights.runWrite` was doing exactly that: await `ensureFreshToken()`, then read `authRef.current.accessToken` and send it. That is correct offline, where the write dies at the network layer with no status and classifies as `transient`. It is wrong when the token endpoint is down but the highlights API is reachable: 1. Access token is expired; the refresh 5xxs or times out. Tokens are retained, per policy, and the token stays expired. 2. The write goes out with it anyway and comes back 401. 3. 401 classifies as `auth`, and `useHighlightPermissionFlow` reads `auth` as a stale grant (ADR 0016). 4. A valid `highlights` grant is invalidated and consent is re-prompted. 5. The re-consent mints with the same expired token, 401s in turn, and dead-ends as `not-permitted` — which the docs describe as an app-key setting, so the user is told to check a console they cannot see. Add `getAccessToken()` to the auth context. It runs the same leeway-gated, single-flight refresh as `ensureFreshToken()`, then re-reads the refs it left behind and says which happened: `ok` with the token, `signed-out`, or `refresh-failed`. Non-forced on purpose — the leeway gate already refreshes exactly when the token needs it, and joining an in-flight refresh comes free, so concurrent callers make one HTTP call and all get the new token. It never rejects, and makes no network call when there is no refresh token to spend. `runWrite` sources its token from it. `refresh-failed` reverts the paint and settles `transient` without issuing the request, which is the line that stops step 2 and so the whole chain; `signed-out` keeps the existing `not-signed-in` path. The same-user identity guard is unchanged, now evaluated against the accessor's result. Nothing else moves. `isAuthenticated` still means "has a session", so an offline user with a retained session still renders signed in — the regression the obvious fix here would have introduced — and the retention test at auth-provider.test.tsx:451 passes unmodified. `ensureFreshToken()` stays for callers that only want the side effect. `useHighlightPermissionFlow` is untouched: with a failed refresh now classified `transient` upstream, its `auth` branch is correct as written. Co-Authored-By: Claude Fable 5 * fix(core): pair the access token with the user it belongs to `getAccessToken` reads AuthProvider's refs, which `setAuthState` writes synchronously, while `useHighlights` compares against an `identityRef` synced from a passive effect a render later. A sign-in as a different user landing while a write awaits the accessor therefore moved the token first: the identity check passed against the departed user's captured id and the write went out under the new user's credentials — the exact outcome the guard above it exists to prevent. `AccessTokenResult`'s `ok` variant now carries the `userId` read in the same synchronous block as the token, and the write guard compares against that. The regression test drives the window directly (accessor returns a token owned by someone else while the rendered identity still lags) and fails without the change. Co-Authored-By: Claude Opus 5 * fix(core): stop a failed refresh from dead-ending the consent flow Three changes to the same seam, all addressing review on #122. The accessor gated on the leeway window, which answers "is the token fresh enough?" when the question at that point is "did the refresh land?". A genuinely new token minted with a lifetime at or under the 60s leeway reported `refresh-failed` despite the refresh working. Gate on actual expiry instead, with a finite check so a corrupt stored expiry (NaN) fails closed rather than passing every comparison. This also dissolves the duplicated freshness rule the review flagged — the accessor no longer restates the gate at line 143. `requestPermissions` sources its pre-mint token from the accessor too. A signed-in user without the `highlights` grant reaches consent with no write in front of it, so an expired token and a failing token endpoint sent the mint anyway and dead-ended on `not-permitted`. It now resolves `transient` without minting. `signed-out` still falls through on purpose — the initiator guard owns that case and reports `user-changed`. Make `refreshToken` total. Its revocation branch awaits `clearAuthState()`, which ends in a Keychain delete that can reject; that rejection escaped through `ensureFreshToken`, `getAccessToken`, and `requestPermissions`, all three documented never to throw. Co-Authored-By: Claude Opus 5 * refactor(core): drop the unreachable arm from the write-token ternary `getAccessToken` is a required, non-optional member of `AuthContextValue` (auth-context.tsx:59), so a null accessor implies a null `auth`, which implies a null `accessToken`. The `rawToken !== null` arm could never run, and the comment justifying the lagging identity check rested on a "no-accessor path" that does not exist. Collapse to the two reachable outcomes and re-anchor the comment on what the check actually still buys: the branch below reuses `isSameUser` to tell a user switch from a plain failed refresh, and only the latter reports `transient`. Co-Authored-By: Claude Opus 5 * docs: list getAccessToken in the core exports surface The AccessTokenResult type reached the auth types list; the accessor it describes never reached the useYVAuth value list beside it. The Exports section is the canonical list, so the two move together. Co-Authored-By: Claude Opus 5 * docs: give Access Token Result a term in the domain model CONTEXT.md already carries Highlight Write Outcome and Granted Permissions as ubiquitous-language terms, and AGENTS.md points here for domain language. The signed-out / refresh-failed split is the same kind of concept and was missing. Add the term, plus a relationship line for what this PR changed about `transient` — an unavailable result now settles a write and a Data Exchange without a round-trip, which is what keeps a valid grant from being dropped on a 401 that only meant the network was down. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Fable 5 --- .changeset/core-get-access-token.md | 15 ++ AGENTS.md | 3 +- CONTEXT.md | 5 + .../src/auth/__tests__/auth-provider.test.tsx | 237 +++++++++++++++++- .../src/auth/__tests__/use-yv-auth.test.tsx | 1 + packages/core/src/auth/auth-context.tsx | 27 ++ packages/core/src/auth/auth-provider.tsx | 96 +++++-- packages/core/src/auth/data-exchange.ts | 3 +- packages/core/src/auth/index.ts | 1 + .../use-highlight-permission-flow.test.tsx | 5 + .../__tests__/use-highlights.test.tsx | 124 ++++++++- .../core/src/highlights/use-highlights.ts | 77 ++++-- packages/core/src/index.ts | 1 + .../bible-reader-highlights-prompts.test.tsx | 5 + 14 files changed, 539 insertions(+), 61 deletions(-) create mode 100644 .changeset/core-get-access-token.md diff --git a/.changeset/core-get-access-token.md b/.changeset/core-get-access-token.md new file mode 100644 index 00000000..fa1db681 --- /dev/null +++ b/.changeset/core-get-access-token.md @@ -0,0 +1,15 @@ +--- +'@youversion/platform-react-native-expo-core': patch +--- + +Fix a token endpoint outage presenting to the user as a revoked permission. When the access token was expired and the refresh failed for a reason that was not a revocation — a 5xx, a timeout, a captive portal — the highlight write went out with the expired token anyway. It came back 401, the 401 classified as `auth`, and `useHighlightPermissionFlow` reads `auth` as a stale grant, so a valid `highlights` grant was invalidated and the user was asked to consent again. The re-consent then minted with the same expired token, 401'd in turn, and dead-ended as `not-permitted` — which the docs describe as an app-key setting, not anything the user can act on. + +The cause is that `refreshToken` swallows failure by design, to keep the retention policy: a transient failure must not sign anyone out. It returns nothing, so a caller reading the token afterwards could not tell a refresh that worked from one that did not. + +Add `getAccessToken()` to the auth context, exported through `useYVAuth()` and typed as `AccessTokenResult`. It runs the same leeway-gated, single-flight refresh as `ensureFreshToken()`, then reports the outcome: `{ status: 'ok', token, userId }`, or `{ status: 'unavailable', reason: 'signed-out' | 'refresh-failed' }`. 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 a sign-in, and the highlights write path compares against the accessor's value for exactly that reason. It never rejects, it makes no network call when there is no refresh token to spend, and concurrent callers join one refresh and all receive the new token. `refresh-failed` leaves the tokens in storage — the session is intact and the user stays signed in, which is the existing policy and not something to change. + +`requestPermissions` sources its pre-mint token from it too, closing the same hole one step further along: a signed-in user who does not yet hold the `highlights` grant reaches the consent flow directly, with no write in front of it, so an expired token and a failing token endpoint sent the mint out anyway and dead-ended on `not-permitted`. A `refresh-failed` now resolves `{ status: 'failure', reason: 'transient' }` **without minting**. `signed-out` is unchanged on purpose — the session was cleared mid-flow, and the initiator guard already owns that case by minting with the token this render captured and reporting `user-changed`. + +Also make `refreshToken` total. Its revocation branch awaits `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 in this file. + +`useHighlights` sources its write token from it. A `refresh-failed` now settles as a `transient` outcome **without issuing the request**, so the false `auth` can no longer be manufactured; `signed-out` maps to `not-signed-in` as before. Nothing else moves: `isAuthenticated` still means "has a session", so an offline user with a retained session still renders as signed in, and `ensureFreshToken()` stays for callers that only want the refresh side effect. diff --git a/AGENTS.md b/AGENTS.md index d56a37ef..553c2c8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,7 +168,7 @@ Keep `apps/example/metro.config.js` minimal — just `getDefaultConfig(__dirname **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` -**Core** (`@youversion/platform-react-native-expo-core`): `YouVersionProvider` (installation id + optional auth), `useYouVersion`, `useYVAuth` (its value carries `requestedPermissions` / `grantedPermissions` / `hasPermission` / `invalidatePermissions` / `requestPermissions` / `ensureFreshToken` alongside the sign-in surface), `useHighlights`, `useHighlightPermissionFlow`, `deriveServerColors`, `HIGHLIGHT_COLORS` / `isHighlightColor`, `mmkvStorage`, auth types (`AuthConfig`, `AuthPermission`, `KnownAuthPermission`, `AuthScope`, `DataExchangeOutcome`, `DataExchangeFailureReason`, `YVUserInfo`), and highlights types (`Highlight`, `HighlightScope`, `ServerColors`, `HighlightWriteOutcome`, `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` / `ensureFreshToken` / `getAccessToken` alongside the sign-in surface), `useHighlights`, `useHighlightPermissionFlow`, `deriveServerColors`, `HIGHLIGHT_COLORS` / `isHighlightColor`, `mmkvStorage`, auth types (`AccessTokenResult`, `AuthConfig`, `AuthPermission`, `KnownAuthPermission`, `AuthScope`, `DataExchangeOutcome`, `DataExchangeFailureReason`, `YVUserInfo`), and highlights types (`Highlight`, `HighlightScope`, `ServerColors`, `HighlightWriteOutcome`, `HighlightsFetchError`, `UseHighlightsOptions`, `UseHighlightsResult`, `UseHighlightPermissionFlowResult`, `PermissionFlowError`, `PermissionFlowErrorReason`) UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider`. Import Bible components from UI; import `useYVAuth` from core. @@ -195,6 +195,7 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider - `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. - 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. diff --git a/CONTEXT.md b/CONTEXT.md index ac1dd3db..b888200d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -138,6 +138,10 @@ _Avoid_: Re-deriving the rule from the sheet's UI; an ALL rule (a color on one v What an `apply` or `remove` resolves to: `ok` with the verses that landed, `noop` when there was nothing to write, or `error` carrying a `reason` (`not-signed-in` / `auth` / `invalid` / `transient`), a diagnostic `message`, and both `failedVerses` (reverted) and `succeededVerses` (landed — non-empty means a partial batch). The only channel a write failure reports on; the hook's `error` state is for fetches alone, so a failed write can never evict a fetch error that is still true. _Avoid_: Branching on `message` (generic outside development builds); routing write failures through the hook's `error`; a separate `partial` status (the two verse arrays already say it) +**Access Token Result**: +What `getAccessToken()` resolves to, and the only thing in the SDK that can tell a refresh that worked from one that did not — `refreshToken` swallows failure by design, so a caller reading the token afterwards cannot. Either `ok` with the `token` and the `userId` that owns it, or `unavailable` with a `reason`. The two reasons are different situations, not degrees of the same one: `signed-out` means there is no session, while `refresh-failed` means the session is intact and only the token endpoint is unreachable — tokens stay in storage and the user stays signed in. The `userId` rides along because it is read in the same synchronous block as the token; `userInfo` read through a render lags it, so a caller guarding on a captured identity that compares against the lagging one passes a check it should have failed. +_Avoid_: Collapsing `refresh-failed` into `signed-out` (it would sign out a user who is merely offline); treating an `ok` token as freshly minted (it may be an unexpired one no refresh was owed for); reading the owner from `userInfo` alongside a token from here + **Granted Permissions**: What the user actually granted at sign-in, read off the OAuth **app redirect** and cached per user. A three-state signal, not a list: `null` = no `granted_permissions` key at all, so nothing was requested and nothing is known; `[]` = requested and **denied**; populated = granted. Requesting a permission (`AuthConfig.permissions`) is a separate thing from being granted it. Values the SDK does not recognize are kept verbatim rather than narrowed to the known permission union. _Avoid_: Scopes (permissions travel as `requested_permissions[]`, never in `scope`); collapsing `[]` into `null` (it erases "the user said no"); "requested permissions" when you mean the grant @@ -185,6 +189,7 @@ _Avoid_: Persisting it (that is F1's offline queue, a different thing); keeping - With the in-WebView verse action **Presentation Shell** switched off, a **Verse Selection** crosses to native as a **Native Action** and a **Selection Clear Signal** crosses back. Neither is **DOM-Owned Sheet UI State**: the selection is a committed observation, and the clear is a one-way native→DOM command. - **Granted Permissions** are read only from the app redirect, never from the `/auth/callback` hop, which drops them. They are **Native-Owned State** cached per user in MMKV and purged with the rest of auth state on sign-out; a stale grant can be invalidated so the next pre-flight re-prompts. - A permission pre-flight reads **Granted Permissions**; a **Highlight Write Outcome** of `reason: 'auth'` is the corrective path when that cache is wrong, not the primary signal. +- Every request that spends a token sources it from an **Access Token Result**, so an expired one is caught before it goes out. An `unavailable` result therefore settles the caller without a round-trip: a write becomes a `transient` **Highlight Write Outcome** and **Data Exchange** a `transient` failure, neither of which touches **Granted Permissions**. Letting the request 401 instead would classify as `auth`, and a **Permission Flow** reads that as a stale grant and drops one that was valid. - **Data Exchange** is the other way to obtain **Granted Permissions** — the one that does not require a new sign-in. It writes into the same per-user cache, merging rather than replacing, and only ever on a granted return. - A **Permission Flow** composes the permission pre-flight, sign-in, and **Data Exchange** around a single guarded `apply`; its consent confirmation is a **Native Sheet**, whose every dismissal path routes to decline. - A **Verse Action Sheet** is open exactly while a **Verse Selection** is live and no permission prompt is up. Every exit from it increments the **Selection Clear Signal**, so the selection and the sheet cannot disagree about whether one exists. diff --git a/packages/core/src/auth/__tests__/auth-provider.test.tsx b/packages/core/src/auth/__tests__/auth-provider.test.tsx index a707f7bd..729e5ce1 100644 --- a/packages/core/src/auth/__tests__/auth-provider.test.tsx +++ b/packages/core/src/auth/__tests__/auth-provider.test.tsx @@ -577,6 +577,202 @@ describe('AuthProvider — refresh lock', () => { }) }) +describe('AuthProvider — getAccessToken', () => { + const clearedTokens = { accessToken: null, refreshToken: null, expiryDate: null } + + function renderProvider() { + return render( + + + , + ) + } + + it('resolves ok with the current token, with no refresh call, when it is beyond the leeway', async () => { + mockLoadTokens.mockResolvedValue({ + accessToken: 'stored-access', + refreshToken: 'stored-refresh', + expiryDate: new Date(Date.now() + 60 * 60 * 1000), + }) + + renderProvider() + await waitFor(() => expect(getText('isLoading')).toBe('false')) + + const result = await act(async () => latestAuth!.getAccessToken()) + + expect(result).toEqual({ status: 'ok', token: 'stored-access', userId: null }) + expect(mockRefreshTokens).not.toHaveBeenCalled() + }) + + it('refreshes an expired token and resolves ok with the new one', async () => { + mockLoadTokens.mockResolvedValue({ + accessToken: 'stored-access', + refreshToken: 'stored-refresh', + expiryDate: new Date(Date.now() - 1000), + }) + // Bootstrap's refresh lands a token that is *still* at expiry, so the + // accessor's own leeway check genuinely triggers the second refresh. + mockRefreshTokens.mockResolvedValueOnce({ + ...validTokens, + access_token: 'still-stale', + expires_in: '0', + }) + renderProvider() + await waitFor(() => expect(getText('isLoading')).toBe('false')) + expect(mockRefreshTokens).toHaveBeenCalledTimes(1) + + mockRefreshTokens.mockResolvedValueOnce({ ...validTokens, access_token: 'fresh-access' }) + const result = await act(async () => latestAuth!.getAccessToken()) + + expect(result).toEqual({ status: 'ok', token: 'fresh-access', userId: null }) + expect(mockRefreshTokens).toHaveBeenCalledTimes(2) + }) + + it('reports refresh-failed on a transient refresh error, keeping tokens and the session', async () => { + mockLoadTokens.mockResolvedValue({ + accessToken: 'stored-access', + refreshToken: 'stored-refresh', + expiryDate: new Date(Date.now() - 1000), + }) + mockRefreshTokens.mockRejectedValue(new Error('Network request failed')) + + renderProvider() + await waitFor(() => expect(getText('isLoading')).toBe('false')) + + const result = await act(async () => latestAuth!.getAccessToken()) + + // The line the highlights write path branches on: still signed in, token + // just not fresh — a retryable condition, not a sign-out. + expect(result).toEqual({ status: 'unavailable', reason: 'refresh-failed' }) + expect(getText('isAuthenticated')).toBe('true') + expect(getText('accessToken')).toBe('stored-access') + expect(mockSaveTokens).not.toHaveBeenCalledWith(clearedTokens) + }) + + // The leeway triggers the refresh; it does not decide usable. A token 30s from + // expiry still works, so a failed refresh must hand it over, not refuse it. + it('resolves ok with a token inside the leeway window that the refresh could not replace', async () => { + mockLoadTokens.mockResolvedValue({ + accessToken: 'stored-access', + refreshToken: 'stored-refresh', + expiryDate: new Date(Date.now() + 30 * 1000), + }) + mockRefreshTokens.mockRejectedValue(new Error('Network request failed')) + + renderProvider() + await waitFor(() => expect(getText('isLoading')).toBe('false')) + + const result = await act(async () => latestAuth!.getAccessToken()) + + expect(result).toEqual({ status: 'ok', token: 'stored-access', userId: null }) + // Pins the failed-refresh path, not the fresh-token shortcut. + expect(mockRefreshTokens).toHaveBeenCalled() + }) + + it('reports signed-out when the refresh finds the token revoked and clears the session', async () => { + mockLoadTokens.mockResolvedValue({ + accessToken: 'stored-access', + refreshToken: 'stored-refresh', + expiryDate: new Date(Date.now() - 1000), + }) + // Bootstrap keeps the token stale; the accessor's refresh hits the revocation. + mockRefreshTokens + .mockResolvedValueOnce({ ...validTokens, access_token: 'still-stale', expires_in: '0' }) + .mockRejectedValueOnce(new TokenEndpointError(401, 'invalid_grant')) + + renderProvider() + await waitFor(() => expect(getText('isLoading')).toBe('false')) + + const result = await act(async () => latestAuth!.getAccessToken()) + + expect(result).toEqual({ status: 'unavailable', reason: 'signed-out' }) + expect(getText('isAuthenticated')).toBe('false') + expect(mockSaveTokens).toHaveBeenCalledWith(clearedTokens) + }) + + it('reports signed-out without a network call when no refresh token is stored', async () => { + mockLoadTokens.mockResolvedValue(noStoredTokens) + + renderProvider() + await waitFor(() => expect(getText('isLoading')).toBe('false')) + + const result = await act(async () => latestAuth!.getAccessToken()) + + expect(result).toEqual({ status: 'unavailable', reason: 'signed-out' }) + expect(mockRefreshTokens).not.toHaveBeenCalled() + }) + + // The pairing a caller with a captured identity relies on: the token and the + // id of whoever owns it come from the same read, so a sign-in that lands while + // the caller is awaiting cannot hand it a token attributed to the old user. + it('reports the signed-in user alongside the token, updated by a sign-in as somebody else', async () => { + mockMmkv.set(MMKV_AUTH_KEYS.cachedUserInfo, JSON.stringify(adaUserInfo)) + mockLoadTokens.mockResolvedValue({ + accessToken: 'ada-access', + refreshToken: 'ada-refresh', + expiryDate: new Date(Date.now() + 60 * 60 * 1000), + }) + + renderProvider() + await waitFor(() => expect(getText('isLoading')).toBe('false')) + + expect(await act(async () => latestAuth!.getAccessToken())).toEqual({ + status: 'ok', + token: 'ada-access', + userId: 'u1', + }) + + mockSignInWithPKCE.mockResolvedValue({ + kind: 'success', + tokens: { ...validTokens, access_token: 'grace-access' }, + userInfo: { id: 'u2', name: 'Grace' }, + grantedPermissions: null, + }) + await userEvent.press(screen.getByTestId('signIn')) + await waitFor(() => expect(getText('signInOutcome')).toBe('resolved')) + + expect(await act(async () => latestAuth!.getAccessToken())).toEqual({ + status: 'ok', + token: 'grace-access', + userId: 'u2', + }) + }) + + it('joins an in-flight refresh: concurrent callers share one HTTP call and get the new token', async () => { + mockLoadTokens.mockResolvedValue({ + accessToken: 'expired-access', + refreshToken: 'r', + expiryDate: new Date(Date.now() - 1000), + }) + + let resolveRefresh: (v: TokenResponse) => void = () => {} + mockRefreshTokens.mockReturnValue( + new Promise((r) => { + resolveRefresh = r + }), + ) + + renderProvider() + + // Bootstrap's refresh is in flight and held open; both accessor calls must + // join it rather than resolve on the expired token or start a second call. + await waitFor(() => expect(mockRefreshTokens).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(latestAuth).not.toBeNull()) + + const first = latestAuth!.getAccessToken() + const second = latestAuth!.getAccessToken() + + await act(async () => { + resolveRefresh(validTokens) + await Promise.all([first, second]) + }) + + expect(await first).toEqual({ status: 'ok', token: 'new-access', userId: null }) + expect(await second).toEqual({ status: 'ok', token: 'new-access', userId: null }) + expect(mockRefreshTokens).toHaveBeenCalledTimes(1) + }) +}) + describe('AuthProvider — AppState wiring', () => { it('does not trigger a refresh on "active" when no refresh token is available', async () => { mockLoadTokens.mockResolvedValue(noStoredTokens) @@ -1094,12 +1290,33 @@ describe('AuthProvider — requestPermissions', () => { ) }) + // The third state, and the one this flow used to have no name for: the token + // is expired and the refresh did not land, but the session is intact. Minting + // anyway earns a 401, and every mint 401 reads as `not-permitted` — dead-ending + // a user with a stale token on "check your app key". + it('fails transient without minting when the token is expired and the refresh fails', async () => { + await signInWithStaleToken() + + mockRefreshTokens.mockRejectedValueOnce(new Error('Network request failed')) + + expect(await pressRequestPermissions()).toMatchObject({ + status: 'failure', + reason: 'transient', + }) + expect(mockRefreshTokens).toHaveBeenCalledTimes(2) + expect(mockCreateDataExchangeApi).not.toHaveBeenCalled() + expect(mockRequestDataExchange).not.toHaveBeenCalled() + // Retryable, not a sign-out: the tokens stay and the user stays signed in. + expect(getText('isAuthenticated')).toBe('true') + }) + it('still mints with this render token when the pre-mint refresh clears the session', async () => { await signInWithStaleToken() - // A revoked refresh trips `clearAuthState`, emptying the token ref. The - // flow must not change story here: it mints with the token this render - // captured and lets the initiator guard discard the grant as + // A revoked refresh trips `clearAuthState`, emptying the token ref — the + // accessor's `signed-out`, which is a different case from `refresh-failed` + // above. The flow must not change story here: it mints with the token this + // render captured and lets the initiator guard discard the grant as // `user-changed`. Bailing to `not-signed-in` instead would be a different // contract than the one the guard and its docs describe. mockRefreshTokens.mockRejectedValueOnce(new TokenEndpointError(401, 'invalid_grant')) @@ -1111,4 +1328,18 @@ describe('AuthProvider — requestPermissions', () => { expect.objectContaining({ accessToken: 'stale-access' }), ) }) + + // Clearing a revoked session ends in a Keychain write. It can reject, and this + // flow is documented to resolve — a consumer following that has no catch. + it('resolves rather than rejecting when clearing a revoked session fails', async () => { + await signInWithStaleToken() + + mockRefreshTokens.mockRejectedValueOnce(new TokenEndpointError(401, 'invalid_grant')) + mockSaveTokens.mockRejectedValueOnce(new Error('keychain unavailable')) + mockRequestDataExchange.mockResolvedValue({ status: 'cancel' }) + + const outcome = await act(async () => requestPermissionsFromContext(['highlights'])) + + expect(outcome).toEqual({ status: 'cancel' }) + }) }) diff --git a/packages/core/src/auth/__tests__/use-yv-auth.test.tsx b/packages/core/src/auth/__tests__/use-yv-auth.test.tsx index 41c8d20a..9bb88aed 100644 --- a/packages/core/src/auth/__tests__/use-yv-auth.test.tsx +++ b/packages/core/src/auth/__tests__/use-yv-auth.test.tsx @@ -20,6 +20,7 @@ describe('useYVAuth', () => { signOut: jest.fn(), refreshNow: jest.fn(), ensureFreshToken: jest.fn(), + getAccessToken: jest.fn(async () => ({ status: 'ok', token: 'a', userId: 'u1' }) as const), isLoading: false, requestedPermissions: ['highlights'], grantedPermissions: null, diff --git a/packages/core/src/auth/auth-context.tsx b/packages/core/src/auth/auth-context.tsx index 72244a69..b12d9053 100644 --- a/packages/core/src/auth/auth-context.tsx +++ b/packages/core/src/auth/auth-context.tsx @@ -2,6 +2,22 @@ import { createContext } from 'react' import type { DataExchangeOutcome } from './data-exchange' import type { AuthPermission, YVUserInfo } from './types' +/** + * What {@link AuthContextValue.getAccessToken} resolves to. `refresh-failed` + * means the token is expired and the refresh did not land (endpoint down, + * network out) — the session itself is still intact, so treat it as transient, + * not as signed out. + * + * `userId` is whose token this is, read in the same synchronous block as the + * token itself. A caller that captured an identity earlier must compare against + * this, not against a `userInfo` it read from a render: the provider writes both + * refs together on sign-in, so anything reading identity through React state + * lags the token by a render and can pass an owner check it should have failed. + */ +export type AccessTokenResult = + | { status: 'ok'; token: string; userId: string | null } + | { status: 'unavailable'; reason: 'signed-out' | 'refresh-failed' } + export type AuthContextValue = { isAuthenticated: boolean accessToken: string | null @@ -30,6 +46,17 @@ export type AuthContextValue = { * a 401 regardless. */ ensureFreshToken: () => Promise + /** + * Resolve a token that is verifiably fresh, or say why one is unavailable. + * Unlike {@link ensureFreshToken}, which only performs the refresh side + * effect, this reports whether it worked — so a caller can tell "refreshed" + * from "still expired" and stop a doomed request before it 401s and gets + * misread as a revoked grant. + * + * Leeway-gated and single-flight like {@link ensureFreshToken}: cheap when + * the token is fresh, joins an in-flight refresh otherwise. Never rejects. + */ + getAccessToken: () => Promise isLoading: boolean /** * What the app **asked for** on its `auth` config — never what was granted. diff --git a/packages/core/src/auth/auth-provider.tsx b/packages/core/src/auth/auth-provider.tsx index 33591ad2..d401d3fa 100644 --- a/packages/core/src/auth/auth-provider.tsx +++ b/packages/core/src/auth/auth-provider.tsx @@ -5,7 +5,7 @@ import { toMessage } from '../error-message' import { clearHighlightsCache } from '../highlights' import { getOrSetInstallationId } from '../installation-id' import { mmkvStorage } from '../storage/mmkv-storage' -import { AuthContext, type AuthContextValue } from './auth-context' +import { AuthContext, type AccessTokenResult, type AuthContextValue } from './auth-context' import { MMKV_AUTH_KEYS, REFRESH_LEEWAY_SECONDS } from './constants' import { requestDataExchange, type AuthIdentity, type DataExchangeOutcome } from './data-exchange' import { createDataExchangeApi } from './data-exchange-api' @@ -171,7 +171,15 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth }) } catch (e) { if (e instanceof TokenEndpointError && e.isRevoked) { - await clearAuthState() + // Clearing is best-effort: it ends in a Keychain write, which can + // reject. Everything downstream of this refresh — ensureFreshToken, + // getAccessToken, requestPermissions — is documented never to + // throw, so a storage failure must not escape as one. + try { + await clearAuthState() + } catch { + // In-memory state is already cleared; only the persisted copy lost. + } } setError(e instanceof Error ? e : new Error(String(e))) } @@ -305,6 +313,41 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth // single-flight by promise, awaiting it does mean the token is usable. const ensureFreshToken = useCallback(() => refreshToken(), [refreshToken]) + // The refresh with its outcome attached. `refreshToken` swallows failure by + // design, so a caller reading the token afterwards cannot tell "refreshed" + // from "still expired" — this re-reads the refs it left behind and says which. + // Non-forced on purpose: the leeway gate already refreshes exactly when the + // token needs it, and joining an in-flight refresh comes free. + const getAccessToken = useCallback(async (): Promise => { + if (refreshTokenRef.current === null) { + return { status: 'unavailable', reason: 'signed-out' } + } + + await refreshToken() + + // Re-read the refs, never closure state: the refresh may have replaced the + // token, or found it revoked and cleared the session via clearAuthState. + // Token and owner in one synchronous block — `setAuthState`/`clearAuthState` + // write both, so a caller checking the owner it captured cannot be handed a + // token from the other side of a sign-in. + const token = accessTokenRef.current + const userId = userInfoRef.current?.id ?? null + if (refreshTokenRef.current === null || token === null) { + return { status: 'unavailable', reason: 'signed-out' } + } + + // Actual expiry, not the leeway window the refresh triggers on: a token + // inside the window is still one the server takes, and refusing it here + // would fail writes that would have succeeded. A corrupt stored expiry is + // NaN, which fails every comparison — hence the finite check. + const expiresAt = expiryRef.current?.getTime() ?? 0 + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + return { status: 'unavailable', reason: 'refresh-failed' } + } + + return { status: 'ok', token, userId } + }, [refreshToken]) + const hasPermission = useCallback( (permission: AuthPermission) => grantedPermissions?.includes(permission) ?? false, [grantedPermissions], @@ -368,24 +411,35 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth // That 401s, and `data-exchange-api.ts` reads every mint 401 as // `not-permitted` — which the docs tell consumers is an app-key setting, // not a user problem. The user would be dead-ended by a stale token and - // told to check the console. Refreshing first keeps the 401 honest. + // told to check the console. // - // This is the no-op path in the common case: `refreshToken` returns - // immediately unless the expiry is inside the leeway window. - await refreshToken() - - // Prefer the ref over the closure: refresh may have just replaced the - // token. Fall back to the closure token when the ref is null, which - // means the session was cleared while this flow was starting — a - // sign-out, or a refresh that found the token revoked. - // - // Falling back rather than bailing keeps the initiator guard's story - // intact: the mint uses the token this render captured, the guard - // re-reads identity after the browser returns, and a session that moved - // mid-flow reports `user-changed`. Bailing here would report - // `not-signed-in` instead, which is a different contract than the one - // documented, and one subtask 3 has not been written against. - const freshAccessToken = accessTokenRef.current ?? accessToken + // The accessor, not a bare `refreshToken()`, because the refresh + // swallows failure: it is the only thing that can tell a refresh that + // worked from one that did not. No-op in the common case — it returns + // the current token unless the expiry is inside the leeway window. + const tokenResult = await getAccessToken() + + // Expired and the refresh did not land (endpoint 5xx, timeout, captive + // portal). Minting anyway earns the 401 that reads as `not-permitted`, + // so stop here and say the one true thing: retry when the network + // recovers. The session is intact, so this is `transient`. + if (tokenResult.status === 'unavailable' && tokenResult.reason === 'refresh-failed') { + return { + status: 'failure', + reason: 'transient', + message: + 'Could not refresh the session token; the permission request was not sent. Retry when the network recovers.', + } + } + + // `signed-out` deliberately does NOT bail: the session was cleared while + // this flow was starting (a sign-out, or a refresh that found the token + // revoked), and the initiator guard already owns that story — the mint + // uses the token this render captured, the guard re-reads identity after + // the browser returns, and reports `user-changed`. Bailing here would + // report `not-signed-in`, a different contract than the documented one. + const freshAccessToken = + tokenResult.status === 'ok' ? tokenResult.token : (accessTokenRef.current ?? accessToken) // Built per call rather than memoized: the installation id is async, and // this runs at most once per user gesture. It reads native state and can @@ -429,7 +483,7 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth inFlightRequestRef.current = { key, promise: pending } return pending }, - [accessToken, apiHost, appKey, config.redirectUri, getCurrentIdentity, refreshToken], + [accessToken, apiHost, appKey, config.redirectUri, getAccessToken, getCurrentIdentity], ) const value: AuthContextValue = useMemo( @@ -442,6 +496,7 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth signOut, refreshNow, ensureFreshToken, + getAccessToken, isLoading, requestedPermissions, grantedPermissions, @@ -457,6 +512,7 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth signOut, refreshNow, ensureFreshToken, + getAccessToken, isLoading, requestedPermissions, grantedPermissions, diff --git a/packages/core/src/auth/data-exchange.ts b/packages/core/src/auth/data-exchange.ts index 9c49e026..48dbf0b7 100644 --- a/packages/core/src/auth/data-exchange.ts +++ b/packages/core/src/auth/data-exchange.ts @@ -42,7 +42,8 @@ import type { AuthPermission } from './types' * - `in-progress` — another request holds the flow. Retry once it settles, not * before: a retry now hits this same branch, because only one consent page can * be open at a time. - * - `transient` — a network blip, a 5xx, a schema failure. Retry immediately. + * - `transient` — a network blip, a 5xx, a schema failure, or an expired token + * the pre-mint refresh could not replace. Retry immediately. */ export type DataExchangeFailureReason = | 'not-signed-in' diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index c97bfcbc..7c0736dd 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -1,3 +1,4 @@ +export type { AccessTokenResult } from './auth-context' export type { DataExchangeFailureReason, DataExchangeOutcome } from './data-exchange' export type { AuthConfig, diff --git a/packages/core/src/highlights/__tests__/use-highlight-permission-flow.test.tsx b/packages/core/src/highlights/__tests__/use-highlight-permission-flow.test.tsx index f1bce306..08ce908a 100644 --- a/packages/core/src/highlights/__tests__/use-highlight-permission-flow.test.tsx +++ b/packages/core/src/highlights/__tests__/use-highlight-permission-flow.test.tsx @@ -84,6 +84,11 @@ function authValue(state: AuthState): AuthContextValue { signOut: jest.fn(), refreshNow: jest.fn(), ensureFreshToken: mockEnsureFreshToken, + getAccessToken: jest.fn(async () => + state.signedIn + ? ({ status: 'ok', token: 'token-1', userId: 'user-1' } as const) + : ({ status: 'unavailable', reason: 'signed-out' } as const), + ), isLoading: false, // An app that reaches this flow has asked for `highlights` — the reader // never mounts the fetch otherwise (`shouldFetchHighlights`). What the user diff --git a/packages/core/src/highlights/__tests__/use-highlights.test.tsx b/packages/core/src/highlights/__tests__/use-highlights.test.tsx index 9570bcdc..40335dee 100644 --- a/packages/core/src/highlights/__tests__/use-highlights.test.tsx +++ b/packages/core/src/highlights/__tests__/use-highlights.test.tsx @@ -3,7 +3,7 @@ import { act, render, renderHook } from '@testing-library/react-native' import { Text } from 'react-native' import type { ReactNode } from 'react' -import { AuthContext, type AuthContextValue } from '../../auth/auth-context' +import { AuthContext, type AccessTokenResult, type AuthContextValue } from '../../auth/auth-context' import { YouVersionContext } from '../../youversion-context' import type { Result } from '../../result' import type { HighlightsApiError } from '../api' @@ -121,6 +121,22 @@ const signedInWithoutPermission: AuthShape = { // it on a deferred promise. const ensureFreshToken = jest.fn(async () => undefined) +/** + * Hoisted for the same reason. The default implementation mirrors the real + * accessor against the *current* auth value, so identity/token transitions via + * `setAuth` flow through; tests override it to exercise `refresh-failed`. + */ +const getAccessToken = jest.fn, []>() + +function defaultGetAccessToken(): Promise { + const token = currentAuth?.accessToken ?? null + return Promise.resolve( + token === null + ? { status: 'unavailable', reason: 'signed-out' } + : { status: 'ok', token, userId: currentAuth?.userInfo?.id ?? null }, + ) +} + function authValue(overrides: Partial): AuthContextValue { return { isAuthenticated: false, @@ -131,6 +147,7 @@ function authValue(overrides: Partial): AuthContextValue { signOut: jest.fn(async () => undefined), refreshNow, ensureFreshToken, + getAccessToken, isLoading: false, // The default for every existing case: these tests exercise the fetch, so // the app must have asked for the permission that mounts it. @@ -218,6 +235,8 @@ beforeEach(() => { mockDeleteHighlight.mockReset() ensureFreshToken.mockReset() ensureFreshToken.mockResolvedValue(undefined) + getAccessToken.mockReset() + getAccessToken.mockImplementation(defaultGetAccessToken) mockGetHighlights.mockResolvedValue(collection([])) mockCreateHighlight.mockResolvedValue({ ok: true, value: highlight('JHN.3.16', YELLOW) }) mockDeleteHighlight.mockResolvedValue({ ok: true, value: undefined }) @@ -555,9 +574,9 @@ describe('apply', () => { expect(readCache()).toEqual([highlight('JHN.3.16', YELLOW), highlight('JHN.3.17', YELLOW)]) }) - it('refreshes the token after the paint and before the POST', async () => { - const refresh = deferred() - ensureFreshToken.mockReturnValueOnce(refresh.promise) + it('resolves the token after the paint and before the POST', async () => { + const tokenGate = deferred() + getAccessToken.mockReturnValueOnce(tokenGate.promise) const { result } = renderUseHighlights() await act(async () => { @@ -569,14 +588,14 @@ describe('apply', () => { outcome = result.current.apply(YELLOW, [16]) }) - // Painted while the refresh is still out. A refresh in front of the claim - // would leave the verse unpainted for a whole token round-trip every time - // one was due, which is the whole reason it lives here (ADR 0016). + // Painted while the accessor is still out. A token round-trip in front of + // the claim would leave the verse unpainted every time a refresh was due, + // which is the whole reason it lives here (ADR 0016). expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) expect(mockCreateHighlight).not.toHaveBeenCalled() await act(async () => { - refresh.resolve(undefined) + tokenGate.resolve({ status: 'ok', token: 'token-1', userId }) await outcome }) @@ -584,7 +603,59 @@ describe('apply', () => { // `auth`, and `useHighlightPermissionFlow` reads `auth` as a stale grant — // so the user would be asked to grant a permission they already granted. expect(mockCreateHighlight).toHaveBeenCalledTimes(1) - expect(ensureFreshToken).toHaveBeenCalledTimes(1) + expect(getAccessToken).toHaveBeenCalledTimes(1) + }) + + // The reason `getAccessToken` exists: an expired token plus a failing token + // endpoint used to send the write out anyway, 401, classify as `auth`, and + // `useHighlightPermissionFlow` would invalidate a perfectly valid grant. + it('fails as transient with no request when the token refresh fails', async () => { + seedServer([highlight('JHN.3.16', GREEN)]) + getAccessToken.mockResolvedValue({ status: 'unavailable', reason: 'refresh-failed' }) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [16]) + }) + + // `transient` — never `auth` (which invalidates the grant) and never + // `not-signed-in` (which prompts sign-in): the session is intact. + expect(outcome).toEqual({ + status: 'error', + reason: 'transient', + message: expect.stringContaining('refresh'), + failedVerses: [16], + succeededVerses: [], + }) + // The doomed request never went out, and the paint reverted to server truth. + expect(mockCreateHighlight).not.toHaveBeenCalled() + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) + }) + + it('maps an accessor signed-out (session cleared mid-write) to not-signed-in', async () => { + seedServer([highlight('JHN.3.16', GREEN)]) + // Signed in per context, but the refresh found the token revoked and + // cleared the session before the write was sent. + getAccessToken.mockResolvedValue({ status: 'unavailable', reason: 'signed-out' }) + + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [16]) + }) + + expect(outcome).toMatchObject({ status: 'error', reason: 'not-signed-in', failedVerses: [16] }) + expect(mockCreateHighlight).not.toHaveBeenCalled() + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) }) it('collapses contiguous verses into one ranged POST per run', async () => { @@ -1175,6 +1246,41 @@ describe('auth states', () => { expect(mockCreateHighlight).not.toHaveBeenCalledWith('token-2', expect.anything()) }) + // The narrow window the previous test cannot reach: the provider writes its + // token and identity refs synchronously on sign-in, while the identity this + // hook compares against is synced from a passive effect a render later. A + // sign-in landing while the write awaits the accessor therefore hands back the + // new user's token under the old user's rendered identity — so the guard has + // to believe the identity that came back WITH the token. + it('abandons a write when the accessor returns a token owned by a different user', async () => { + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + getAccessToken.mockResolvedValueOnce({ + status: 'ok', + token: 'token-2', + userId: 'user-2', + }) + + let outcome: Promise | undefined + await act(async () => { + outcome = result.current.apply(YELLOW, [16]) + await outcome + }) + + expect(await outcome).toMatchObject({ + status: 'error', + reason: 'not-signed-in', + failedVerses: [16], + }) + expect(mockCreateHighlight).not.toHaveBeenCalled() + // The optimistic paint is reverted, not left stranded on the departed user's + // chapter. + expect(colorsOf(result.current)).toEqual({}) + }) + it('abandons a queued remove rather than deleting the new user’s highlights', async () => { seedServer([highlight('JHN.3.16', YELLOW), highlight('JHN.3.17', YELLOW)]) const heldWrite = deferred>() diff --git a/packages/core/src/highlights/use-highlights.ts b/packages/core/src/highlights/use-highlights.ts index 53d82508..cb42ff80 100644 --- a/packages/core/src/highlights/use-highlights.ts +++ b/packages/core/src/highlights/use-highlights.ts @@ -1,7 +1,7 @@ import type { Highlight } from '@youversion/platform-core' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { AuthPermission } from '../auth' +import type { AccessTokenResult, AuthPermission } from '../auth' import { useYVAuthOptional } from '../auth' import { useYouVersion } from '../use-youversion' import { createHighlightsApi, type HighlightsApi, type HighlightsApiError } from './api' @@ -83,6 +83,9 @@ export type UseHighlightsResult = { const INVALID_COLOR_MESSAGE = 'Unsupported highlight color. Use one of the five YouVersion highlight swatches.' +const TOKEN_REFRESH_FAILED_MESSAGE = + 'Could not refresh the session token. Retry when the network recovers.' + /** * `auth` wins (it changes what the user must do); retrying `invalid` is * pointless. `not-signed-in` is ranked but unreachable here — it is never @@ -185,7 +188,7 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul // through the closure rather than a ref so a change still re-runs the fetch // effect below (`runFetch` is one of its deps). const canFetchHighlights = shouldFetchHighlights(auth?.requestedPermissions ?? []) - const ensureFreshToken = auth?.ensureFreshToken ?? null + const getAccessToken = auth?.getAccessToken ?? null const scope = useMemo( () => ({ versionId: options.versionId, book: options.book, chapter: options.chapter }), @@ -227,7 +230,7 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul // over one render's values. const identityRef = useRef({ key: currentIdentityKey, scope, userId }) const stateRef = useRef(renderedState) - const authRef = useRef({ accessToken, isAuthLoading, ensureFreshToken }) + const authRef = useRef({ accessToken, isAuthLoading, getAccessToken }) // ── The token-loading hold ───────────────────────────────────────────────── // `userInfo` is seeded synchronously but `accessToken` only arrives after @@ -247,7 +250,7 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul useEffect(() => { identityRef.current = { key: currentIdentityKey, scope, userId } stateRef.current = renderedState - authRef.current = { accessToken, isAuthLoading, ensureFreshToken } + authRef.current = { accessToken, isAuthLoading, getAccessToken } if (accessToken !== null && userId === null) { warnMissingUserId() @@ -375,24 +378,20 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul await waitForAuthSettled() - // The token has to be current when the request goes out — not when the - // user tapped. An expired token 401s, a 401 classifies as `auth`, and - // `useHighlightPermissionFlow` reads `auth` as a stale grant, so without - // this a user would be asked to grant a permission they already granted - // (ADR 0016). - // - // It belongs here, in the send path, rather than in front of the tap: a - // refresh that is actually due costs a token round-trip, and the - // optimistic claim in `startWrite` has already painted by the time this - // runs. Nothing the user can see waits on it. `refreshToken` handles its - // own failures and never rejects, so a dead network leaves the old token - // in place and the write below reports through the normal outcome. - await authRef.current.ensureFreshToken?.() + // Fresh token resolved in the send path, not at tap time — `startWrite` + // has already painted. A failed refresh must stop the write here: an + // expired token 401s, classifies as `auth`, and has the permission flow + // drop a valid grant (ADR 0016). No accessor means no auth is configured, + // which this hook treats exactly as signed out. + const getToken = authRef.current.getAccessToken + const tokenResult: AccessTokenResult = getToken + ? await getToken() + : { status: 'unavailable', reason: 'signed-out' } // The write chain outlives an identity change: `enqueue` serializes behind // whatever is in flight, and there is no AbortController, so one hung // request can hold a queued batch across a sign-out and a sign-in as - // somebody else. Below we read the CURRENT token rather than one captured + // somebody else. Below we use the CURRENT token rather than one captured // at claim time — deliberately, so a mid-write refresh does not fail the // write — which without this guard would issue the departed user's // passage under the new user's token, creating or deleting highlights on @@ -401,18 +400,40 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul // Compare user ids, not `captured.key`: the key also encodes scope, and a // write issued for JHN.3 that settles after the reader moved on to JHN.4 // is still a legitimate write for JHN.3. - const isSameUser = identityRef.current.userId === captured.userId - const accessTokenNow = authRef.current.accessToken - - if (!isSameUser || accessTokenNow === null) { - // Auth settled with no token, or with a different user: from the caller - // that issued this batch, both are "you are not signed in". Reverting - // the paint is a no-op in the user-switch case — the identity change - // already reset state during render — but stays correct if that reset - // ever stops covering it. + // + // The token's own `userId` is the authority, not `identityRef`: the + // provider writes token and identity together, while `identityRef` is + // synced from a passive effect a render later. A sign-in as somebody else + // landing while this awaited above moves the token first, so an + // identityRef-only check would pass and send under the new user's + // credentials. The lagging check stays because the branch below reuses + // `isSameUser` to tell a user switch from a plain failed refresh, and + // only that one reports `transient`. + const isSameUser = + identityRef.current.userId === captured.userId && + (tokenResult.status !== 'ok' || tokenResult.userId === captured.userId) + + if (!isSameUser || tokenResult.status === 'unavailable') { + // Revert the paint either way — a no-op in the user-switch case, where + // the render-time identity reset already covered it. setState((prev) => settle(prev, { token, op, color, succeededVerses: [], failedVerses: verses }), ) + // The session is intact and no request went out, so `transient` — never + // `auth` (drops the grant) or `not-signed-in` (prompts sign-in). + if ( + isSameUser && + tokenResult.status === 'unavailable' && + tokenResult.reason === 'refresh-failed' + ) { + return { + status: 'error', + reason: 'transient', + message: TOKEN_REFRESH_FAILED_MESSAGE, + failedVerses: verses, + succeededVerses: [], + } + } return { status: 'error', reason: 'not-signed-in', @@ -422,6 +443,8 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul } } + const accessTokenNow = tokenResult.token + const succeededVerses: number[] = [] const failedVerses: number[] = [] const errors: HighlightsApiError[] = [] diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 12110afc..47f41c80 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ export { default as YouVersionProvider } from './youversion-provider' export { useYVAuth, useYVAuthOptional } from './auth' export type { + AccessTokenResult, AuthConfig, AuthPermission, AuthScope, diff --git a/packages/ui/src/native/__tests__/bible-reader-highlights-prompts.test.tsx b/packages/ui/src/native/__tests__/bible-reader-highlights-prompts.test.tsx index 1edd5298..b095d07a 100644 --- a/packages/ui/src/native/__tests__/bible-reader-highlights-prompts.test.tsx +++ b/packages/ui/src/native/__tests__/bible-reader-highlights-prompts.test.tsx @@ -110,6 +110,11 @@ function stubAuth(isAuthenticated: boolean) { signOut: jest.fn(async () => undefined), refreshNow: jest.fn(async () => undefined), ensureFreshToken: jest.fn(async () => undefined), + getAccessToken: jest.fn(async () => + isAuthenticated + ? ({ status: 'ok', token: 'test-token', userId: null } as const) + : ({ status: 'unavailable', reason: 'signed-out' } as const), + ), isLoading: false, requestedPermissions: ['highlights'], grantedPermissions: null, From 4cc1d51b37c49139418608b9bbd69d877c40e143 Mon Sep 17 00:00:00 2001 From: Brenden Manquen Date: Tue, 11 Aug 2026 14:48:04 -0500 Subject: [PATCH 16/43] feat(core): highlight writes park offline and reconcile on reconnect (YPE-3717) (#125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): highlight writes park offline and survive relaunch - A write that cannot reach the server keeps its paint, persists per user and chapter, and is still there after a force-quit and relaunch - `HighlightWriteOutcome` gains `{ status: 'queued'; verses }`; only a server refusal (401/403 or any other 4xx) still reverts, so the `reason: 'auth'` branch `useHighlightPermissionFlow` depends on is untouched - Cached Highlights are now the paint, unsent writes included; queue is written first, cache second, and the mount re-applies the queue to repair a crash between the two - Highlight Overlay and its ownership tokens (ADR 0013) deleted — a settling write finds its entries by value, which holds across a relaunch where an in-memory token could not - Queue entry is `{ local, server }`; `server` is captured once per verse and survives later writes, so a rejection reverts exactly even offline. An entry whose two sides agree is dropped - A verse the queue no longer wants in that color is not sent. Checked at send time, not tap time, since a write is already on the chain by the time a later tap can cancel or supersede it. A verse holds one color at a time, so a replaced color is not a state the server needs to see - Reconciliation stays in memory; persisting it would make the drain re-send a write the server already has - Nothing drains the queue yet. The subscribable store and the sign-out purge land with it rather than ahead of it, unused - ADR 0017 records the decisions; ADR 0013 updated where this reverses it * feat(core): queued highlight writes land when service returns - A highlight parked while offline reaches the account on its own, instead of waiting for the next successful write to the same verse. - The drain is provider-owned, not hook-owned: a parked write outlives the chapter that made it, and after a relaunch the queue is the only record of its scope. - Wakes on mount, token change, foreground, the rising edge of expo-network connectivity, and the write path's own signals; otherwise a per-verse widening, capped backoff. - A wake-up retires the pending wait, so a backed-off write goes out the moment service returns. Failure counts survive, so the decay widens rather than restarting on every foreground. - Connectivity is a trigger, never a gate. The timer covers what an edge cannot: a server-side failure leaves the device connected, so no edge fires. - The drain defers to verses a mounted useHighlights is sending; the hook never waits on the drain. - A write lands cache-first, then drops the entry — the cache is the paint, so a crash between the two must leave the write owed rather than the paint gone. - expo-network is a new required peer dependency of core. Autolinked, so upgrading means installing it and rebuilding the dev client. * feat(core): signing out takes the queued highlight writes with it A highlight parked while offline belongs to the user who made it. Until now nothing removed those entries when that user left the device, so the drain could carry them into the next session and land another person's highlights on the account signing in after them. - `clearHighlightQueue()` joins the highlights cache and the granted- permission cache in `clearAuthState`, the one routine both `signOut` and a revoked refresh token run. - It drops every user's entries, not only the departing one's: one user is signed in at a time, so an entry under any other id was already left by an earlier departure and has no session that could send it. - Entries stay per-user keyed while signed in, and the drain already re-reads auth per scope — a user change part-way through a pass now has a regression test pinning that the remaining scopes are abandoned rather than sent under the new token. - Tests attach at the provider seam (real provider, real sign-out, real queue and drain; only MMKV, the highlights API and the auth edges faked) and assert what reached the API and what a mounted reader paints. * feat(ui): the reader asks before it signs you out - `BibleReader` intercepts the Web SDK user menu's `onSignOutPress` and raises a native `Alert` instead of calling `signOut()`, matching Swift - Two variants, chosen by whether the write queue still holds unsent work: a plain confirmation, or an escalated "Save your highlights?" naming what sign-out would purge - An `Alert`, not a `NativeSheet`: it matches Swift's `.alert`, it needs `style: 'destructive'`, and there is nothing to lay out - Web bypasses it — `react-native-web`'s `Alert.alert` is a silent no-op, which would leave the menu item doing nothing forever - Reader-scoped by design; `YouVersionAuthButton` and `useYVAuth().signOut()` still sign out immediately - Core exports `hasQueuedHighlightWrites(userId)`, the question the reader asks to choose a variant. A plain MMKV prefix scan that never throws: an unreadable store answers "nothing to lose" rather than breaking the gesture that raises the prompt - Strings authored upstream in platform-localization under `reactnative.*` and synced down by its generator * feat(core): a highlight the server keeps refusing stops being shown - A parked write refused with 401/403 earns an unconditional `refreshNow` and one more attempt; the ordinary expired-token case lands. - Only a second auth refusal, under a genuinely minted token, drops the entry and reverts the verse to the entry's `server` side. - The un-paint reaches a mounted reader via a new drop notification on the queue (`onWritesDropped` / `dropRejectedWrites`) — a drop notification, not a change feed, since every other queue change is already visible to whoever caused it. - Nothing else drops: 5xx, non-auth 4xx and unreachable keep retrying on their backoff, and a refresh that is absent, throws, or ends the session drops nothing. * test(core): offline without the grant leaves nothing behind - Pin that a signed-in user without the `highlights` grant, tapping a color offline, gets no paint and no queued write: the data-exchange mint fails before any browser opens, so the pending highlight is discarded rather than persisted - Pin the contrast — the same user WITH the cached grant sails past the flow into an ordinary write that parks at the network - That second block fails if a pre-flight ever starts confirming the cached grant with the server (ADR 0014), checked by mutating the branch - No production change; the tests exist because the rejected alternative (queue it optimistically, un-paint at the drain) is only written down in ADR 0017 * docs(core): release notes and agent instructions for the write queue - rename the auto-named changeset and rewrite it as the feature's headline entry: the offline win in one line, and the widened `HighlightWriteOutcome` called out prominently (only an exhaustive `switch` with no default breaks) - drop never-shipped history from the sibling changesets — the drain and the drop path ship in the same release as the queue, so there is no "previously" for a consumer to have seen - scope "no API changes" claims to each entry; changesets concatenate in arbitrary order, so cross-entry references cannot assume position - AGENTS.md: writes now report a point-in-time outcome, not a final one — unreachable and 5xx park, only a refusal reverts - AGENTS.md: name `auth` alone as the flow's refusal branch point; `not-signed-in` is raised locally before any request, and the sign-in prompt comes from the flow's pre-flight - AGENTS.md: exported types list carries the widened outcome * fix(core): an unreadable store cannot keep a user signed in - `clearAuthState` purges three caches before it clears the tokens, and two of those purges could throw straight out of it — a failing MMKV left the user signed in after sign-out, or after a revoked refresh token - `clearHighlightQueue` and `clearHighlightsCache` now swallow their own storage failures, matching `clearGrantedPermissions` and every other never-throws reader in those modules; the guard belongs with the storage code so every caller gets it, not just sign-out - the raw `cachedUserInfo` removal is guarded inline — it is the first statement in `clearAuthState` and has no helper to own the rule - a surviving cache entry belongs to a user who has left, and the drain re-reads auth per scope before sending, so the cost of a failed purge is stale bytes rather than a wrong write - pin the consequence rather than the mechanism: with the store throwing, sign-out still clears the tokens and the in-memory state - note the best-effort purge in the sign-out changeset; it is the routine that entry already describes * docs(core): a queued outcome repeats on a verse already parked - State it in the `queued` doc comment, AGENTS.md, the changeset, and ADR 0017: the outcome is point-in-time, so every tap on a still-parked verse resolves `queued` again - No first-park-vs-repeat field, on two grounds: a batch can mix a parked verse with fresh ones, so an honest signal would be a per-verse split rather than a flag; and a verse parked yellow then tapped green is a new write, which "repeat" would describe wrongly - Deduping a "saved offline" message stays the caller's own state Documentation only; no behavior change. * refactor(core): the queue's scans and its key builder share one prefix - `highlightQueueUserPrefix` in constants.ts; `highlightQueueKey` and both per-user scans in queue.ts build from it - the drift it forecloses is silent: a scan that stops matching leaves the drain with no scope to send and sign-out reporting nothing to lose * refactor(core): the drain's refusal path is named for what it does - the drain's local `drop` becomes `revert`: it was the only one of three drop-ish verbs whose point is not removing an entry — it takes the paint back to the entry's `server` side, and removal is incidental - `land` stays. it is the documented outcome vocabulary (`ok` means "landed server-side" in CONTEXT.md, ADR 0017, AGENTS.md), and the alternatives are each occupied: `settle` and `retire` span both branches, `confirm` is already optimistic.ts's in-memory analog and the flow's consent tap - `revert` was the word the codebase was already reaching for — use-highlights has a local `revert` doing the same job at tap time, and queue.ts documents `server` as what a rejected write is reverted to - docstrings cross-link the pair so which verb un-paints is legible at the definition site rather than inferred from the call * docs(core): the sign-out purge names the rule it already followed - ADR 0017 described `clearAuthState`'s queue purge without saying it is best-effort — the never-throws rule lives in ADR 0014 and reads as if it were about the grant cache alone - point at that rule from the sign-out paragraph, give the ordering that makes it matter (purges run ahead of the token clearing), and state the constraint for whatever is added to the routine next - documents what 898ae87 shipped; no behavior change * docs(core): the auth end of the paint coupling says what it owes - `useHighlights` documents that its synchronous cache read depends on `userInfo` being seeded in AuthProvider's own initializer; from this end the line looked like an ordinary cache read - the only mention here was the `grantedPermissions` comment pointing up at it in passing, which explains that seeding, not this one - name the dependent and the cost of moving it, so a future change to seed in an effect meets the reason at the line it would break * fix(core): a store that refuses writes cannot reject sign-out - `saveTokens` wrote the cached expiry unguarded, so a refusing MMKV threw from the last line of `clearAuthState` — after the session and the stored tokens were already gone. The caller saw a rejected `signOut()` for a sign-out that had completed - the expiry is a cache over the tokens, which are the record; `writeExpiry` swallows its own failure like the other purges. A lost expiry costs one refresh, since `refreshToken` reads a missing one as already stale - the sibling residual — a surviving `cachedUserInfo` reseeding the departed identity, and with it the highlights paint — is recorded rather than patched. ADR 0014's amendment gives the actual bound (the bootstrap clear, which runs off SecureStore) and why no mitigation exists: every candidate is another write into the store that just refused one - tests pin both: `saveTokens` resolves through a refused set and a refused remove; the mount leaves state clear while the record survives * docs(core): the grant purge says why its catch cannot detect - name the read-only case where MMKV.remove returns false and the entry survives with nothing caught, so the catch is not read as a detector - record that no mitigation can sit on top: an overwrite, a tombstone, or a retry is another write into the store that just refused one - date the ADR 0014 amendment so the right section is reachable from here * fix(core): a failed refresh takes the paint back rather than parking it - restore `revert(verses)` in `runWrite`'s unavailable-token branch: the rebase onto `getAccessToken` kept main's `settle(…{ token })` line, which ADR 0017 had already replaced with a value comparison against the write queue, so `settle` and `token` no longer exist and core did not compile - add `getAccessToken` to the three auth mocks that arrived on this branch and so were not covered by that commit's own mock updates - pin that a failed refresh reverts and parks nothing, across apply, remove, and the next launch — unlike an unreachable server, nothing was sent and the drain is owed nothing, so a stale entry would resurrect a highlight the user watched disappear * docs(changeset): one release note for native highlights - Replace 21 pending changesets with a single minor/minor entry; the resulting bump is unchanged (both packages 1.1.1 -> 1.2.0) - Organize by theme rather than development order: action-required peers, highlights, offline queue, permission flow, permissions, tokens, reader, fixes, dependencies - Drop intermediate state that never shipped and would read as a contradiction: the "not exported yet" caveats on the highlights cache and API wrapper, the HighlightWriteOutcome migration warning (the union is new this release), the paint-before-refresh fix, and the 2.4.0 web SDK hop * docs: renumber the highlight write queue ADR to 0018 The queue ADR reused 0017, which the native verse action sheet ADR already holds. Rename the file and update every reference that means the queue ADR; verse action sheet references stay 0017. The 0018 mentions inside backoff.ts and use-highlights.ts land with the write-path fix commit, which also edits those files. Co-Authored-By: Claude Fable 5 * fix(core): close three highlight write-path gaps from PR review - A hook that unmounts during the token-loading hold now resolves the write as queued instead of stranding it: the held promise settles 'aborted', the entry stays parked for the drain, and the claim frees. The unmount flag resets on mount so StrictMode's mount/cleanup/mount cycle cannot latch it. - An MMKV enqueue that throws resolves a transient error before any paint or claim, and a last-resort catch after the claim release keeps apply/remove from ever rejecting. - A write that settles after a scope change now repairs the MMKV cache of the scope it was made in - success lands the color, refusal reverts to the entry's server side - before the queue entry drops, so a crash between the two re-refuses rather than stranding paint. Also aligns the backoff doc with noteFailure's pre-increment argument (doc-only; the first failure keeps waiting the base delay). Co-Authored-By: Claude Fable 5 * docs(ui): state why handleSignOutPress stays async The DOM wrapper types onSignOutPress as () => Promise, so a plain () => void handler fails typecheck. Keep async and document the constraint at the handler. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Cameron Pak Co-authored-by: Claude Fable 5 --- .changeset/bump-web-sdk-2-4-0.md | 14 - .changeset/bump-web-sdk-2-5-0.md | 15 - .changeset/core-data-exchange-grant.md | 5 - .changeset/core-get-access-token.md | 15 - .changeset/core-granted-permissions.md | 24 - .changeset/core-highlight-permission-flow.md | 13 - .changeset/core-highlights-cache.md | 5 - .changeset/core-highlights-client-wrapper.md | 5 - .changeset/core-refresh-single-flight.md | 7 - .changeset/core-use-highlights.md | 13 - .changeset/highlight-paint-before-refresh.md | 7 - .changeset/native-highlights-release.md | 113 +++ .changeset/native-verse-action-sheet.md | 28 - .../reader-renders-native-owned-highlights.md | 19 - .../sync-localization-reactnative-ace9bbd.md | 5 - ...verse-action-swatch-tray-android-scroll.md | 11 - .gitignore | 3 + AGENTS.md | 21 +- CONTEXT.md | 42 +- README.md | 2 +- apps/example/package.json | 1 + ...0013-native-highlights-optimistic-layer.md | 7 + docs/adr/0014-cached-grant-is-a-hint.md | 14 + docs/adr/0018-highlight-write-queue.md | 104 +++ packages/core/package.json | 1 + .../__tests__/youversion-provider.test.tsx | 22 + .../src/auth/__tests__/auth-provider.test.tsx | 130 ++- .../src/auth/__tests__/token-storage.test.ts | 24 + packages/core/src/auth/auth-provider.tsx | 17 +- .../src/auth/granted-permissions-cache.ts | 7 + packages/core/src/auth/token-storage.ts | 20 +- .../src/highlights/__tests__/backoff.test.ts | 31 + .../src/highlights/__tests__/exports.test.ts | 1 + .../has-queued-highlight-writes.test.ts | 99 ++ .../highlight-queue-drain-host.test.tsx | 179 ++++ .../__tests__/highlight-queue-drain.test.ts | 857 ++++++++++++++++++ .../highlight-queue-identity.test.tsx | 230 +++++ .../__tests__/highlight-write-queue.test.tsx | 704 ++++++++++++++ .../offline-permission-flow.test.tsx | 402 ++++++++ .../highlights/__tests__/optimistic.test.ts | 368 +++----- .../__tests__/use-highlights.test.tsx | 57 +- packages/core/src/highlights/backoff.ts | 21 + packages/core/src/highlights/cache.ts | 45 +- packages/core/src/highlights/claims.ts | 51 ++ packages/core/src/highlights/constants.ts | 31 + packages/core/src/highlights/drain-signals.ts | 31 + packages/core/src/highlights/drain.ts | 351 +++++++ .../highlights/highlight-queue-drain-host.tsx | 94 ++ packages/core/src/highlights/index.ts | 5 + packages/core/src/highlights/optimistic.ts | 285 +++--- packages/core/src/highlights/queue.ts | 270 ++++++ .../core/src/highlights/use-highlights.ts | 412 +++++++-- packages/core/src/index.ts | 1 + packages/core/src/youversion-provider.tsx | 2 + .../__tests__/bible-reader-sign-out.test.tsx | 226 +++++ packages/ui/src/native/bible-reader.tsx | 31 +- pnpm-lock.yaml | 22 + 57 files changed, 4806 insertions(+), 714 deletions(-) delete mode 100644 .changeset/bump-web-sdk-2-4-0.md delete mode 100644 .changeset/bump-web-sdk-2-5-0.md delete mode 100644 .changeset/core-data-exchange-grant.md delete mode 100644 .changeset/core-get-access-token.md delete mode 100644 .changeset/core-granted-permissions.md delete mode 100644 .changeset/core-highlight-permission-flow.md delete mode 100644 .changeset/core-highlights-cache.md delete mode 100644 .changeset/core-highlights-client-wrapper.md delete mode 100644 .changeset/core-refresh-single-flight.md delete mode 100644 .changeset/core-use-highlights.md delete mode 100644 .changeset/highlight-paint-before-refresh.md create mode 100644 .changeset/native-highlights-release.md delete mode 100644 .changeset/native-verse-action-sheet.md delete mode 100644 .changeset/reader-renders-native-owned-highlights.md delete mode 100644 .changeset/sync-localization-reactnative-ace9bbd.md delete mode 100644 .changeset/verse-action-swatch-tray-android-scroll.md create mode 100644 docs/adr/0018-highlight-write-queue.md create mode 100644 packages/core/src/highlights/__tests__/backoff.test.ts create mode 100644 packages/core/src/highlights/__tests__/has-queued-highlight-writes.test.ts create mode 100644 packages/core/src/highlights/__tests__/highlight-queue-drain-host.test.tsx create mode 100644 packages/core/src/highlights/__tests__/highlight-queue-drain.test.ts create mode 100644 packages/core/src/highlights/__tests__/highlight-queue-identity.test.tsx create mode 100644 packages/core/src/highlights/__tests__/highlight-write-queue.test.tsx create mode 100644 packages/core/src/highlights/__tests__/offline-permission-flow.test.tsx create mode 100644 packages/core/src/highlights/backoff.ts create mode 100644 packages/core/src/highlights/claims.ts create mode 100644 packages/core/src/highlights/drain-signals.ts create mode 100644 packages/core/src/highlights/drain.ts create mode 100644 packages/core/src/highlights/highlight-queue-drain-host.tsx create mode 100644 packages/core/src/highlights/queue.ts create mode 100644 packages/ui/src/native/__tests__/bible-reader-sign-out.test.tsx diff --git a/.changeset/bump-web-sdk-2-4-0.md b/.changeset/bump-web-sdk-2-4-0.md deleted file mode 100644 index 0f6a0091..00000000 --- a/.changeset/bump-web-sdk-2-4-0.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@youversion/platform-react-native-expo-core': patch -'@youversion/platform-react-native-expo-ui': patch ---- - -Update the Web SDK dependencies to 2.4.0 — `@youversion/platform-core` (core, from 2.3.0) and `@youversion/platform-react-ui` (UI, from 2.2.0), which brings `@youversion/platform-core` and `@youversion/platform-react-hooks` 2.4.0 with it, so a single copy of each resolves across the workspace. - -What this pulls in that matters here: - -- `BibleReader`'s controlled highlights mode (`highlights?: Highlight[]`, `onVerseSelect`, `onHighlightApply`, `onHighlightRemove`) — the contract the native highlight bridge is built against. -- A core `ApiClient` fix: an empty-body 2xx (what a successful highlight DELETE returns) is now read as success rather than a failure. -- The data-exchange primitives (`DataExchangeClient`, `buildDataExchangeUrl`, `parseDataExchangeCallback`, `parseGrantedPermissions`) used by the just-in-time `highlights` permission grant. - -No public API changes in either package. diff --git a/.changeset/bump-web-sdk-2-5-0.md b/.changeset/bump-web-sdk-2-5-0.md deleted file mode 100644 index a965c466..00000000 --- a/.changeset/bump-web-sdk-2-5-0.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@youversion/platform-react-native-expo-core': patch -'@youversion/platform-react-native-expo-ui': minor ---- - -Update the Web SDK dependencies to 2.5.0 — `@youversion/platform-core` (core, from 2.4.0) and `@youversion/platform-react-ui` (UI, from 2.4.0), which brings `@youversion/platform-core` and `@youversion/platform-react-hooks` 2.5.0 with it, so a single copy of each resolves across the workspace. - -**The Bible reader's default serif font changes from Source Serif 4 to Untitled Serif**, YouVersion's brand serif (YPE-1350, YPE-1910). The Web SDK now loads it from the YouVersion Fonts API, so a DOM component's WebView makes **a new outbound request** to `api.youversion.com` for the stylesheet plus woff2 fetches from `cdn.youversion.com`. There is no opt-out and no new prop. If those hosts are blocked, serif text falls back to Source Serif 4 with no layout break. - -Two native-side changes were required to keep the reader working across that swap: - -- `reader-fonts` now mirrors the new `UNTITLED_SERIF_FONT` stack (`'"Untitled Serif", "Source Serif 4", serif'`) and carries it over the native/DOM bridge as an `untitled-serif` token. Without this, selecting the serif font in reader settings would have sent the raw quoted stack across the bridge, which corrupts `@expo/dom-webview`'s prop injection on iOS and renders the reader blank (see `docs/adr/0009-bridge-safe-font-tokens.md`). `SOURCE_SERIF_FONT` is retained, deprecated, so values persisted by earlier versions still encode to a known token. -- The reader settings store defaults to Untitled Serif and migrates a persisted Source Serif value on read. The Web SDK performs this migration itself only when `fontFamily` is uncontrolled; we always pass it controlled, so the reader would otherwise have kept the deprecated stack and matched neither font button in the picker. - -Readers who had explicitly chosen Source Serif are migrated to Untitled Serif, matching the Web SDK. Any other `fontFamily` you pass or persist is left untouched. diff --git a/.changeset/core-data-exchange-grant.md b/.changeset/core-data-exchange-grant.md deleted file mode 100644 index aeb8e461..00000000 --- a/.changeset/core-data-exchange-grant.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@youversion/platform-react-native-expo-core': patch ---- - -A signed-in user can now grant a YouVersion Platform permission on the spot, instead of signing out and back in to be asked again. `useYVAuth()` gains `requestPermissions(permissions)`, which mints a data-exchange token, runs YouVersion's hosted consent page in an auth session, and merges what the user granted into the cached grant — so `hasPermission` answers true on the next render. It resolves to a typed `DataExchangeOutcome` rather than throwing: `granted` (with the permissions the server actually reported, which may be fewer than were asked for), `cancel`, or `failure` carrying a `reason` of `not-signed-in`, `not-permitted` (this app key is not enabled for data exchange — a 401 from the mint, deliberately distinct from a flaky network), `user-changed`, `in-progress` (another request already holds the flow — wait for it to settle rather than retrying straight away), or `transient`. The access token is refreshed before minting, so an expired token cannot masquerade as a misconfigured app key. 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 any 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. The consent page returns to your `redirectUri` — the same callback URL sign-in uses, because an app key has exactly one — so data exchange needs no setup beyond what sign-in already required. If the two disagree the return never reaches the SDK and the outcome is `cancel`, indistinguishable from a decline. diff --git a/.changeset/core-get-access-token.md b/.changeset/core-get-access-token.md deleted file mode 100644 index fa1db681..00000000 --- a/.changeset/core-get-access-token.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@youversion/platform-react-native-expo-core': patch ---- - -Fix a token endpoint outage presenting to the user as a revoked permission. When the access token was expired and the refresh failed for a reason that was not a revocation — a 5xx, a timeout, a captive portal — the highlight write went out with the expired token anyway. It came back 401, the 401 classified as `auth`, and `useHighlightPermissionFlow` reads `auth` as a stale grant, so a valid `highlights` grant was invalidated and the user was asked to consent again. The re-consent then minted with the same expired token, 401'd in turn, and dead-ended as `not-permitted` — which the docs describe as an app-key setting, not anything the user can act on. - -The cause is that `refreshToken` swallows failure by design, to keep the retention policy: a transient failure must not sign anyone out. It returns nothing, so a caller reading the token afterwards could not tell a refresh that worked from one that did not. - -Add `getAccessToken()` to the auth context, exported through `useYVAuth()` and typed as `AccessTokenResult`. It runs the same leeway-gated, single-flight refresh as `ensureFreshToken()`, then reports the outcome: `{ status: 'ok', token, userId }`, or `{ status: 'unavailable', reason: 'signed-out' | 'refresh-failed' }`. 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 a sign-in, and the highlights write path compares against the accessor's value for exactly that reason. It never rejects, it makes no network call when there is no refresh token to spend, and concurrent callers join one refresh and all receive the new token. `refresh-failed` leaves the tokens in storage — the session is intact and the user stays signed in, which is the existing policy and not something to change. - -`requestPermissions` sources its pre-mint token from it too, closing the same hole one step further along: a signed-in user who does not yet hold the `highlights` grant reaches the consent flow directly, with no write in front of it, so an expired token and a failing token endpoint sent the mint out anyway and dead-ended on `not-permitted`. A `refresh-failed` now resolves `{ status: 'failure', reason: 'transient' }` **without minting**. `signed-out` is unchanged on purpose — the session was cleared mid-flow, and the initiator guard already owns that case by minting with the token this render captured and reporting `user-changed`. - -Also make `refreshToken` total. Its revocation branch awaits `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 in this file. - -`useHighlights` sources its write token from it. A `refresh-failed` now settles as a `transient` outcome **without issuing the request**, so the false `auth` can no longer be manufactured; `signed-out` maps to `not-signed-in` as before. Nothing else moves: `isAuthenticated` still means "has a session", so an offline user with a retained session still renders as signed in, and `ensureFreshToken()` stays for callers that only want the refresh side effect. diff --git a/.changeset/core-granted-permissions.md b/.changeset/core-granted-permissions.md deleted file mode 100644 index 7de51b75..00000000 --- a/.changeset/core-granted-permissions.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@youversion/platform-react-native-expo-core': minor ---- - -The auth context now reports which permissions the user granted. `useYVAuth()` adds three members: - -- `grantedPermissions` lists the permissions the user granted. -- `hasPermission()` reports whether one permission is in that list. -- `invalidatePermissions()` clears the cached grant. - -`grantedPermissions` has three states: - -- `null` means the app never requested permissions. -- `[]` means the app requested permissions, and the user denied them. -- A populated list means the user granted those permissions. - -The SDK handles the grant as follows: - -- It reads the grant from the OAuth app redirect. -- It caches the grant per user in MMKV. -- It loads the cached grant on cold start. -- It clears the grant on sign-out. - -`AuthPermission` is now an open union (`KnownAuthPermission | (string & {})`). As a result, `AuthConfig.permissions` and `hasPermission()` accept a permission string that this SDK version does not know about. `grantedPermissions` is typed `readonly string[] | null`, so it keeps every value the server returns. diff --git a/.changeset/core-highlight-permission-flow.md b/.changeset/core-highlight-permission-flow.md deleted file mode 100644 index edab8cd6..00000000 --- a/.changeset/core-highlight-permission-flow.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@youversion/platform-react-native-expo-core': minor ---- - -A user who taps a highlight color before they are signed in, or before they have granted the `highlights` permission, now gets their highlight instead of losing it. Add `useHighlightPermissionFlow({ versionId, book, chapter })`, which composes `useHighlights` with the auth context and guards `apply` behind the missing step: it holds the pending highlight in memory, runs sign-in and/or the just-in-time consent grant, and applies the highlight on the way back. `remove` is unwrapped and passes straight through — a user with visible highlights already has the grant. - -The hook returns the underlying `useHighlights` result untouched (render `highlights` from it as before), plus `isConfirming` to drive a consent prompt, `confirm()` / `decline()` to answer it, and `flowError` for the one thing worth a toast. `apply` resolves with the write's own `HighlightWriteOutcome` when a write was issued, `noop` when the user abandoned the flow, and an `error` when the flow itself failed — so a cancel or a decline never reads as something going wrong. - -The branch point is a **pre-flight permission read, not a write's 401/403**: branching on the failure reason would burn a failed round-trip before every first highlight. A write refused with `reason: 'auth'` anyway means the cached grant was stale, so the grant is invalidated and the user is re-prompted — **exactly once**, never in a loop. Every dismissal path discards the pending highlight cleanly, and a grant that comes back without `highlights` does not write. The pending highlight carries the passage it was tapped in, so nothing resumed after the reader changes chapters can paint verses onto text the user never selected — not a browser round-trip landing late, and not a write refused while they were still on the previous chapter. - -Also adds `ensureFreshToken()` to the auth context: the leeway-gated refresh, made public and awaited before the permission read. Without it an expired token 401s, the 401 reads as `auth`, and `auth` reads as "stale grant" — so an expired token would present to the user as a request to grant a permission they already granted. It is cheap enough to await on every user gesture, unlike `refreshNow()`, which always hits the token endpoint. - -Requires `auth` on `YouVersionProvider` and the `highlights` permission (a permission, never a scope). With no `auth` configured the flow behaves exactly as signed out, and says so once in development. The localized consent sheet ships separately, once its strings land in the SDK's generated locale files. diff --git a/.changeset/core-highlights-cache.md b/.changeset/core-highlights-cache.md deleted file mode 100644 index 320ad1ba..00000000 --- a/.changeset/core-highlights-cache.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@youversion/platform-react-native-expo-core': patch ---- - -Core now includes an internal MMKV highlights cache: synchronous per-user, per-chapter reads of the raw `Highlight[]` API shape, zod-validated so corrupt or legacy payloads read as a miss rather than throwing. A `deriveServerColors` projection maps cached highlights onto the displayed scope as the verse → hex color map (expanding range passage ids such as `JHN.3.16-18`), so passage ids survive a cold start and remain available for targeted deletes. Cached highlights are purged on sign-out and revoked-refresh alongside the rest of auth state. This surface is not exported from the package index yet — a later release will ship the public hook and API. diff --git a/.changeset/core-highlights-client-wrapper.md b/.changeset/core-highlights-client-wrapper.md deleted file mode 100644 index d0abe49a..00000000 --- a/.changeset/core-highlights-client-wrapper.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@youversion/platform-react-native-expo-core': patch ---- - -Core now depends on `@youversion/platform-core@2.3.0` and includes an internal Highlights client wrapper (`createHighlightsApi`) that calls get/create/delete with an explicit access token and returns typed `Result` failures (`auth` for 401/403, `transient` otherwise). This surface is not exported from the package index yet — a later release will ship the public hook and API. diff --git a/.changeset/core-refresh-single-flight.md b/.changeset/core-refresh-single-flight.md deleted file mode 100644 index d04f785f..00000000 --- a/.changeset/core-refresh-single-flight.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@youversion/platform-react-native-expo-core': patch ---- - -Fix a token refresh already in flight being skipped rather than joined. `refreshToken` tracked its in-flight request with a boolean, so a second caller returned immediately instead of waiting, resolving on the very token the refresh existed to replace. It now holds the request as a promise and hands it to the second caller, matching how in-flight data-exchange requests are already shared. - -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. `refreshNow()` and the new `ensureFreshToken()` both benefit, so awaiting either now means the token is the current one. diff --git a/.changeset/core-use-highlights.md b/.changeset/core-use-highlights.md deleted file mode 100644 index 47503f6b..00000000 --- a/.changeset/core-use-highlights.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@youversion/platform-react-native-expo-core': patch ---- - -Add `useHighlights`, the public hook for reading and writing Bible highlights on native. - -It paints from the MMKV cache synchronously on first render (no blank frame on a cold start), applies and removes optimistically, reconciles against the server, and reverts writes that fail. `apply(color, verses)` and `remove(color, verses)` return a typed `HighlightWriteOutcome` — `ok`, `noop`, 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. - -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), and the `HighlightScope` / `ServerColors` / `Highlight` types. - -Two additions beyond the original ticket, called out so they read as intentional: `refresh()` for pull-to-refresh (it pairs with `isRefreshing`, which is safe to hand straight to `RefreshControl`), and `isRefreshing` is named for "a GET is in flight" rather than `isLoading` — `highlights` is always safe to render, so gating a spinner on it would reintroduce the blank frame the cache exists to prevent. - -The highlights API wrapper and the MMKV cache stay internal; `useHighlights` is the whole public surface. diff --git a/.changeset/highlight-paint-before-refresh.md b/.changeset/highlight-paint-before-refresh.md deleted file mode 100644 index df4c9a96..00000000 --- a/.changeset/highlight-paint-before-refresh.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@youversion/platform-react-native-expo-core': patch ---- - -Fix a highlight not painting until the token refresh in front of it finished. `useHighlightPermissionFlow.apply` awaited `ensureFreshToken()` before it reached the code that paints. A refresh is due when the access token sits at or inside its 60-second leeway, or when the `AppState` foreground listener already started one. In both cases the user tapped a color and watched nothing happen for a full token round-trip. - -The refresh moved into `useHighlights.runWrite`, next to the existing auth-settled wait. The token is still current when the request goes out, which is the property that keeps a 401 from being misread as a stale permission grant, but the optimistic claim now paints on tap. `remove` and any direct `useHighlights` consumer pick up the same freshness guarantee, which previously only `apply` had. diff --git a/.changeset/native-highlights-release.md b/.changeset/native-highlights-release.md new file mode 100644 index 00000000..3d986c90 --- /dev/null +++ b/.changeset/native-highlights-release.md @@ -0,0 +1,113 @@ +--- +'@youversion/platform-react-native-expo-core': minor +'@youversion/platform-react-native-expo-ui': minor +--- + +Bible highlights on native. The reader paints the signed-in user's highlights, verse actions are a native bottom sheet, highlights made offline survive a relaunch and land on their own, and a user who taps a color before signing in or granting the permission gets their highlight rather than losing it. + +## Action required + +Three native modules are new peer dependencies. They are autolinked, so a JS-only reload leaves a `Cannot find native module` redbox — install them and rebuild your dev client. + +```bash +npx expo install expo-network expo-clipboard expo-application +``` + +`expo-network` is core's (the connectivity trigger for parked writes). `expo-clipboard` is UI's (the Copy fallback in the verse action sheet). `expo-application` is now a UI peer too — apps already using core have it. + +**The Bible reader's default serif font changes from Source Serif 4 to Untitled Serif**, YouVersion's brand serif, following the Web SDK. A DOM component's WebView now makes a new outbound request to `api.youversion.com` for the stylesheet plus woff2 fetches from `cdn.youversion.com`. There is no opt-out and no new prop; if those hosts are blocked, serif text falls back to Source Serif 4 with no layout break. Readers who had explicitly chosen Source Serif are migrated. Any other `fontFamily` you pass or persist is left untouched. + +## Reading and writing highlights + +`useHighlights({ versionId, book, chapter })` is the whole public surface for highlights. It paints from an MMKV cache synchronously on first render, so a cold start has no blank frame; applies and removes optimistically; reconciles against the server; and reverts writes the server refuses. + +`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. + +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. + +## Highlights made offline + +A highlight tapped without service keeps its paint instead of un-painting and reporting an error, is persisted per user and chapter so it is still there after a force-quit and relaunch, and reaches the user's account on its own once service returns. + +Which failures park and which revert: + +- **Unreachable, or a 5xx** — the paint stands and the write parks. `apply` and `remove` resolve `{ status: 'queued', verses }`. +- **Refused: 401, 403, or any other 4xx** — the paint reverts to what the server has, and the outcome reports the refusal. + +Writes are persisted before they are sent, so the paint never has a gap and an app killed mid-request still owes the write. At mount the paint comes from the cache with the queue re-applied over it, which repairs a crash between those two writes. A second tap on a verse that already has a parked write overwrites it rather than stacking, and a write whose end state matches what is already stored cancels out without ever becoming a request — so applying and then removing the same verse offline leaves nothing behind. + +`queued` reports the write you just made, not the verse's history, so **it repeats**: tapping a verse that is still parked resolves `queued` again, and the outcome does not tell you whether that verse was already waiting. Show "saved offline" once by holding that in your own state — a batch can mix a parked verse with fresh ones, and a verse parked yellow then tapped green is a new write rather than a repeat, so there is no single flag the SDK could hand back that would be true. + +Parked writes land with no user action and nothing on screen changing at the moment they do. A write made in John 3 lands while the reader is in Romans 8, and lands even if the user never returns to John 3. The drain is owned by core's `YouVersionProvider` and is inert with no auth configured, no signed-in user, or no access token. It wakes on provider mount, on a token change, on the app returning to the foreground, on the rising edge of connectivity, and on a successful highlights fetch; otherwise each parked verse retries on its own widening, capped backoff that resets when it lands. Any wake-up retires the pending wait, so a write deep into its backoff goes out the moment service returns rather than sitting out the rest of it. Connectivity is a trigger, never a gate — a wrong or missing connectivity answer costs a delayed attempt, never a skipped one. + +A write the server will never accept — reachable by calling `useHighlights` directly without the permission flow, or by a `highlights` grant revoked between the tap and the drain — stops being painted rather than sitting on the device forever. A 401 or 403 earns a forced token refresh and one more attempt; the ordinary expired-token case is cured by that and lands normally. Only a **second** auth refusal under a freshly minted token drops the entry, reverting the verse to the color the server had and un-painting on a reader that is still mounted, with no remount and no user action. Nothing else drops: network failures, 5xx, and non-auth 4xx retry indefinitely. A forced refresh that fails, or one that ends the session the write belongs to, drops nothing. + +Signing out drops every write still waiting, in the same routine that clears the highlights cache and the granted-permission cache — which a revoked refresh token also runs, so a dead session takes the parked writes with it. A write parked on one account can never land on the next one signed in on the device. The purge takes every user's parked writes, not only the departing user's: one user is signed in at a time, so an entry under any other id was already left behind by an earlier departure and has no session that could send it. Entries stay keyed per user while a user is signed in, and a user change part-way through a drain stops the pass rather than sending the departed user's writes under the new token. + +## Highlighting before signing in + +`useHighlightPermissionFlow({ versionId, book, chapter })` composes `useHighlights` with the auth context and guards `apply` behind whatever is missing: it holds the pending highlight in memory, runs sign-in and/or the just-in-time consent grant, and applies the highlight on the way back. `remove` is unwrapped and passes straight through — a user with visible highlights already has the grant. + +It returns the underlying `useHighlights` result untouched (render `highlights` from it as before), plus `isConfirming` to drive a consent prompt, `confirm()` / `decline()` to answer it, and `flowError` for the one thing worth a toast. `apply` resolves with the write's own outcome when a write was issued, `noop` when the user abandoned the flow, and an `error` when the flow itself failed — so a cancel or a decline never reads as something going wrong. + +The branch point is a **pre-flight permission read, not a write's 401/403**: branching on the failure reason would burn a failed round-trip before every first highlight. A write refused with `reason: 'auth'` anyway means the cached grant was stale, so the grant is invalidated and the user is re-prompted — **exactly once**, never in a loop. A `queued` write passes straight back to the caller: a user who signs in, grants, and then taps with no service gets their paint, not a re-prompt. Every dismissal path discards the pending highlight cleanly, a grant that comes back without `highlights` does not write, and the pending highlight carries the passage it was tapped in, so nothing resumed after the reader changes chapters can paint verses onto text the user never selected. + +Requires `auth` on `YouVersionProvider` and the `highlights` permission (a permission, never a scope). With no `auth` configured the flow behaves exactly as signed out, and says so once in development. + +## Permissions + +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. + +`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. + +The consent page returns to your `redirectUri`, the same callback URL sign-in uses, because an app key has exactly one — so data exchange needs no setup beyond what sign-in already required. If the two disagree the return never reaches the SDK and the outcome is `cancel`, indistinguishable from a decline. That is the first thing to check when grants do not stick. + +The cached grant is a hint for choosing UI and skipping redundant prompts. The server enforces; gate a privileged action on the pre-flight, not on a cached `true`. + +## Tokens + +Two additions to the auth context, both public: + +- `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. + +`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**. + +## The reader + +`BibleReader` renders natively-owned highlights and replaces the in-WebView verse action popover with a native bottom sheet. It subscribes to `useHighlights` for its current version / book / chapter and feeds the result in as a controlled prop; because the cache read is synchronous, highlights are in the very first props. Nothing about the highlight path runs inside the WebView any more — no network calls, no local store, no auth surface. + +Selecting a verse raises a native sheet with the localized reference, the highlight color swatches, Copy, and Share. There is nothing to enable: no new prop, no opt-in. + +- **Highlight swatches.** A remove circle for every color present on any selected verse, then an apply circle for each of the five palette colors. Writes go through the same service as `useHighlights`, so the passage repaints at once and the sheet closes. +- **Sign-in and permission prompts.** The sheet asks a signed-out user, or one without the `highlights` permission, for exactly what is missing, then applies their color choice with no reselecting of the verse. This needs an `auth` config that requests the `highlights` permission; without one, the swatches behave as they do for a signed-out user. +- **Copy and Share.** They fall back to `expo-clipboard` and React Native's `Share`. Two new optional props on `BibleReader`, `onCopy` and `onShare`, take either one over. Both receive the `BibleReaderShareData` this package re-exports. + +**The sheet has no backdrop, and that is deliberate.** A backdrop intercepts the second verse tap, and extending a selection one verse at a time is the point. The consequence is that a tap outside does not dismiss the sheet — swipe down, deselect the verses, or act on the sheet. Every themed bottom sheet in the SDK now draws an upward drop shadow, so a sheet without a backdrop still separates from the content behind it. + +Two new props carry selection across the bridge: + +- `onVerseSelect(selection)` fires on every selection change, including clears (`verses: []`). The payload carries `versionId`, `book`, `chapter`, `verses`, `passageIds`, a localized `reference` (`Hebrews 11:4`, not `HEB 11:4`), and `shareData` — all bridge-safe primitives. +- `clearSelectionSignal` dismisses the current selection from native. Increment it; the value at mount is the baseline, so mounting never clears. A counter rather than an imperative ref handle because only serializable props cross the DOM 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. + +**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. + +## 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. +- **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). + +## Dependencies + +The Web SDK dependencies move to 2.5.0 — `@youversion/platform-core` (core, from 2.3.0) and `@youversion/platform-react-ui` (UI, from 2.2.0), which brings `@youversion/platform-core` and `@youversion/platform-react-hooks` 2.5.0 with it, so a single copy of each resolves across the workspace. Beyond the serif font change noted above, it supplies the reader's controlled highlights mode, the data-exchange primitives behind the just-in-time grant, and a core `ApiClient` fix reading an empty-body 2xx (what a successful highlight DELETE returns) as success rather than failure. diff --git a/.changeset/native-verse-action-sheet.md b/.changeset/native-verse-action-sheet.md deleted file mode 100644 index 7354475f..00000000 --- a/.changeset/native-verse-action-sheet.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@youversion/platform-react-native-expo-core': minor -'@youversion/platform-react-native-expo-ui': minor ---- - -Verse actions in `BibleReader` are now a native bottom sheet, matching the Swift and Kotlin SDKs. - -Selecting a verse raises a native sheet with the localized reference, the highlight color swatches, Copy, and Share. It replaces the in-WebView popover the previous release switched off, so the actions that release removed are back as native UI. There is nothing to enable: no new prop, no opt-in. - -**Action required. Install `expo-clipboard` and rebuild your dev client.** `expo-clipboard` is a new peer dependency, and it backs the Copy fallback. It is a native module, so a JS-only reload cannot link it. `expo-application` is also now a UI peer dependency. Apps that already use the core package have it. - -```bash -npx expo install expo-clipboard expo-application -``` - -What the sheet does: - -- **Highlight swatches.** A remove circle for every color present on any selected verse, then an apply circle for each of the five palette colors. Writes go through the same highlights service as `useHighlights`, so the passage repaints at once and the sheet closes. -- **Sign-in and permission prompts.** The sheet asks a signed-out user, or one without the `highlights` permission, for exactly what is missing. It then applies their color choice, with no reselecting of the verse. This needs an `auth` config that requests the `highlights` permission. Without one, the swatches behave as they do for a signed-out user. -- **Copy and Share.** They fall back to `expo-clipboard` and React Native's `Share`. Two new optional props on `BibleReader`, `onCopy` and `onShare`, take either one over. Both receive the `BibleReaderShareData` this package already re-exports. - -**The sheet has no backdrop, and that is deliberate.** A backdrop intercepts the second verse tap, and extending a selection one verse at a time is the point. The consequence is that a tap outside does not dismiss the sheet. To dismiss it, swipe down, deselect the verses, or act on the sheet. Every themed bottom sheet in the SDK now draws an upward drop shadow, so a sheet without a backdrop still separates from the content behind it. - -`onVerseSelect` and `clearSelectionSignal` are unchanged and still public. The first fires alongside the sheet rather than instead of it. The second closes the sheet along with the selection. - -**Web keeps the React Web SDK's verse action popover.** Native verse actions 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 popover is what web gets until the native surface reaches it. - -Nothing changes in the core package's public API. It versions alongside UI. diff --git a/.changeset/reader-renders-native-owned-highlights.md b/.changeset/reader-renders-native-owned-highlights.md deleted file mode 100644 index 3b320388..00000000 --- a/.changeset/reader-renders-native-owned-highlights.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@youversion/platform-react-native-expo-core': patch -'@youversion/platform-react-native-expo-ui': patch ---- - -`BibleReader` now renders natively-owned highlights, and the in-WebView verse action popover is switched off. - -The reader subscribes to `useHighlights` for its current version / book / chapter and feeds the result into the Web SDK reader as a controlled prop. Because the MMKV cache read is synchronous, highlights are in the very first props — no blank first frame. Nothing about the highlight path runs inside the WebView any more: no network calls, no local store, no auth surface. - -**`verseActions="none"` is now hardcoded.** Until the native verse action sheet lands (YPE-3712), selecting a verse raises **no** action UI inside the reader. The color swatches, Copy, and Share buttons are gone. Verse selection and selection painting are unchanged. Two new props replace what the popover provided: - -- `onVerseSelect(selection)` fires on every selection change, including clears (`verses: []`). The payload carries `versionId`, `book`, `chapter`, `verses`, `passageIds`, a localized `reference` (`Hebrews 11:4`, not `HEB 11:4`), and the `shareData` the popover's Copy / Share buttons would have used — all bridge-safe primitives. -- `clearSelectionSignal` dismisses the current selection from native. Increment it; the value at mount is the baseline, so mounting never clears. A counter rather than an imperative ref handle because only serializable props cross the DOM bridge. - -`BibleReaderVerseSelection` and `BibleReaderShareData` are re-exported so a handler can be typed without depending on `@youversion/platform-react-ui` directly. - -On the core side, `useHighlights` now gates its GET on the app having **requested** the `highlights` permission (`auth.permissions` on `YouVersionProvider`). Without it the SDK issues no highlights request at all — so an app that renders a reader and never asked for highlights pays nothing. 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. `useYVAuth()` gains `requestedPermissions` to carry it, defaulting to `[]`. - -Not in this change: applying or removing highlights from native, the native verse action sheet, and copy / share. diff --git a/.changeset/sync-localization-reactnative-ace9bbd.md b/.changeset/sync-localization-reactnative-ace9bbd.md deleted file mode 100644 index 118e09a1..00000000 --- a/.changeset/sync-localization-reactnative-ace9bbd.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@youversion/platform-react-native-expo-ui': patch ---- - -Sync localization from platform-localization (ace9bbd): update 2 keys in en. diff --git a/.changeset/verse-action-swatch-tray-android-scroll.md b/.changeset/verse-action-swatch-tray-android-scroll.md deleted file mode 100644 index 560d4026..00000000 --- a/.changeset/verse-action-swatch-tray-android-scroll.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@youversion/platform-react-native-expo-ui': patch ---- - -Fix the verse action sheet's highlight swatch tray not scrolling on Android, which made hidden swatches unreachable by touch. - -Six swatches fit the tray. A selection spanning two existing highlight colours already produces seven, so this affected a common case, not an edge one — the extra swatches rendered, the trailing fade correctly reported them, and no gesture could reach them. - -`@gorhom/bottom-sheet` builds its pan gesture with no activation criteria, so `react-native-gesture-handler` falls back to a direction-agnostic touch slop. A sideways drag over the tray activated the _sheet's_ pan, and activating cancels the touch stream in every native view underneath it, so the tray's `ScrollView` never scrolled. The sheet now constrains that pan to vertical intent, which leaves horizontal drags to the tray. Swipe-down dismissal is unchanged. - -`NativeSheet` gains an internal `panActiveOffsetY` pass-through for this. No public API changes. diff --git a/.gitignore b/.gitignore index 37ac37b0..b45605ae 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ apps/example/web-build/ # Riptide artifacts (cloud-synced) and workspace config .humanlayer/ + +# Agent scratch (specs and tickets) +.scratch/ diff --git a/AGENTS.md b/AGENTS.md index 553c2c8b..6df277c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,6 +100,8 @@ 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. + ### Verse Action Sheet `BibleVerseActionSheet` (`native/bible-verse-action-sheet.tsx`) is the native replacement for the Web SDK's verse action popover: reference, highlight swatch tray, Copy, Share. It is **internal**. The reader owns it, and nothing exports it. @@ -168,7 +170,7 @@ Keep `apps/example/metro.config.js` minimal — just `getDefaultConfig(__dirname **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` -**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`, `HIGHLIGHT_COLORS` / `isHighlightColor`, `mmkvStorage`, auth types (`AccessTokenResult`, `AuthConfig`, `AuthPermission`, `KnownAuthPermission`, `AuthScope`, `DataExchangeOutcome`, `DataExchangeFailureReason`, `YVUserInfo`), and highlights types (`Highlight`, `HighlightScope`, `ServerColors`, `HighlightWriteOutcome`, `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` / `ensureFreshToken` / `getAccessToken` alongside the sign-in surface), `useHighlights`, `useHighlightPermissionFlow`, `deriveServerColors`, `hasQueuedHighlightWrites`, `HIGHLIGHT_COLORS` / `isHighlightColor`, `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`) UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider`. Import Bible components from UI; import `useYVAuth` from core. @@ -190,7 +192,7 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider - **The return URL is the app's `redirectUri`, not an SDK-owned constant** ([ADR 0015](docs/adr/0015-data-exchange-return-scheme.md)). An app key has exactly one registered callback URL and sign-in already owns it, so data exchange reuses it. Verified on device: with the app's URI registered the server returns `?data_exchange_status=granted&granted_permissions=...`; register a different URI and sign-in fails with `invalid_request: redirect_uri does not match registered callback URL`. - **A `redirectUri` that disagrees with the registered callback URL fails silently.** The consent page opens, the user consents, and the return never matches, so `openAuthSessionAsync` reports `dismiss` and the outcome is `cancel` — identical to a decline, with the grant discarded. This is the first thing to check when grants "don't stick". - The example app and docs use `youversionauth://callback`, matching Swift (`Users+SignIn.swift`, `DataExchangeSession.swift`) and Kotlin (`DEFAULT_AUTH_CALLBACK`). Android must register the `youversionauth` scheme in `app.json` to route it; that scheme is shared across every app integrating the SDK, which is the accepted tradeoff on all three platforms. -- **The cached grant is a hint, not an authority** ([ADR 0014](docs/adr/0014-cached-grant-is-a-hint.md)). `hasPermission` chooses UI and skips redundant prompts; the server enforces. Under MMKV failure a revoked grant can survive — clearing is best-effort by design, because it must never break sign-out — so a privileged action gates on the pre-flight, never on a cached `true`. Read the ADR before "fixing" `clearGrantedPermissions`. +- **The cached grant is a hint, not an authority** ([ADR 0014](docs/adr/0014-cached-grant-is-a-hint.md)). `hasPermission` chooses UI and skips redundant prompts; the server enforces. Under MMKV failure a revoked grant can survive — clearing is best-effort by design, because it must never break sign-out — so a privileged action gates on the pre-flight, never on a cached `true`. Read the ADR before "fixing" `clearGrantedPermissions`. The cached `userInfo` shares the residual on the same terms — a record the store refuses to remove reseeds `userInfo` (and with it the highlights paint) on the next mount, bounded by the bootstrap `clearAuthState` rather than by the server. Same ADR, 2026-08-11 amendment. - `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/`). @@ -206,9 +208,17 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider - The GET is gated on `shouldFetchHighlights(requestedPermissions)`: an app that never asked for `highlights` issues no highlights request at all. Gate on the **requested** list, never on a grant — a missing grant is indistinguishable from an unknown one, so `hasPermission('highlights')` (documented "false when unknown") would silently un-paint the highlights of every user who signed in before grant reporting shipped. When C3.1 tightens this, only a _known_ denial may skip; the constraint is written out on the predicate. - Paints from the MMKV cache **synchronously** in a `useState` initializer. That only works because `AuthProvider` seeds `userInfo` from its own initializer, so `userInfo.id` exists on the first render — load-bearing coupling, commented at both ends. - `highlights` is always safe to render. `isRefreshing` means "a GET is in flight", never "no data yet"; gating a spinner on it reintroduces the blank first frame the cache exists to prevent. -- `error` is **fetch-only**. Writes report once, through the `HighlightWriteOutcome` they resolve to — that is also C3's branch point for the sign-in prompt (`reason === 'auth'` / `'not-signed-in'`). +- `error` is **fetch-only**. Writes report through the `HighlightWriteOutcome` they resolve to, and that outcome is point-in-time, not final: `queued` means the paint stands and the drain owes the server the write. Treat it as a success anywhere a write outcome is branched on — the highlight is on screen. Being point-in-time, it also **repeats**: every tap on a verse that is still parked resolves `queued` again, because the outcome reports that write and not the verse's queue state. It carries no first-park-vs-repeat field on purpose — a batch can mix a parked verse with fresh ones, so an honest one would be a per-verse split, and a verse parked yellow then tapped green is a new write rather than a repeat. Deduping a "saved offline" message is the caller's own state. Only an `error` un-paints, and the reason that matters downstream is `auth`: a refusal under a token that should have worked is the permission flow's corrective fallback for a stale grant. `not-signed-in` is not a refusal at all — it is raised locally, before any request goes out, when auth settles with no token or a different user; the flow's own **pre-flight** is what raises the sign-in prompt. The queue changes neither reason. - The five swatches in `HIGHLIGHT_COLORS` are a company standard enforced in core: both `apply` and `remove` reject anything else as `invalid` before painting or issuing a request. Do not relax this on layering grounds — the open improvement is relocating the palette to `@youversion/platform-core`, not deferring it to the UI layer. -- Overlay math lives in the pure, React-free `packages/core/src/highlights/optimistic.ts`, ported from the web highlights machine. Ownership tokens and the color-aware overlay retirement rule are documented in [ADR 0013](docs/adr/0013-native-highlights-optimistic-layer.md); the retirement rule reads like a bug in both directions and is defended only by its regression pair, so read the ADR before touching `shouldRetire`. +- Paint math lives in the pure, React-free `packages/core/src/highlights/optimistic.ts`, ported from the web highlights machine. `state.colors` is what the reader shows — server truth with unconfirmed edits already folded in, not a base plus an overlay. The colour-aware reconcile retirement rule is documented in [ADR 0013](docs/adr/0013-native-highlights-optimistic-layer.md); it reads like a bug in both directions and is defended only by its regression pair, so read the ADR before touching `shouldRetire`. That ADR's ownership tokens were retired by [ADR 0018](docs/adr/0018-highlight-write-queue.md) — the guarantee they gave is now a value comparison against the write queue. +- Writes are persisted before they are sent. `packages/core/src/highlights/queue.ts` holds one entry per verse, `{ local, server }`, keyed per user + scope. A write that cannot reach the server, or that comes back 5xx, keeps its paint and resolves `{ status: 'queued' }` — those are the two failures that park. Only a server _refusal_ (401/403 or any other 4xx) reverts, using the entry's `server` side. **Cached Highlights are the paint, not raw server truth** — MMKV is written queue first, cache second, and the mount re-applies the queue over the cache to repair a crash between the two. See [ADR 0018](docs/adr/0018-highlight-write-queue.md). +- Parked writes are sent by the **drain** (`highlights/drain.ts`), mounted at core's `YouVersionProvider` by `HighlightQueueDrainHost` inside `AuthProvider` — a parked write outlives the chapter that made it, and after a relaunch the queue is the only record of its scope. It wakes on mount, on a token change, on `AppState` returning to active, on the rising edge of `expo-network` connectivity, and on the two signals the write path raises directly (`drain-signals.ts`: a request reached the server, a write parked). Everything else is a per-verse in-memory backoff that widens on each consecutive failure and resets on success. Connectivity is a trigger, never a gate. +- The drain has exactly one drop path: a 401/403 earns an unconditional `refreshNow` and one retry, and a second auth refusal drops the entry and reverts the cache to the entry's `server` side. Nothing else drops — 5xx, non-auth 4xx, and unreachable all retry indefinitely. A refresh that is absent, throws, or ends the session drops nothing — the retry must go out under a token the refresh actually minted. The un-paint reaches a mounted reader through the queue's drop notification (`onWritesDropped`, raised only by `dropRejectedWrites`); a settling write already owns its own paint, so `dropWrites` stays silent. +- `hasQueuedHighlightWrites(userId)` answers the one question sign-out asks the queue: does this user have writes the server never took? A plain MMKV prefix scan, read on that one gesture rather than subscribed to, and it never throws — an unreadable store answers "nothing to lose" rather than breaking the gesture that raises the prompt. Any key under the user's prefix counts, including a scope suffix `listQueuedScopes` would reject: the drain cannot send that entry, which makes it more certain to be lost, not less. +- Sign-out purges the queue, in `clearAuthState` alongside the highlights cache and the grant cache (the revoked-refresh-token path routes through the same routine). `clearHighlightQueue()` drops **every** user's entries, not just the departing one's — one user is signed in at a time, so anything under another id was already left by a departure. Entries stay per-user keyed while signed in, and the drain re-reads auth per scope so a user change mid-pass cannot send the departed user's writes under the new token. +- The drain skips verses a mounted `useHighlights` is currently sending (`highlights/claims.ts`, refcounted). Queue-first writes leave an entry in MMKV for the whole life of a write, so the queue alone cannot tell the drain what is already in hand. The deference is one-directional — the hook never waits on the drain. +- It lands a write by writing the color into the **cache** and then dropping the entry, in that order. It may be landing a scope no hook is mounted on, and the cache is the paint; a crash between the two must leave the write owed rather than the paint gone. +- Backoff intervals live in `highlights/backoff.ts` and are not pinned by any test. Retuning them must not red the suite. ## Highlight permission flow (core) @@ -220,6 +230,7 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider - 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. - `flowError` is for terminal _flow_ failures only (a failed grant, a still-refused write). Cancels and declines resolve `{ status: 'noop' }` — a user choice is not an error and must not surface as one. +- The queue is invisible to the flow, and that is deliberate. `apply` branches only on `error` + `reason: 'auth'`, so a `queued` write passes straight back to the caller: a user who signs in, grants, and then taps with no service gets their paint and a `{ status: 'queued' }` outcome, not a re-prompt. Only a refusal means the grant was stale, and only a refusal reverts — a flow that treated an unreachable server as a permission problem would prompt a user who has already granted everything. - **The flow's two prompts are `HighlightConsentSheet` and `SignInWithYouVersionSheet`** (`packages/ui/src/native/`). Both are presentational, and both are internal. `BibleReader` wires them. Consent's `isOpen` is the hook's `isConfirming`, and `onConfirm` is `confirm()`. Route **every** dismissal path (button, backdrop, pan-down, displacement) to `decline()`. A path that skips it strands the flow with `isConfirming` still true. The sign-in prompt is the reader's own, in front of the flow, because the hook calls `signIn()` with no UI of its own. Neither sheet runs any auth itself. ## Runtime Dependencies @@ -232,6 +243,8 @@ Native modules and app-owned framework packages are peer dependencies. Consumers The verse action sheet added two UI peers: `expo-clipboard` (the Copy fallback) and `expo-application` (the app's display name in the sign-in prompt). A consumer upgrading into this version must install `expo-clipboard` and rebuild the dev client. `expo-application` was already a core dependency, so no new autolinked module reaches an app that already had core. +The highlight write queue's drain added one core peer: `expo-network`, the rising-edge connectivity trigger. It is autolinked, so a consumer upgrading into this version must install it and rebuild the dev client — a JS-only reload leaves a `Cannot find native module` redbox. + ## Peer Dependencies See `packages/ui/package.json` and `packages/core/package.json` `peerDependencies` for the canonical list. Requires a dev build (not Expo Go). diff --git a/CONTEXT.md b/CONTEXT.md index b888200d..5fa7526b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -103,16 +103,16 @@ The chapter a highlights flow is operating on: `versionId` + `book` + `chapter`. _Avoid_: Folding `userId` into this type; Reader Location (restore snapshot for uncontrolled readers, different purpose); cache key (implementation detail) **Server Colors**: -The verse→color map for a **Highlight Scope**: `Record` where keys are verse numbers and values are 6-char hex colors with no `#`. A _derived_ projection of **Cached Highlights** onto the displayed scope, used for optimistic overlay math — not something we persist, and not optimistic UI overlays themselves. Range passage ids expand to one entry per verse and colors are normalized to lowercase during projection. +The verse→color map for a **Highlight Scope**: `Record` where keys are verse numbers and values are 6-char hex colors with no `#`. A _derived_ projection of **Cached Highlights** onto the displayed scope — not something we persist. A verse appears at most once: one color at a time, so a recolor replaces rather than accumulates. Range passage ids expand to one entry per verse and colors are normalized to lowercase during projection. _Avoid_: Persisting this shape (it destroys passage ids — see **Cached Highlights**); highlight colors (ambiguous with UI state), highlightedVerses (Web SDK render prop; often boolean-keyed) **Cached Highlights**: -The raw core API shape (`Highlight[]`: `version_id` + `passage_id` + `color`) persisted on native per `userId` + **Highlight Scope**. Passage ids may be verse ranges (`JHN.3.16-18`), so this is the only shape that can feed the web reader's controlled `highlights` prop on a cold start and that supports passage-id-targeted deletes. Reads are synchronous and validated; a valid empty array is a real snapshot (“none”), not a cache miss, and any corrupt or legacy payload reads as a miss. -_Avoid_: Flattening to **Server Colors** before writing; treating an empty array as a miss +The raw core API shape (`Highlight[]`: `version_id` + `passage_id` + `color`) persisted on native per `userId` + **Highlight Scope**. Passage ids may be verse ranges (`JHN.3.16-18`), so this is the only shape that can feed the web reader's controlled `highlights` prop on a cold start and that supports passage-id-targeted deletes. Reads are synchronous and validated; a valid empty array is a real snapshot (“none”), not a cache miss, and any corrupt or legacy payload reads as a miss. What the reader paints, not raw server truth: a **Queued Write** is folded in, which is what lets an unsent highlight survive a relaunch before anything touches the network. +_Avoid_: Flattening to **Server Colors** before writing; treating an empty array as a miss; merging unsent writes into it -**Highlight Overlay**: -The local layer of pending edits for a **Highlight Scope**, `Record` — a hex color where the user just applied one, `null` where they just removed one. Sits on top of **Server Colors** so the reader paints before the server answers; entries retire once the server confirms them (see [ADR 0013](docs/adr/0013-native-highlights-optimistic-layer.md) for the color-aware remove rule). Never persisted — see **Cached Highlights**. -_Avoid_: Optimistic state (too vague — this is one specific layer), **Server Colors** (the layer underneath), persisting it +**Reconcile Entry**: +A write the server has accepted, held in memory until a fetch agrees with it. Without it a read replica one step behind repaints a highlight that was just deleted ("vapor"); the color-aware retirement rule is in [ADR 0013](docs/adr/0013-native-highlights-optimistic-layer.md) and reads like a bug in both directions. In memory only — persisting it would make the drain re-send a write the server already has. +_Avoid_: Highlight Overlay (the separate optimistic layer this replaced — **Cached Highlights** now hold the paint), ownership token / write intent (retired with it; a settling write finds its entries by value) **Controlled Highlights Latch**: The **Native Wrapper** always supplying a `highlights` array to its **Expo DOM Component**, never `undefined`. The Web SDK reader decides at first mount whether its highlight slice is controlled, and only the controlled branch makes no network calls, keeps no local store, and exposes no auth surface. So the array's _presence on the mount render_ is the guarantee, and `[]` is a legitimate value meaning "controlled, nothing highlighted". Missing it on that first render is what hands the WebView back the ability to write highlights with the token native gave it; dropping it later only un-paints, because the SDK reads `highlights ?? []` after the latch is set. Both are bugs — the first is unrecoverable and silent, which is why the DOM wrapper coerces a non-array to `[]` rather than trusting the type alone. @@ -135,8 +135,8 @@ The highlight circles in a **Verse Action Sheet**. A pure function projects them _Avoid_: Re-deriving the rule from the sheet's UI; an ALL rule (a color on one verse of three still earns a remove circle); counting colors the palette does not contain **Highlight Write Outcome**: -What an `apply` or `remove` resolves to: `ok` with the verses that landed, `noop` when there was nothing to write, or `error` carrying a `reason` (`not-signed-in` / `auth` / `invalid` / `transient`), a diagnostic `message`, and both `failedVerses` (reverted) and `succeededVerses` (landed — non-empty means a partial batch). The only channel a write failure reports on; the hook's `error` state is for fetches alone, so a failed write can never evict a fetch error that is still true. -_Avoid_: Branching on `message` (generic outside development builds); routing write failures through the hook's `error`; a separate `partial` status (the two verse arrays already say it) +What an `apply` or `remove` resolves to: `ok` with the verses that landed, `queued` with the verses parked as **Queued Writes**, `noop` when there was nothing to write, or `error` carrying a `reason` (`not-signed-in` / `auth` / `invalid` / `transient`), a diagnostic `message`, and both `failedVerses` (reverted) and `succeededVerses` (landed — non-empty means a partial batch). The only channel a write failure reports on; the hook's `error` state is for fetches alone, so a failed write can never evict a fetch error that is still true. `queued` is a point-in-time signal at the tap, not a standing state — nothing in the API reports on a **Queued Write** afterwards. +_Avoid_: Branching on `message` (generic outside development builds); routing write failures through the hook's `error`; a separate `partial` status (the two verse arrays already say it); reading `ok` as "saved" and `queued` as a failure (both mean the paint stays) **Access Token Result**: What `getAccessToken()` resolves to, and the only thing in the SDK that can tell a refresh that worked from one that did not — `refreshToken` swallows failure by design, so a caller reading the token afterwards cannot. Either `ok` with the `token` and the `userId` that owns it, or `unavailable` with a `reason`. The two reasons are different situations, not degrees of the same one: `signed-out` means there is no session, while `refresh-failed` means the session is intact and only the token endpoint is unreachable — tokens stay in storage and the user stays signed in. The `userId` rides along because it is read in the same synchronous block as the token; `userInfo` read through a render lags it, so a caller guarding on a captured identity that compares against the lagging one passes a check it should have failed. @@ -156,7 +156,15 @@ _Avoid_: Branching on a write failure first (burns a round-trip before every fir **Pending Highlight**: The in-memory `{ color, verses, scope }` a **Permission Flow** stashes when the user taps a color before they can write, and applies when sign-in or consent succeeds. Lives only inside reducer state — `openAuthSessionAsync` returns to the same live process, so web's `sessionStorage` stash and TTL solve a problem native does not have. Discarded cleanly on every cancel, decline, failure, or scope change. Its `scope` is the passage the intent was formed in and governs it: verse numbers replayed into another chapter would paint text the user never selected, so anything resumed after an await is checked against the scope it was claimed under. -_Avoid_: Persisting it (that is F1's offline queue, a different thing); keeping it across a scope change (the user has left the passage); reading the current **Highlight Scope** at replay time instead of the claimed one; treating a discard as an error +_Avoid_: Persisting it (a **Queued Write** is the persisted thing, and it is a different thing); keeping it across a scope change (the user has left the passage); reading the current **Highlight Scope** at replay time instead of the claimed one; treating a discard as an error + +**Queued Write**: +One verse's unsent write, persisted per `userId` + **Highlight Scope**, written before the request goes out. Two sides: `local` is where the user wants the verse (a hex color, or `null` for none) and `server` is where the server had it before the user started editing, kept so a rejected write can be put back exactly — offline, and after a relaunch. Desired state, not an operation: a second tap overwrites `local` rather than appending, `server` survives that overwrite, and an entry whose two sides agree asks for nothing and is dropped. Retired when the server accepts or refuses it, never on a failure to reach it. +_Avoid_: **Pending Highlight** (an in-memory permission-flow intent, discarded rather than persisted); a write log or op journal; "offline write" (a 5xx from a reachable server parks here too) + +**Highlight Write Queue**: +The durable store of **Queued Writes** and the drain over them, owned by core's `YouVersionProvider` rather than any one `useHighlights` — a write must land after the user has navigated away, and draining needs a token. It runs on provider mount, on a token change, on `AppState` returning to active, on the rising edge of connectivity (`expo-network`), on any successful highlights GET, and otherwise on a per-entry backoff that widens on each consecutive failure and resets on success. Connectivity is a trigger, never a gate — the drain never asks whether the network is up before attempting. Unbounded by design — no size cap, no TTL, no attempt budget — with a single drop path, a 401/403 that survives a forced token refresh and one retry. Purged on sign-out with the rest of the user's data. See [ADR 0018](docs/adr/0018-highlight-write-queue.md). +_Avoid_: Offline queue (5xx entries park here too); a per-scope or per-hook queue; gating a drain attempt on the connectivity answer; treating a stuck entry as something the SDK will eventually clean up ## Relationships @@ -182,7 +190,7 @@ _Avoid_: Persisting it (that is F1's offline queue, a different thing); keeping - The **SDK Attribution Header** depends on **Compiled Distribution**: because published builds run from `build/` while dev runs from `src/`, the publish-time stamp can give the two different channel signals from one source file. - A **Highlight Scope** identifies the chapter for highlights (web-compatible location triple). Native persists **Cached Highlights** keyed by `userId` + **Highlight Scope**; without a known `userId`, the cache does not read or write. This is **Native-Owned State**, distinct from **Reader Location**. - **Server Colors** are derived from **Cached Highlights** for a given **Highlight Scope**, never stored: entries whose version, book, or chapter does not match the scope are ignored, so stale data cannot mispaint. -- A **Highlight Overlay** sits on top of **Server Colors** and is the only optimistic layer in the stack — the web reader's controlled `highlights` prop is pure projection. Each write claims the verses it paints, and a settling write only reverts verses it still owns. +- **Cached Highlights** are the only optimistic layer in the stack — the web reader's controlled `highlights` prop is pure projection. A settling write only touches **Queued Writes** still asking for what it sent, so a rejection cannot revert a newer tap. - A **Highlight Write Outcome** is the sole report of a write's fate, and is where C3's sign-in branch reads from. - The reader's **Native Wrapper** derives **Cached Highlights** for its current **Highlight Scope** and holds the **Controlled Highlights Latch** with them; the **Expo DOM Component** only projects that array and never fetches, stores, or authenticates for highlights. - The highlights fetch is mounted only when the app **requested** the `highlights` permission on its auth config — not when a grant is known. A never-requested permission means no request; an unknown grant still fetches, because absence of a grant record is not a denial. @@ -196,6 +204,12 @@ _Avoid_: Persisting it (that is F1's offline queue, a different thing); keeping - The **Verse Action Sheet** yields to the sign-in and consent sheets rather than competing with them. **Native Sheet** displacement would close it, and closing it clears the selection a **Pending Highlight** is waiting on. - **Verse Action Swatches** are a projection of **Verse Selection** over **Server Colors**, the same layer the reader paints from, so the tray and the passage can never disagree. A swatch press routes to **Permission Flow**'s guarded `apply`, or straight to `remove`. - A **Pending Highlight** belongs to exactly one **Permission Flow** and one **Highlight Scope**; when the flow ends in an apply, its fate is reported through the ordinary **Highlight Write Outcome**. +- A write that fails to reach the server becomes a **Queued Write** instead of reverting; a write rejected by the server (401/403, or any other 4xx) reverts and reports as before, so auth never enters the **Highlight Write Queue**. +- A **Queued Write** survives a cold start because **Cached Highlights** already carry it. MMKV is written queue first, cache second, and the mount re-applies the queue over the cache to repair a process that died between the two. +- The **Highlight Write Queue** needs a way to tell mounted readers an entry was dropped, or a verse stays painted after the SDK has given up on it. A successful drain needs no notification to look right — the paint and the new server truth are the same color. +- A **Queued Write** wins over disagreeing server truth (`Highlight` carries no id or timestamp, so recency cannot be computed). +- A verse holds **one color at a time**, so an apply is an upsert and a color the user has replaced is not a state the server ever needs to see. A **Queued Write** superseded before it is sent is dropped rather than sent and overwritten. +- A **Permission Flow** that cannot complete offline produces no **Queued Write**: the data-exchange mint fails before any browser opens, and the **Pending Highlight** is discarded. Offline highlighting works at all only because the pre-flight reads the _cached_ grant ([ADR 0014](docs/adr/0014-cached-grant-is-a-hint.md)). ## Example Dialogue @@ -220,9 +234,17 @@ _Avoid_: Persisting it (that is F1's offline queue, a different thing); keeping > **Dev:** "I wired `onClick` on `BibleVersionPickerLanguageTrigger` but the popover state still changes." > **Domain expert:** "Call `event.preventDefault()` in the DOM wrapper so the Web SDK doesn't also run `setIsLanguagesOpen`. Mobile uses the shell cross-fade, not popover layout." +> **Dev:** "The user tapped a color offline — can I just persist the pending highlight until they reconnect?" +> **Domain expert:** "Those are two different things. A **Pending Highlight** is an intent waiting on a _permission_, and it stays in memory (ADR 0016). A **Queued Write** is a write waiting on the _network_, and it is persisted. Offline with no grant, the flow fails and nothing is queued — there is no reason to believe that write is permitted." + +> **Dev:** "Should we add a connectivity library so the queue drains the moment service comes back?" +> **Domain expert:** "We did — `expo-network`, on the rising edge only. Without it the wait is a foreground away, because the successful-GET signal can't fire on a network that's down, and the backoff that makes a stuck entry cheap is what makes that wait long. But it's a trigger, not a gate: we never ask whether the network is up before attempting, so a wrong answer costs a late attempt, never a skipped one." + ## Flagged Ambiguities - "DOM component" can mean browser UI in general or an Expo DOM wrapper. Resolved: use **Expo DOM Component** for files with `'use dom'` in this SDK. - "Selection" and "press" are distinct. Resolved: **Picker Press** opens presentation from the current location; **Picker Selection** commits a new location. - "Passage id", "USFM ref", and reader state were used interchangeably. Resolved: the chapter picker selection payload is reader state: `book`, `chapter`, and `versionId`. - "**Native-Owned State**" was read as "all picker state on native." Resolved: committed outcomes and sheet coordination are native-owned; in-sheet panels are **DOM-Owned Sheet UI State** (see ADR 0005). +- "Offline queue" (used in [ADR 0013](docs/adr/0013-native-highlights-optimistic-layer.md) and earlier drafts of this file) suggested the queue holds writes made without a network. Resolved: it is the **Highlight Write Queue**, and a 5xx from a perfectly reachable server parks in it too. "Offline" names one cause, not the eligibility rule. +- "Pending" was used for both a permission-flow intent and an unsent write. Resolved: **Pending Highlight** is in-memory and waits on a permission; a **Queued Write** is persisted and waits on the server. diff --git a/README.md b/README.md index 47407364..b9036262 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Install the required peer dependencies (Expo will pick versions compatible with ```bash npx expo install @gorhom/bottom-sheet @expo/dom-webview \ - expo-application expo-clipboard expo-crypto expo-secure-store expo-web-browser \ + expo-application expo-clipboard expo-crypto expo-network expo-secure-store expo-web-browser \ react-dom \ react-native-gesture-handler react-native-mmkv \ react-native-nitro-modules react-native-reanimated \ diff --git a/apps/example/package.json b/apps/example/package.json index 122e013f..ce0cd2f4 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -28,6 +28,7 @@ "expo-clipboard": "56.0.4", "expo-dev-client": "56.0.20", "expo-linking": "56.0.14", + "expo-network": "56.0.5", "expo-router": "56.2.11", "expo-secure-store": "56.0.4", "expo-splash-screen": "56.0.12", diff --git a/docs/adr/0013-native-highlights-optimistic-layer.md b/docs/adr/0013-native-highlights-optimistic-layer.md index 058de298..845cc54e 100644 --- a/docs/adr/0013-native-highlights-optimistic-layer.md +++ b/docs/adr/0013-native-highlights-optimistic-layer.md @@ -72,3 +72,10 @@ Web's settle routes a 401/403 into invalidate → re-stash pending highlight → - **`error` is fetch-only.** Writes report once, through their return value. With one error slot, a transient write failure would evict a fetch error that is still true (the reader is showing stale cached data _because_ the GET failed), and a consumer with both a call-site handler and an error banner would render two UIs for one event. - **Writes hold through the token-loading window** on `accessToken !== null || !isLoading`, never on `isLoading` alone — `postTokenEndpoint` has no `AbortController`, so a hung network can leave `isLoading` true indefinitely. Without the hold, a cold-start write returns `not-signed-in` for a genuinely signed-in user, which is the exact value C3 branches on to launch a sign-in prompt. - The cache stores server truth only. A _confirmed_ write would be safe to persist — this is a cost decision, not a correctness one: merging a remove into cached ranges drags range-splitting onto the write path to fix a flash that requires the app to die inside a one-request window. F1's offline write queue will need exactly that machinery. + - **Update ([ADR 0018](0018-highlight-write-queue.md)):** it did not, and the premise was reversed. **Cached Highlights** now hold the paint, unsent writes included, and the range-splitting predicted here is avoided anyway — `selectHighlights` already emits per-verse, so persisting it is lossless. The window named above is closed: the queue entry is written before the request goes out, so a write that dies with the app mid-request is owed on the next launch. + +- **Update ([ADR 0018](0018-highlight-write-queue.md)): the ownership token is retired, the guarantee is not.** With the queue holding every unconfirmed write, the **Highlight Overlay** and its `writeIntent` map were deleted; a settling write now finds its entries by value, touching only those still asking for what it sent. + + The token was necessary here because an overlay entry carried no record of what it was for — two writes were distinguishable only by identity. A queue entry carries its own desired state, so the yellow-then-green race resolves on the data: yellow's rejection reads `local` as green, and leaves it alone. The one case value comparison cannot separate — two writes carrying the same colour — needs no separating, because they express the same intent and either may retire the entry. + + The `claim` / `settle` / reconcile vocabulary shared with web narrows to reconcile alone (`paint` / `confirm` / `restore` replace the rest). Anyone diffing the two files should read this before assuming the guarantee was dropped along with the mechanism. diff --git a/docs/adr/0014-cached-grant-is-a-hint.md b/docs/adr/0014-cached-grant-is-a-hint.md index cc35b102..cc989af9 100644 --- a/docs/adr/0014-cached-grant-is-a-hint.md +++ b/docs/adr/0014-cached-grant-is-a-hint.md @@ -29,6 +29,20 @@ Clearing stays best-effort, with no mitigation layered on top: The second row is the accepted residual: it requires an MMKV removal to fail, and its worst outcome is a skipped prompt followed by a request the server denies. +## Amendment (2026-08-11): the identity cache carries the same residual + +`cachedUserInfo` is purged by the same best-effort removal, in the same `clearAuthState`, and seeds `userInfo` in the same synchronous initializer. Review raised the sibling finding: a removal that does not take leaves the departed user's record readable, the next mount seeds it, and `useHighlights` — which keys its own synchronous cache read off `userInfo.id` — paints that user's highlights. + +The mechanism is real and the decision is the same, but it cannot borrow this ADR's reasoning: a stale grant is bounded by the server, and a stale identity is not. Nothing server-side stops the SDK painting a departed user's name or their cached highlights. What bounds it is the **bootstrap clear**. SecureStore is a different store, its removals are awaited, and they took — so `loadTokens()` finds no refresh token, `clearAuthState` runs again, and `setIdentity(null)` drops the identity from memory whether or not the store accepts the removal. The exposure is the window before that resolves, which every cold start already has, with `isAuthenticated` false and `isLoading` true throughout. + +Nothing is layered on top of that bound, because nothing can be: + +- Every candidate mitigation — a fallback overwrite with a value the loader rejects, a tombstone, a retry — is another **write** into the store that just refused a write. Both realistic asymmetric states (a read-only instance, a full disk) serve reads and fail every write, so each fallback fails alongside the removal it was meant to cover. +- The exception is not even the operative signal: `MMKV.remove` returns `false` rather than throwing on a read-only instance, so the record can survive with nothing caught. The `try`/`catch` in `clearAuthState` exists to keep a throw from aborting the token clearing below it; it is not a detector, and reading it as one overstates what it can promise. +- Corroborating the seed against the store sign-out did clear means awaiting SecureStore, which is precisely the synchronous first-render seed this ADR and `useHighlights` are both built on. + +So the cached identity is a hint on the same terms as the grant: `userInfo` decides what to paint on the first frame, `isAuthenticated` / `isLoading` decide whether anyone is signed in, and privileged work gates on the latter. `AuthProvider — mount` pins the bound in both directions — with a healthy store the record is gone, and with a store that refuses removals the identity and grant still leave state at bootstrap while the record survives. + ## Consequences The blast radius of a stale grant is a redundant request and a re-prompt, never access the user does not have. That holds only while the write path treats the server's 401/403 as authoritative and corrects the cache through `invalidatePermissions`; a future change acting on `hasPermission` without that corrective edge voids this ADR and reopens the authoritative-cache option. diff --git a/docs/adr/0018-highlight-write-queue.md b/docs/adr/0018-highlight-write-queue.md new file mode 100644 index 00000000..1e1aa678 --- /dev/null +++ b/docs/adr/0018-highlight-write-queue.md @@ -0,0 +1,104 @@ +# 18. The highlight write queue is unbounded desired state, drained on connectivity and a per-entry backoff + +Date: 2026-08-05 + +## Status + +Accepted + +## Context + +A highlight tapped without service is lost. `useHighlights` paints optimistically, the request fails at the network, `settle` reverts the paint, and `apply` resolves `{ status: 'error', reason: 'transient' }`. The intent exists only in an in-memory overlay and a promise chain, so the app does not have to die for the write to disappear — losing the network is enough. + +[ADR 0013](0013-native-highlights-optimistic-layer.md) anticipated the fix and named the cost it expected to pay: _"F1's offline write queue will need exactly that machinery"_ — merging confirmed writes into cached ranges, which drags range-splitting onto the write path. `CONTEXT.md` reserved the boundary from the other side, telling readers not to persist a **Pending Highlight** because "that is F1's offline queue, a different thing." + +This is that queue. Five questions had answers a future reader will find surprising, and each has a cheaper-looking alternative. + +**What a queue entry is.** The obvious model is a log of the operations the user performed, replayed in order. It is what the existing promise chain does and it is the most faithful record of what happened. + +**Whether a connectivity library is required.** "Retry when service returns" reads as a subscription problem, and `@react-native-community/netinfo` is the standard answer. + +**Update (drain implementation, 2026-08-08): it is, and the library is `expo-network`.** See the amended decision below. + +**How the queue is bounded.** Every durable queue is expected to have a size cap, a TTL and an attempt budget. + +**Who wins a conflict.** `Highlight` is exactly `{ version_id, passage_id, color }` — no id, no timestamp — so "which change is newer" cannot be computed from the API. + +**Whether every write goes through it.** A queue that handles only failures means two paint sources with a handoff between them; a queue that handles every write means one. + +## Decision + +**A queue entry is one verse's desired end state, not an operation.** + +`{ [verse]: { local, server } }`, persisted per user + **Highlight Scope**. `local` is where the user wants the verse — a color, or `null` for no highlight. `server` is where the server had it before the user started editing, captured once by the first write to that verse and preserved when a later write overwrites `local`. + +Enqueueing the same verse overwrites rather than appends, so yellow → remove → blue collapses to a single entry and one request. An entry whose two sides agree asks for nothing and is dropped, so applying then removing offline leaves nothing behind — a case the op-log model sends to the server as a DELETE for something it never had. That rule is intrinsic to the entry, which matters because there is no stored copy of server truth to compare against (see the next decision). + +`server` is what a rejected write is reverted to. The alternative — refetch on rejection — does not work in the two cases that need it most: a 401 rejection means the GET will fail the same way, and a rejection after a relaunch has no in-memory server truth left to fall back on. + +On drain, contiguous same-color verses collapse into ranged POSTs through the existing `collapseVerseRuns`, so the wire format is unchanged. + +**Cached Highlights are the paint, and every write goes through the queue.** + +The cache holds what the reader shows, unsent writes included, so a relaunch paints them before anything touches the network. The queue holds what still needs sending. Neither stores raw server truth; `server` on each entry carries the only piece of it anything needs. + +Making the cache optimistic is what allows the **Highlight Overlay** to be deleted rather than persisted. Under the alternative — cache stays server-truth-only, overlay re-derived from the queue at mount — the paint is a merge of two records on every render, and the overlay is a second copy of the queue that happens not to survive a relaunch. + +Deleting the overlay also retires [ADR 0013](0013-native-highlights-optimistic-layer.md)'s in-memory ownership tokens. Their job — stopping a settling write from clobbering paint a newer write put down — is now done by comparing values: a settling write only touches entries still asking for what it sent. The behavior is preserved; the mechanism is not. See that ADR's update for why the value comparison is sufficient where an operation log would have needed the token. + +Two entry fields were considered for this and rejected. A per-write sequence number is what the ownership token becomes once it has to survive `JSON.stringify` — unnecessary, because a stale settle cannot reach an entry whose `local` no longer matches what it sent, and two writes carrying the same color express the same intent, so either may retire it. A `sent` flag ("the server accepted this, keep painting until a GET confirms") would persist across a relaunch and make the drain re-send an already-accepted write; reconciliation stays in memory instead. + +**~~No connectivity library. A drain attempt is its own probe.~~ Reversed — see the update below.** + +There is no separate "is the network up" question to answer: you attempt the write, and success is the answer. What remains is cadence, and three signals cover it — provider mount, `AppState` returning to active, and any successful highlights GET, which is free live proof that the network is up while a reader is on screen. A capped backoff covers the rest. + +NetInfo would close one narrow window: the user is in the app, no reader is mounted, and service returns, so the drain fires up to one backoff interval late instead of instantly. Nothing is lost, only delayed. That is not worth a required native peer dependency and a dev-client rebuild for every consumer, on an SDK that today needs no networking native module at all. A failed attempt while offline is also cheap — with no route to the host the request fails locally and fast, so probing is close to free. + +**Update (drain implementation, 2026-08-08): `expo-network` is added, and it is a `peerDependency` of core.** + +The window above was mis-sized. It is not "no reader is mounted" — it is _every_ moment between two foregrounds, because the successful-GET signal cannot fire on a network that is down, and the drain is where it is precisely so a write outlives the chapter that made it. A user who highlights on a plane and lands still holding the phone gets nothing until the backoff walks up to them, and the same backoff that makes a permanently-stuck entry cheap (one request an hour, per the unbounded decision below) is what makes that wait long. Probing more often to shorten it undoes the reason the backoff is there. The rising edge is the one signal that resolves both at once: back off hard on failure _and_ land instantly when the network returns. + +`expo-network` over NetInfo because it is an Expo-first-party module already in the SDK's dependency universe, so it does not add a second linking story; the SDK is Expo-only by construction. It is a peer, matching every other native module the package needs — consumers install it and rebuild the dev client, the same upgrade step `expo-clipboard` imposed. + +The listener is a **trigger, not a gate**: the drain never asks whether the network is up before attempting, so a wrong or absent connectivity answer costs a delayed attempt, never a skipped one. Only the rising edge fires, `wasConnected` is seeded connected so a redundant event on subscribe cannot duplicate the mount drain, and an `isConnected` of `undefined` is unknown and changes nothing. A refresh-token success was considered as a fourth software-only signal and rejected: it proves the same thing the connectivity edge does, later and less often, and having both means two paths to the same drain with no additional coverage. + +**The queue is unbounded: no size cap, no TTL, no attempt budget.** + +A per-verse entry is a few dozen bytes, so a user who highlighted every verse in the Bible offline (~31,000) would still be under a couple of megabytes in MMKV. A size cap would guard against nothing. Entries are therefore keyed per user + scope rather than held in one global blob, so a tap rewrites one chapter's slice instead of the whole queue. + +A TTL was considered and rejected on product grounds. It only ever fires for a device that has been offline for the length of the TTL — and for a Bible app, long offline stretches in low-connectivity places are a normal use case, not an edge case. Discarding a month of someone's highlights on day 31 is a worse failure than the case a TTL prevents (a long-dormant device waking up and re-adding highlights deleted elsewhere). + +An attempt budget for 5xx entries was specified and then deliberately removed. **The consequence is real and is not a bug:** a payload the server permanently rejects with a 5xx lives in the queue for the life of the install and is removable only by signing out. Per-entry backoff makes it cheap — one request an hour rather than one per drain tick — and entries are independent, so a stuck entry never blocks another. If this is ever revisited, note that existing installs will already hold such entries; a code change alone does not clear them. + +**Local intent wins a conflict; the queue is skipped only when server truth already satisfies it.** + +With no timestamps, any rule is a policy rather than a comparison. Local-wins produces the same outcome the user would get if both devices were online and this tap came last. The alternative — assuming a differing server color means someone else changed it more recently, and discarding the offline intent — silently destroys a tap the user actually made on this device. + +**One exception to "nothing is ever dropped": a definitive auth rejection.** + +A 401/403 that survives a forced token refresh and one retry is the server stating this write will never be accepted. Keeping it would leave the verse painted on this device forever, showing a highlight that exists nowhere else — and with no pending-state surface on the public API, the user could never learn it is not real. A permanent local-only phantom is worse than a silent un-paint, so the entry is dropped. This keeps auth entirely out of the queue's business, matching the tap-time rule that a 401/403 reverts and reports rather than parking. + +The refresh is unconditional (`refreshNow`), not leeway-gated: the pass already ran `ensureFreshToken`, so a refusal means that read was wrong. Only a _second_ refusal drops, and only under a token the refresh genuinely minted. A refresh that is absent, throws, or ends the session the write belongs to drops nothing and takes the ordinary backoff — the drain must not be what decides a departed user's write was refused for good. + +Note that a dead session does not reach this path: `refreshToken` clears auth state on a revoked refresh token. Both it and `signOut` route through `clearAuthState`, which purges the queue alongside the highlights cache and the grant cache. The purge takes **every** user's entries, not just the departing one's — only a departure can leave an entry under an id that is not current, and one signed-in user at a time means anything else is already abandoned. The queue's distinct MMKV prefix means this is an explicit call rather than something inherited from a prefix match. + +Every purge in that routine is best-effort, under [ADR 0014](0014-cached-grant-is-a-hint.md)'s rule that a storage failure must not break sign-out — so `clearHighlightQueue` swallows its own failures, as `clearGrantedPermissions` already did. The purges run ahead of the token clearing, and a throw from one of them would leave a user who asked to sign out still signed in; a surviving entry, belonging to someone who has left and reachable by no session that could send it, is the cheaper loss. Anything added to `clearAuthState` owes the same guard. + +**MMKV is written queue first, cache second.** + +The two writes are not atomic. Dying between them leaves a write that is owed but not painted, which the next mount repairs by re-applying the queue over the cache. The other order leaves one painted that nothing will ever send — a phantom highlight with no route back to correctness. + +## Consequences + +- **`HighlightWriteOutcome` gains `{ status: 'queued'; verses }`** — painted, persisted, not yet landed. `ok` keeps meaning "landed server-side", which is what existing consumers already read it as. Shipped as a minor: no existing status changes meaning and every existing branch behaves identically, so only an exhaustive `switch` with no default is affected. The changeset should say so loudly. +- **`queued` repeats, and reports no first-park-vs-repeat distinction.** It is point-in-time, describing the write the caller just made rather than the verse's queue state, so every tap on a verse that is still parked resolves `queued` again. Rejected on two grounds rather than one: a batch can mix a parked verse with fresh ones, so an honest signal would have to be a per-verse split of `verses` rather than a flag; and a verse parked yellow then tapped green is a new write, which "repeat" would describe wrongly — suppressing a message on the tap that most deserves one. It would also be the queue's first leak into a public surface that is otherwise entirely internal, for a need no consumer has raised. A caller showing "saved offline" once holds that in its own state. +- **A batch that reached nobody skips its reconciling GET.** Every settled write is followed by one GET; a write that could not reach the server changed nothing server-side and has nothing to reconcile, so spending a request on a network that just refused one only produces a fetch error for what was a successful park. +- **A refusal outranks a park in a mixed batch.** If part of a batch was refused and part was queued, the outcome is the `error`, because `useHighlightPermissionFlow` branches on `reason` and that branch must still fire. The queued verses stay queued regardless — the outcome reports the actionable failure, not everything that happened. +- **The queue is provider-owned, not hook-owned.** A write for JHN 3 must land after the user has navigated to ROM 8, and draining needs a token, which lives in `AuthProvider`. The drain therefore needs a way to tell mounted readers an entry was dropped. That shipped as a drop notification (`onWritesDropped` / `dropRejectedWrites`), not a subscribable store: a successful drain changes nothing a reader can see — the cache already holds that color — and queue-first writes store an entry for every tap, so a general change feed would fire on writes the listening hook is itself mid-flight on. +- **`expo-network` is a new required peer dependency of core.** Consumers upgrading into this version install it and rebuild the dev client; it is autolinked, so a JS-only reload leaves the module missing. +- **The drain defers to verses a mounted `useHighlights` is currently sending.** Queue-first writes mean the queue is non-empty during every in-flight write, so an entry alone no longer means "nothing else is handling this". An in-memory refcounted claim set (`claims.ts`) closes the gap. The hook never defers to the drain — the user's newest intent goes out immediately. +- **The write path signals the drain directly** (`drain-signals.ts`), at the two moments only it knows: a request reached the server, and a write parked. This is not a queue subscription, which would wake the drain on every tap. +- **Backoff is in memory, per user + scope + verse.** A relaunch is itself a drain trigger, so persisting the wait would only deny a fresh start the attempt it is entitled to. It resets on success and the record is dropped with the entry. +- **A trigger retires the pending wait; the timer does not.** The wait is a guess about a network nobody had asked, and a trigger — connectivity's rising edge above all — is the answer arriving. Filtering triggers through the same wait would leave a backed-off write sitting out up to an hour of restored service, which is the whole reason the connectivity peer was taken on. Failure counts survive the trigger, so the decay widens from where it was rather than starting over on every foreground. +- **Offline with a missing `highlights` grant fails cleanly rather than queueing.** The data-exchange mint fails before any browser opens, the **Pending Highlight** is discarded, nothing paints and nothing queues. Queueing a write the SDK has no reason to believe is permitted only defers the un-paint to the drain, where it would be unexplained. +- **The ordinary offline path works because of [ADR 0014](0014-cached-grant-is-a-hint.md).** The pre-flight reads the _cached_ grant, which is a hint, so a granted user offline sails through the permission flow to a write that fails at the network and queues. If that read ever became authoritative, offline highlighting would stop working for everyone. diff --git a/packages/core/package.json b/packages/core/package.json index d404582f..5634aea2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -85,6 +85,7 @@ "peerDependencies": { "expo": ">=56.0.0 <57.0.0", "expo-crypto": ">=56.0.0 <57.0.0", + "expo-network": ">=56.0.0 <57.0.0", "expo-secure-store": ">=56.0.0 <57.0.0", "expo-web-browser": ">=56.0.0 <57.0.0", "react": ">=19.0.0 <20.0.0", diff --git a/packages/core/src/__tests__/youversion-provider.test.tsx b/packages/core/src/__tests__/youversion-provider.test.tsx index 7f36dadc..8d968984 100644 --- a/packages/core/src/__tests__/youversion-provider.test.tsx +++ b/packages/core/src/__tests__/youversion-provider.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react-native' import { Text } from 'react-native' import AuthProvider from '../auth/auth-provider' +import HighlightQueueDrainHost from '../highlights/highlight-queue-drain-host' import { getOrSetInstallationId } from '../installation-id' import { useYouVersion } from '../use-youversion' import YouVersionProvider from '../youversion-provider' @@ -14,8 +15,14 @@ jest.mock('../auth/auth-provider', () => ({ default: jest.fn(({ children }: { children: React.ReactNode }) => children), })) +jest.mock('../highlights/highlight-queue-drain-host', () => ({ + __esModule: true, + default: jest.fn(() => null), +})) + const mockGetOrSetInstallationId = getOrSetInstallationId as jest.Mock const MockAuthProvider = AuthProvider as unknown as jest.Mock +const MockDrainHost = HighlightQueueDrainHost as unknown as jest.Mock beforeEach(() => { jest.clearAllMocks() @@ -70,5 +77,20 @@ describe('YouVersionProvider', () => { expect(screen.getByTestId('content')).toBeTruthy() expect(MockAuthProvider).not.toHaveBeenCalled() + // No auth means no user, so there can be no queue to drain. + expect(MockDrainHost).not.toHaveBeenCalled() + }) + + it('mounts the highlight queue drain alongside AuthProvider', async () => { + mockGetOrSetInstallationId.mockResolvedValue('inst-1') + + render( + + Content + , + ) + + await waitFor(() => expect(screen.getByTestId('content')).toBeTruthy()) + expect(MockDrainHost).toHaveBeenCalled() }) }) diff --git a/packages/core/src/auth/__tests__/auth-provider.test.tsx b/packages/core/src/auth/__tests__/auth-provider.test.tsx index 729e5ce1..6fd52729 100644 --- a/packages/core/src/auth/__tests__/auth-provider.test.tsx +++ b/packages/core/src/auth/__tests__/auth-provider.test.tsx @@ -1,6 +1,9 @@ import { act, fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native' import { useEffect, useState } from 'react' import { AppState, Pressable, Text, View } from 'react-native' +import { getCachedHighlights, setCachedHighlights } from '../../highlights/cache' +import type { HighlightScope } from '../../highlights/constants' +import { enqueueWrites, listQueuedScopes } from '../../highlights/queue' import { getOrSetInstallationId } from '../../installation-id' import type { AuthContextValue } from '../auth-context' import AuthProvider from '../auth-provider' @@ -14,6 +17,7 @@ import type { AuthConfig, AuthPermission } from '../types' import { useYVAuth } from '../use-yv-auth' const mockMmkv = new Map() +let mockMmkvThrows = false jest.mock('../../storage/mmkv-storage', () => ({ mmkvStorage: { @@ -21,8 +25,14 @@ jest.mock('../../storage/mmkv-storage', () => ({ mockMmkv.set(k, v) }), getString: jest.fn((k: string) => mockMmkv.get(k)), - remove: jest.fn((k: string) => mockMmkv.delete(k)), - getAllKeys: jest.fn(() => Array.from(mockMmkv.keys())), + remove: jest.fn((k: string) => { + if (mockMmkvThrows) throw new Error('mmkv unavailable') + return mockMmkv.delete(k) + }), + getAllKeys: jest.fn(() => { + if (mockMmkvThrows) throw new Error('mmkv unavailable') + return Array.from(mockMmkv.keys()) + }), }, })) @@ -92,6 +102,14 @@ const validTokens = { const adaUserInfo = { id: 'u1', name: 'Ada', email: undefined, avatarUrl: undefined } +const JHN3: HighlightScope = { versionId: 111, book: 'JHN', chapter: '3' } +const GEN1: HighlightScope = { versionId: 111, book: 'GEN', chapter: '1' } +const PSA23: HighlightScope = { versionId: 111, book: 'PSA', chapter: '23' } + +function parkWrite(userId: string, scope: HighlightScope): void { + enqueueWrites({ userId, scope, verses: [16], color: 'fffe00', currentColors: {} }) +} + /** * Latest context value, so a test can drive `requestPermissions` with its own * permission list and hold the promise — the rendered button is fixed to @@ -171,6 +189,7 @@ function fireAppStateChange(state: string) { beforeEach(() => { mockMmkv.clear() + mockMmkvThrows = false latestAuth = null jest.clearAllMocks() mockAppStateAddEventListener.mockImplementation(() => ({ remove: jest.fn() })) @@ -194,6 +213,32 @@ describe('AuthProvider — mount', () => { expect(mockRefreshTokens).not.toHaveBeenCalled() }) + // A purge that could not take leaves the record readable, so the next mount + // seeds the departed user. The bootstrap clear is what bounds that — no write + // into a store refusing writes can — so pin it. + it('drops a cached userInfo the store still refuses to remove', async () => { + mockMmkv.set(MMKV_AUTH_KEYS.cachedUserInfo, JSON.stringify(adaUserInfo)) + mockMmkv.set( + MMKV_AUTH_KEYS.grantedPermissions, + JSON.stringify({ userId: 'u1', permissions: ['highlights'] }), + ) + mockLoadTokens.mockResolvedValue(noStoredTokens) + mockMmkvThrows = true + + render( + + + , + ) + + await waitFor(() => expect(getText('isLoading')).toBe('false')) + expect(getText('userInfo')).toBe('null') + expect(getText('grantedPermissions')).toBe('null') + expect(getText('isAuthenticated')).toBe('false') + // The record itself survives: the accepted residual, not the exposure. + expect(mockMmkv.has(MMKV_AUTH_KEYS.cachedUserInfo)).toBe(true) + }) + it('hydrates state from stored tokens and skips refresh when not near expiry', async () => { mockMmkv.set(MMKV_AUTH_KEYS.cachedUserInfo, JSON.stringify(adaUserInfo)) mockLoadTokens.mockResolvedValue({ @@ -405,13 +450,10 @@ describe('AuthProvider — signIn', () => { }) describe('AuthProvider — signOut', () => { - it('clears tokens, resets in-memory state, and removes cached userInfo and highlights', async () => { - const highlightsKey = 'yvp.highlights.user-1.111.JHN.3' + it('clears tokens, resets in-memory state, and removes cached userInfo, highlights and queued writes', async () => { mockMmkv.set(MMKV_AUTH_KEYS.cachedUserInfo, JSON.stringify({ id: 'u1' })) - mockMmkv.set( - highlightsKey, - JSON.stringify([{ version_id: 111, passage_id: 'JHN.3.16', color: 'fffe00' }]), - ) + setCachedHighlights('u1', JHN3, [{ version_id: 111, passage_id: 'JHN.3.16', color: 'fffe00' }]) + parkWrite('u1', JHN3) mockLoadTokens.mockResolvedValue({ accessToken: 'a', refreshToken: 'r', @@ -436,7 +478,65 @@ describe('AuthProvider — signOut', () => { expiryDate: null, }) expect(mockMmkv.has(MMKV_AUTH_KEYS.cachedUserInfo)).toBe(false) - expect(mockMmkv.has(highlightsKey)).toBe(false) + expect(getCachedHighlights('u1', JHN3)).toBeNull() + expect(listQueuedScopes('u1')).toEqual([]) + }) + + // Every user's entries, like the highlights cache: one user is signed in at a + // time, so anything under another id was already left by a departure. + it('leaves no queued write behind, for any chapter or any user', async () => { + parkWrite('u1', JHN3) + parkWrite('u1', GEN1) + parkWrite('u2', PSA23) + mockLoadTokens.mockResolvedValue({ + accessToken: 'a', + refreshToken: 'r', + expiryDate: new Date(Date.now() + 60 * 60 * 1000), + }) + + render( + + + , + ) + await waitFor(() => expect(getText('isAuthenticated')).toBe('true')) + + fireEvent.press(screen.getByTestId('signOut')) + + await waitFor(() => expect(getText('isAuthenticated')).toBe('false')) + expect(listQueuedScopes('u1')).toEqual([]) + expect(listQueuedScopes('u2')).toEqual([]) + }) + + // The purges run before the tokens are cleared. A store that throws must cost a + // surviving cache entry, never a user who asked to sign out and stayed in. + it('still signs out when the cache purges cannot reach the store', async () => { + mockMmkv.set(MMKV_AUTH_KEYS.cachedUserInfo, JSON.stringify({ id: 'u1' })) + parkWrite('u1', JHN3) + mockLoadTokens.mockResolvedValue({ + accessToken: 'a', + refreshToken: 'r', + expiryDate: new Date(Date.now() + 60 * 60 * 1000), + }) + + render( + + + , + ) + await waitFor(() => expect(getText('isAuthenticated')).toBe('true')) + + mockMmkvThrows = true + fireEvent.press(screen.getByTestId('signOut')) + + await waitFor(() => expect(getText('isAuthenticated')).toBe('false')) + expect(getText('accessToken')).toBe('null') + expect(getText('userInfo')).toBe('null') + expect(mockSaveTokens).toHaveBeenCalledWith({ + accessToken: null, + refreshToken: null, + expiryDate: null, + }) }) }) @@ -466,11 +566,10 @@ describe('AuthProvider — refresh failure policy', () => { }) it('clears tokens when the refresh token is revoked (TokenEndpointError 401)', async () => { - const highlightsKey = 'yvp.highlights.user-1.111.JHN.3' - mockMmkv.set( - highlightsKey, - JSON.stringify([{ version_id: 111, passage_id: 'JHN.3.16', color: 'fffe00' }]), - ) + setCachedHighlights('user-1', JHN3, [ + { version_id: 111, passage_id: 'JHN.3.16', color: 'fffe00' }, + ]) + parkWrite('user-1', JHN3) mockLoadTokens.mockResolvedValue(expiredStored) mockRefreshTokens.mockRejectedValue(new TokenEndpointError(401, 'invalid_grant')) @@ -485,7 +584,8 @@ describe('AuthProvider — refresh failure policy', () => { expect(getText('accessToken')).toBe('null') expect(getText('error')).toMatch(/401/) expect(mockSaveTokens).toHaveBeenCalledWith(clearedTokens) - expect(mockMmkv.has(highlightsKey)).toBe(false) + expect(getCachedHighlights('user-1', JHN3)).toBeNull() + expect(listQueuedScopes('user-1')).toEqual([]) }) }) diff --git a/packages/core/src/auth/__tests__/token-storage.test.ts b/packages/core/src/auth/__tests__/token-storage.test.ts index 3a83ae60..70dd648d 100644 --- a/packages/core/src/auth/__tests__/token-storage.test.ts +++ b/packages/core/src/auth/__tests__/token-storage.test.ts @@ -70,6 +70,30 @@ describe('saveTokens', () => { expect(mmkvStorage.set).not.toHaveBeenCalled() }) + // Sign-out clears the session before it awaits this, so a rejection here would + // land after the fact — on a store that refuses writes, with nothing to undo. + it('resolves when the store refuses to remove the expiry', async () => { + jest.mocked(mmkvStorage.remove).mockImplementationOnce(() => { + throw new Error('mmkv is read-only') + }) + + await expect( + saveTokens({ accessToken: null, refreshToken: null, expiryDate: null }), + ).resolves.toBeUndefined() + expect(secureStorage.remove).toHaveBeenCalledWith(SECURE_STORAGE_KEYS.accessToken) + expect(secureStorage.remove).toHaveBeenCalledWith(SECURE_STORAGE_KEYS.refreshToken) + }) + + it('resolves when the store refuses to write the expiry', async () => { + jest.mocked(mmkvStorage.set).mockImplementationOnce(() => { + throw new Error('mmkv is read-only') + }) + + await expect(saveTokens(fullTokens)).resolves.toBeUndefined() + expect(secureStorage.set).toHaveBeenCalledWith(SECURE_STORAGE_KEYS.accessToken, 'access') + expect(secureStorage.set).toHaveBeenCalledWith(SECURE_STORAGE_KEYS.refreshToken, 'refresh') + }) + it('mixes set and remove when some tokens are null and others are not', async () => { await saveTokens({ accessToken: 'a', diff --git a/packages/core/src/auth/auth-provider.tsx b/packages/core/src/auth/auth-provider.tsx index d401d3fa..8bde54ca 100644 --- a/packages/core/src/auth/auth-provider.tsx +++ b/packages/core/src/auth/auth-provider.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { AppState, type AppStateStatus } from 'react-native' import { z } from 'zod' import { toMessage } from '../error-message' -import { clearHighlightsCache } from '../highlights' +import { clearHighlightQueue, clearHighlightsCache } from '../highlights' import { getOrSetInstallationId } from '../installation-id' import { mmkvStorage } from '../storage/mmkv-storage' import { AuthContext, type AccessTokenResult, type AuthContextValue } from './auth-context' @@ -34,6 +34,9 @@ type AuthProviderProps = { export default function AuthProvider({ config, appKey, apiHost, children }: AuthProviderProps) { const [accessToken, setAccessToken] = useState(null) + // Seeding this synchronously is load-bearing for useHighlights: it paints from + // cache in its own initializer, keyed by `userInfo.id`. Seed it later and the + // reader loses its instant paint on a cold start. const [userInfo, setUserInfo] = useState(() => loadCachedUserInfo()) // Seeded synchronously so hasPermission answers correctly on the first render // after a cold start — the same pattern (and load-bearing coupling) as the @@ -120,9 +123,19 @@ export default function AuthProvider({ config, appKey, apiHost, children }: Auth }, []) const clearAuthState = useCallback(async () => { - mmkvStorage.remove(MMKV_AUTH_KEYS.cachedUserInfo) + // The cache purges are best-effort; none may abort the token clearing below. + // The three helpers swallow their own failures, this removal cannot. + // + // A survived record reseeds `userInfo` on the next mount; the bootstrap + // clear is what bounds that, not this catch (ADR 0014's amendment). + try { + mmkvStorage.remove(MMKV_AUTH_KEYS.cachedUserInfo) + } catch { + // Cached user info survived; the tokens still go. + } invalidatePermissions() clearHighlightsCache() + clearHighlightQueue() expiryRef.current = null refreshTokenRef.current = null accessTokenRef.current = null diff --git a/packages/core/src/auth/granted-permissions-cache.ts b/packages/core/src/auth/granted-permissions-cache.ts index c4b45a35..de9e2bec 100644 --- a/packages/core/src/auth/granted-permissions-cache.ts +++ b/packages/core/src/auth/granted-permissions-cache.ts @@ -81,6 +81,13 @@ export function mergeGrantedPermissions( * the server is the enforcement point, and a survived entry costs at most a * skipped prompt and a request the server denies. See * `docs/adr/0014-cached-grant-is-a-hint.md` before hardening this. + * + * The `catch` is not a detector, and reading it as one overstates what this can + * promise: `MMKV.remove` returns `false` rather than throwing on a read-only + * instance, so the entry can survive with nothing caught. It is here only so a + * throw cannot abort the caller mid-sign-out. Nothing can be layered on top + * either — an overwrite, a tombstone, or a retry is another *write* into the + * store that just refused one (ADR 0014's 2026-08-11 amendment). */ export function clearGrantedPermissions(): void { try { diff --git a/packages/core/src/auth/token-storage.ts b/packages/core/src/auth/token-storage.ts index a2ba6f1d..d2a32418 100644 --- a/packages/core/src/auth/token-storage.ts +++ b/packages/core/src/auth/token-storage.ts @@ -15,10 +15,22 @@ export async function saveTokens(tokens: StoredTokens): Promise { writeSecureValue(SECURE_STORAGE_KEYS.accessToken, tokens.accessToken), writeSecureValue(SECURE_STORAGE_KEYS.refreshToken, tokens.refreshToken), ]) - if (tokens.expiryDate) { - mmkvStorage.set(MMKV_AUTH_KEYS.expiryDateISO, tokens.expiryDate.toISOString()) - } else { - mmkvStorage.remove(MMKV_AUTH_KEYS.expiryDateISO) + writeExpiry(tokens.expiryDate) +} + +// The expiry is a cache over the tokens, which are the record, so a store that +// refuses it cannot fail the save — sign-out awaits this after it has already +// cleared the session. A lost expiry costs one refresh: `refreshToken` reads a +// missing one as already stale. +function writeExpiry(expiryDate: Date | null): void { + try { + if (expiryDate) { + mmkvStorage.set(MMKV_AUTH_KEYS.expiryDateISO, expiryDate.toISOString()) + } else { + mmkvStorage.remove(MMKV_AUTH_KEYS.expiryDateISO) + } + } catch { + // Cached expiry lost; the tokens are what the session runs on. } } diff --git a/packages/core/src/highlights/__tests__/backoff.test.ts b/packages/core/src/highlights/__tests__/backoff.test.ts new file mode 100644 index 00000000..96ea4b9d --- /dev/null +++ b/packages/core/src/highlights/__tests__/backoff.test.ts @@ -0,0 +1,31 @@ +import { nextBackoffDelay } from '../backoff' + +// The shape is pinned; the intervals are not. Backoff numbers are tuning, and +// asserting them makes retuning a test-rewriting exercise. +describe('nextBackoffDelay', () => { + const cap = nextBackoffDelay(Number.MAX_SAFE_INTEGER) + + it('waits before the first attempt, so a parked write is not chased instantly', () => { + expect(nextBackoffDelay(0)).toBeGreaterThan(0) + }) + + it('at least doubles with each failure — exponential, not a linear ramp', () => { + for (let failures = 0; nextBackoffDelay(failures) < cap; failures++) { + const current = nextBackoffDelay(failures) + expect(nextBackoffDelay(failures + 1)).toBeGreaterThanOrEqual(Math.min(2 * current, cap)) + } + }) + + it('reaches the cap and stays there, so an entry the server keeps refusing stays cheap', () => { + expect(Number.isFinite(cap)).toBe(true) + expect(nextBackoffDelay(1000)).toBe(cap) + for (let failures = 0; failures <= 100; failures++) { + expect(nextBackoffDelay(failures)).toBeLessThanOrEqual(cap) + } + }) + + it('treats a nonsense failure count as a first attempt rather than NaN', () => { + expect(nextBackoffDelay(-5)).toBe(nextBackoffDelay(0)) + expect(nextBackoffDelay(NaN)).toBe(nextBackoffDelay(0)) + }) +}) diff --git a/packages/core/src/highlights/__tests__/exports.test.ts b/packages/core/src/highlights/__tests__/exports.test.ts index 17ab675c..b263d425 100644 --- a/packages/core/src/highlights/__tests__/exports.test.ts +++ b/packages/core/src/highlights/__tests__/exports.test.ts @@ -29,6 +29,7 @@ describe('package exports', () => { expect(names).not.toContain('getCachedHighlights') expect(names).not.toContain('setCachedHighlights') expect(names).not.toContain('clearHighlightsCache') + expect(names).not.toContain('clearHighlightQueue') expect(names).not.toContain('ok') expect(names).not.toContain('err') }) diff --git a/packages/core/src/highlights/__tests__/has-queued-highlight-writes.test.ts b/packages/core/src/highlights/__tests__/has-queued-highlight-writes.test.ts new file mode 100644 index 00000000..f30a8651 --- /dev/null +++ b/packages/core/src/highlights/__tests__/has-queued-highlight-writes.test.ts @@ -0,0 +1,99 @@ +/** + * The one question sign-out asks the queue: does signing this user out cost them + * work the server never took? + */ +import { + highlightQueueKey, + highlightsCacheKey, + MMKV_HIGHLIGHT_QUEUE_KEY_PREFIX, + type HighlightScope, +} from '../constants' +import { clearHighlightQueue, enqueueWrites, hasQueuedHighlightWrites } from '../queue' + +const mockMmkv = new Map() +let mockGetAllKeysThrows = false + +jest.mock('../../storage/mmkv-storage', () => ({ + mmkvStorage: { + set: jest.fn((k: string, v: string) => { + mockMmkv.set(k, v) + }), + getString: jest.fn((k: string) => mockMmkv.get(k)), + remove: jest.fn((k: string) => { + mockMmkv.delete(k) + }), + getAllKeys: jest.fn(() => { + if (mockGetAllKeysThrows) throw new Error('mmkv unavailable') + return Array.from(mockMmkv.keys()) + }), + has: jest.fn((k: string) => mockMmkv.has(k)), + }, +})) + +const scope: HighlightScope = { versionId: 111, book: 'JHN', chapter: '3' } +const YELLOW = 'ffd43b' + +beforeEach(() => { + mockMmkv.clear() + mockGetAllKeysThrows = false +}) + +describe('hasQueuedHighlightWrites', () => { + it('is false with nothing queued', () => { + expect(hasQueuedHighlightWrites('user-1')).toBe(false) + }) + + it('is true once a write is parked, and false again once it settles', () => { + enqueueWrites({ userId: 'user-1', scope, verses: [1], color: YELLOW, currentColors: {} }) + expect(hasQueuedHighlightWrites('user-1')).toBe(true) + + clearHighlightQueue() + expect(hasQueuedHighlightWrites('user-1')).toBe(false) + }) + + it('answers per user', () => { + enqueueWrites({ userId: 'user-1', scope, verses: [1], color: YELLOW, currentColors: {} }) + + expect(hasQueuedHighlightWrites('user-2')).toBe(false) + }) + + it('is false for a signed-out user, whoever else has writes parked', () => { + enqueueWrites({ userId: 'user-1', scope, verses: [1], color: YELLOW, currentColors: {} }) + + expect(hasQueuedHighlightWrites(null)).toBe(false) + }) + + it('ignores the highlights cache, which is not unsent work', () => { + mockMmkv.set(highlightsCacheKey('user-1', scope), JSON.stringify({ 1: YELLOW })) + + expect(hasQueuedHighlightWrites('user-1')).toBe(false) + }) + + // A scope suffix `listQueuedScopes` would reject still counts. The drain cannot + // send that entry, which makes it more certain to be lost, not less. + it('counts an entry the drain could never address', () => { + mockMmkv.set(`${MMKV_HIGHLIGHT_QUEUE_KEY_PREFIX}user-1.garbage`, JSON.stringify({ 1: YELLOW })) + + expect(hasQueuedHighlightWrites('user-1')).toBe(true) + }) + + // Sign-out must stay reachable: an unreadable store answers "nothing to lose" + // rather than throwing out of the gesture that raises the prompt. + it('is false when the store cannot be read', () => { + enqueueWrites({ userId: 'user-1', scope, verses: [1], color: YELLOW, currentColors: {} }) + expect(hasQueuedHighlightWrites('user-1')).toBe(true) + + mockGetAllKeysThrows = true + expect(hasQueuedHighlightWrites('user-1')).toBe(false) + }) + + it('does not confuse one user id for another that shares its prefix', () => { + mockMmkv.set( + highlightQueueKey('user-10', scope), + JSON.stringify({ 1: { local: YELLOW, server: null } }), + ) + + expect(hasQueuedHighlightWrites('user-1')).toBe(false) + expect(hasQueuedHighlightWrites('user-10')).toBe(true) + }) +}) diff --git a/packages/core/src/highlights/__tests__/highlight-queue-drain-host.test.tsx b/packages/core/src/highlights/__tests__/highlight-queue-drain-host.test.tsx new file mode 100644 index 00000000..3a94670f --- /dev/null +++ b/packages/core/src/highlights/__tests__/highlight-queue-drain-host.test.tsx @@ -0,0 +1,179 @@ +/** + * The host owns nothing but wiring: which moments wake the drain. The drain + * itself is faked here — its behaviour is covered in `highlight-queue-drain.test`. + */ + +import { render, screen } from '@testing-library/react-native' +import { addNetworkStateListener, type NetworkState } from 'expo-network' +import { act } from 'react' +import { AppState, type AppStateStatus } from 'react-native' + +import { useYVAuthOptional } from '../../auth' +import { useYouVersion } from '../../use-youversion' +import { notifyDrain } from '../drain-signals' +import { startHighlightQueueDrain } from '../drain' +import HighlightQueueDrainHost from '../highlight-queue-drain-host' + +jest.mock('../../auth', () => ({ useYVAuthOptional: jest.fn() })) +jest.mock('../../use-youversion', () => ({ useYouVersion: jest.fn() })) +jest.mock('../api', () => ({ createHighlightsApi: jest.fn(() => ({})) })) +jest.mock('../drain', () => ({ startHighlightQueueDrain: jest.fn() })) +jest.mock('expo-network', () => ({ addNetworkStateListener: jest.fn() })) + +const mockUseAuth = useYVAuthOptional as jest.Mock +const mockUseYouVersion = useYouVersion as jest.Mock +const mockStartDrain = startHighlightQueueDrain as jest.Mock +const mockAddNetworkStateListener = addNetworkStateListener as jest.Mock + +const drain = { + drainNow: jest.fn(), + noteParkedWrite: jest.fn(), + stop: jest.fn(), +} + +let networkListener: ((state: NetworkState) => void) | null = null +let appStateListener: ((state: AppStateStatus) => void) | null = null + +function emitNetwork(isConnected: boolean | undefined) { + act(() => networkListener?.({ isConnected } as NetworkState)) +} + +beforeEach(() => { + jest.clearAllMocks() + networkListener = null + appStateListener = null + + mockUseYouVersion.mockReturnValue({ + appKey: 'appkey', + apiHost: 'api.youversion.com', + installationId: 'inst-1', + }) + mockUseAuth.mockReturnValue({ + userInfo: { id: 'user-1' }, + accessToken: 'token-1', + ensureFreshToken: jest.fn(), + refreshNow: jest.fn(), + }) + mockStartDrain.mockReturnValue(drain) + mockAddNetworkStateListener.mockImplementation((listener) => { + networkListener = listener + return { remove: jest.fn() } + }) + jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, listener) => { + appStateListener = listener as (state: AppStateStatus) => void + return { remove: jest.fn() } + }) +}) + +describe('HighlightQueueDrainHost', () => { + it('drains on mount and renders nothing', () => { + render() + + expect(drain.drainNow).toHaveBeenCalledTimes(1) + expect(screen.toJSON()).toBeNull() + }) + + it('reads auth through a ref that is current before the drain starts', () => { + render() + + const [{ getAuth }] = mockStartDrain.mock.calls[0] + expect(getAuth()).toEqual({ + userId: 'user-1', + accessToken: 'token-1', + ensureFreshToken: expect.any(Function), + refreshNow: expect.any(Function), + }) + }) + + it('reports no user when auth is not configured', () => { + mockUseAuth.mockReturnValue(null) + render() + + const [{ getAuth }] = mockStartDrain.mock.calls[0] + expect(getAuth()).toEqual({ + userId: null, + accessToken: null, + ensureFreshToken: null, + refreshNow: null, + }) + }) + + it('drains again when the token changes — sign-in and refresh both land here', () => { + const { rerender } = render() + drain.drainNow.mockClear() + + mockUseAuth.mockReturnValue({ + userInfo: { id: 'user-1' }, + accessToken: 'token-2', + ensureFreshToken: jest.fn(), + }) + rerender() + + expect(drain.drainNow).toHaveBeenCalledTimes(1) + }) + + it('drains when the app returns to active, and not on the way out', () => { + render() + drain.drainNow.mockClear() + + act(() => appStateListener?.('background')) + expect(drain.drainNow).not.toHaveBeenCalled() + + act(() => appStateListener?.('active')) + expect(drain.drainNow).toHaveBeenCalledTimes(1) + }) + + it('drains on a reached service, and only starts the clock on a parked write', () => { + render() + drain.drainNow.mockClear() + + act(() => notifyDrain('service-reached')) + expect(drain.drainNow).toHaveBeenCalledTimes(1) + expect(drain.noteParkedWrite).not.toHaveBeenCalled() + + act(() => notifyDrain('write-parked')) + expect(drain.drainNow).toHaveBeenCalledTimes(1) + expect(drain.noteParkedWrite).toHaveBeenCalledTimes(1) + }) + + it('drains on the rising edge of connectivity only', () => { + render() + drain.drainNow.mockClear() + + // Seeded connected, so a redundant event on subscribe does not re-drain. + emitNetwork(true) + expect(drain.drainNow).not.toHaveBeenCalled() + + emitNetwork(false) + emitNetwork(true) + expect(drain.drainNow).toHaveBeenCalledTimes(1) + + emitNetwork(true) + expect(drain.drainNow).toHaveBeenCalledTimes(1) + }) + + it('ignores an unknown connectivity state rather than treating it as a change', () => { + render() + drain.drainNow.mockClear() + + emitNetwork(false) + emitNetwork(undefined) + expect(drain.drainNow).not.toHaveBeenCalled() + + emitNetwork(true) + expect(drain.drainNow).toHaveBeenCalledTimes(1) + }) + + it('stops the drain and its listeners on unmount', () => { + const { unmount } = render() + unmount() + + expect(drain.stop).toHaveBeenCalledTimes(1) + + act(() => notifyDrain('service-reached')) + act(() => appStateListener?.('active')) + emitNetwork(false) + emitNetwork(true) + expect(drain.drainNow).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/core/src/highlights/__tests__/highlight-queue-drain.test.ts b/packages/core/src/highlights/__tests__/highlight-queue-drain.test.ts new file mode 100644 index 00000000..b58f6ac6 --- /dev/null +++ b/packages/core/src/highlights/__tests__/highlight-queue-drain.test.ts @@ -0,0 +1,857 @@ +/** + * Vertical seam for the Highlight Write Queue drain: real queue, real cache, + * real paint projection. Only MMKV and the API client are faked. + * + * No test pins a backoff interval. The growth test measures the gaps the drain + * actually waits and asserts the shape of the sequence, so retuning the + * constants cannot red this file. + */ + +import { claimWrites } from '../claims' +import { getCachedHighlights, setCachedHighlights } from '../cache' +import { startHighlightQueueDrain, type DrainAuth } from '../drain' +import { enqueueWrites, getQueuedWrites } from '../queue' +import { err, ok } from '../../result' +import type { HighlightsApi, HighlightsApiError } from '../api' +import type { HighlightScope } from '../constants' + +const mockMmkv = new Map() + +jest.mock('../../storage/mmkv-storage', () => ({ + mmkvStorage: { + set: jest.fn((k: string, v: string) => { + mockMmkv.set(k, v) + }), + getString: jest.fn((k: string) => mockMmkv.get(k)), + remove: jest.fn((k: string) => { + mockMmkv.delete(k) + }), + getAllKeys: jest.fn(() => Array.from(mockMmkv.keys())), + has: jest.fn((k: string) => mockMmkv.has(k)), + }, +})) + +const USER = 'user-1' +const JHN3: HighlightScope = { versionId: 111, book: 'JHN', chapter: '3' } +const JHN4: HighlightScope = { versionId: 111, book: 'JHN', chapter: '4' } +const YELLOW = 'ffd43b' +const GREEN = '51cf66' + +/** Never reached the server. */ +const UNREACHABLE: HighlightsApiError = { kind: 'transient', message: 'offline' } +/** Reached it and was refused on auth grounds — the one droppable failure. */ +const REFUSED: HighlightsApiError = { kind: 'auth', status: 401, message: 'no' } +const FORBIDDEN: HighlightsApiError = { kind: 'auth', status: 403, message: 'nope' } +/** Reached it and failed for any other reason. Retried forever, never dropped. */ +const SERVER_ERROR: HighlightsApiError = { kind: 'transient', status: 500, message: 'boom' } +const UNPROCESSABLE: HighlightsApiError = { kind: 'transient', status: 422, message: 'bad' } + +type Call = { kind: 'create' | 'delete'; passageId: string; color?: string } + +type CreateResult = Awaited> + +type CreateData = Parameters[1] + +/** `onCreate` chooses the POST's answer per call; the attempt is recorded either way. */ +function createApi({ + onCreate, + ...overrides +}: Partial & { + onCreate?: (data: CreateData) => CreateResult | Promise +} = {}) { + const calls: Call[] = [] + const api: HighlightsApi = { + getHighlights: jest.fn(async () => ok({ data: [] }) as never), + createHighlight: jest.fn(async (_token, data) => { + calls.push({ kind: 'create', passageId: data.passage_id, color: data.color }) + return onCreate ? await onCreate(data) : (ok({} as never) as CreateResult) + }), + deleteHighlight: jest.fn(async (_token, passageId) => { + calls.push({ kind: 'delete', passageId }) + return ok(undefined) + }), + ...overrides, + } + return { api, calls } +} + +function signedIn(overrides: Partial = {}): () => DrainAuth { + const auth: DrainAuth = { + userId: USER, + accessToken: 'token-1', + ensureFreshToken: null, + refreshNow: null, + ...overrides, + } + return () => auth +} + +function queueApply(scope: HighlightScope, verses: number[], color: string | null) { + enqueueWrites({ userId: USER, scope, verses, color, currentColors: {} }) +} + +/** Runs everything the drain does synchronously-ish, without waiting on timers. */ +async function settle() { + for (let tick = 0; tick < 12; tick++) { + await Promise.resolve() + } +} + +beforeEach(() => { + mockMmkv.clear() + jest.clearAllMocks() +}) + +describe('startHighlightQueueDrain', () => { + it('sends a queued write and clears its entry when the service returns', async () => { + queueApply(JHN3, [16], YELLOW) + const { api, calls } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toEqual([{ kind: 'create', passageId: 'JHN.3.16', color: YELLOW }]) + expect(getQueuedWrites(USER, JHN3)).toEqual({}) + }) + + it('writes the landed color into the cache, which is what the next mount paints', async () => { + queueApply(JHN3, [16], YELLOW) + const { api } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await settle() + drain.stop() + + expect(getCachedHighlights(USER, JHN3)).toEqual([ + { version_id: 111, passage_id: 'JHN.3.16', color: YELLOW }, + ]) + }) + + it('leaves highlights the write did not touch alone in the cache', async () => { + setCachedHighlights(USER, JHN3, [{ version_id: 111, passage_id: 'JHN.3.1', color: GREEN }]) + queueApply(JHN3, [16], YELLOW) + const { api } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await settle() + drain.stop() + + expect(getCachedHighlights(USER, JHN3)).toEqual([ + { version_id: 111, passage_id: 'JHN.3.1', color: GREEN }, + { version_id: 111, passage_id: 'JHN.3.16', color: YELLOW }, + ]) + }) + + it('lands a removal, taking the verse out of the cache', async () => { + setCachedHighlights(USER, JHN3, [{ version_id: 111, passage_id: 'JHN.3.16', color: YELLOW }]) + enqueueWrites({ + userId: USER, + scope: JHN3, + verses: [16], + color: null, + currentColors: { 16: YELLOW }, + }) + const { api, calls } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toEqual([{ kind: 'delete', passageId: 'JHN.3.16' }]) + expect(getCachedHighlights(USER, JHN3)).toEqual([]) + }) + + it('drains a chapter no reader is mounted on', async () => { + queueApply(JHN3, [16], YELLOW) + queueApply(JHN4, [1], GREEN) + const { api, calls } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await settle() + drain.stop() + + expect(calls.map((call) => call.passageId).sort()).toEqual(['JHN.3.16', 'JHN.4.1']) + expect(getQueuedWrites(USER, JHN3)).toEqual({}) + expect(getQueuedWrites(USER, JHN4)).toEqual({}) + }) + + it('collapses an applied run into one ranged request', async () => { + queueApply(JHN3, [16, 17, 18], YELLOW) + const { api, calls } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toEqual([{ kind: 'create', passageId: 'JHN.3.16-18', color: YELLOW }]) + }) + + it('sends removals one verse at a time, because a DELETE carries no color', async () => { + enqueueWrites({ + userId: USER, + scope: JHN3, + verses: [16, 17], + color: null, + currentColors: { 16: YELLOW, 17: YELLOW }, + }) + const { api, calls } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toEqual([ + { kind: 'delete', passageId: 'JHN.3.16' }, + { kind: 'delete', passageId: 'JHN.3.17' }, + ]) + }) + + it('groups a mixed queue by the color each verse is owed', async () => { + queueApply(JHN3, [16], YELLOW) + queueApply(JHN3, [17], GREEN) + const { api, calls } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toHaveLength(2) + expect(calls).toContainEqual({ kind: 'create', passageId: 'JHN.3.16', color: YELLOW }) + expect(calls).toContainEqual({ kind: 'create', passageId: 'JHN.3.17', color: GREEN }) + }) + + it('does not touch a verse a mounted hook has already claimed', async () => { + queueApply(JHN3, [16], YELLOW) + queueApply(JHN3, [17], YELLOW) + const release = claimWrites(USER, JHN3, [16]) + const { api, calls } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await settle() + + expect(calls).toEqual([{ kind: 'create', passageId: 'JHN.3.17', color: YELLOW }]) + expect(getQueuedWrites(USER, JHN3)).toEqual({ 16: { local: YELLOW, server: null } }) + + release() + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toContainEqual({ kind: 'create', passageId: 'JHN.3.16', color: YELLOW }) + expect(getQueuedWrites(USER, JHN3)).toEqual({}) + }) + + it('retires an entry the server already agrees with, without a request', async () => { + // `local === server` — a reconcile caught up with it while it sat queued. + mockMmkv.set( + `yvp.highlightqueue.${USER}.111.JHN.3`, + JSON.stringify({ 16: { local: YELLOW, server: YELLOW } }), + ) + const { api, calls } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toEqual([]) + expect(getQueuedWrites(USER, JHN3)).toEqual({}) + }) + + it.each([ + ['the server cannot be reached', UNREACHABLE], + ['the server answers 5xx', SERVER_ERROR], + ['the server answers a non-auth 4xx', UNPROCESSABLE], + ])('keeps the entry, and so the paint, when %s', async (_case, error) => { + queueApply(JHN3, [16], YELLOW) + const refreshNow = jest.fn(async () => undefined) + const { api, calls } = createApi({ onCreate: () => err(error) }) + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn({ refreshNow }) }) + drain.drainNow() + await settle() + drain.stop() + + // One attempt, no forced refresh: only an auth refusal earns a second look. + expect(calls).toHaveLength(1) + expect(refreshNow).not.toHaveBeenCalled() + expect(getQueuedWrites(USER, JHN3)).toEqual({ 16: { local: YELLOW, server: null } }) + expect(getCachedHighlights(USER, JHN3)).toBeNull() + }) + + describe('a write the server refuses', () => { + /** Refuses the first attempt, then answers `then`. */ + function refusingOnce(then: () => CreateResult) { + let attempts = 0 + return () => (++attempts === 1 ? err(REFUSED) : then()) + } + + it('mints a fresh token and states the write once more', async () => { + queueApply(JHN3, [16], YELLOW) + const refreshNow = jest.fn(async () => undefined) + const { api, calls } = createApi({ + onCreate: refusingOnce(() => ok({}) as CreateResult), + }) + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn({ refreshNow }) }) + drain.drainNow() + await settle() + drain.stop() + + expect(refreshNow).toHaveBeenCalledTimes(1) + expect(calls).toEqual([ + { kind: 'create', passageId: 'JHN.3.16', color: YELLOW }, + { kind: 'create', passageId: 'JHN.3.16', color: YELLOW }, + ]) + expect(getQueuedWrites(USER, JHN3)).toEqual({}) + expect(getCachedHighlights(USER, JHN3)).toEqual([ + { version_id: 111, passage_id: 'JHN.3.16', color: YELLOW }, + ]) + }) + + it('sends the retry under the token the refresh minted', async () => { + queueApply(JHN3, [16], YELLOW) + let auth: DrainAuth = { + userId: USER, + accessToken: 'stale', + ensureFreshToken: null, + refreshNow: async () => { + auth = { ...auth, accessToken: 'fresh' } + }, + } + const { api } = createApi({ onCreate: refusingOnce(() => ok({}) as CreateResult) }) + + const drain = startHighlightQueueDrain({ api, getAuth: () => auth }) + drain.drainNow() + await settle() + drain.stop() + + const tokens = (api.createHighlight as jest.Mock).mock.calls.map(([token]) => token) + expect(tokens).toEqual(['stale', 'fresh']) + }) + + it.each([ + ['401', REFUSED], + ['403', FORBIDDEN], + ])('drops the entry when a %s survives the forced refresh', async (_status, error) => { + queueApply(JHN3, [16], YELLOW) + const refreshNow = jest.fn(async () => undefined) + const { api, calls } = createApi({ onCreate: () => err(error) }) + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn({ refreshNow }) }) + drain.drainNow() + await settle() + drain.stop() + + expect(refreshNow).toHaveBeenCalledTimes(1) + expect(calls).toHaveLength(2) + expect(getQueuedWrites(USER, JHN3)).toEqual({}) + }) + + it('un-paints the verse back to the color the server had', async () => { + // The cache is the paint, so it already holds the optimistic yellow. + setCachedHighlights(USER, JHN3, [ + { version_id: 111, passage_id: 'JHN.3.16', color: YELLOW }, + { version_id: 111, passage_id: 'JHN.3.17', color: GREEN }, + ]) + enqueueWrites({ + userId: USER, + scope: JHN3, + verses: [16], + color: YELLOW, + currentColors: { 16: GREEN }, + }) + const { api } = createApi({ onCreate: () => err(REFUSED) }) + + const drain = startHighlightQueueDrain({ + api, + getAuth: signedIn({ refreshNow: jest.fn(async () => undefined) }), + }) + drain.drainNow() + await settle() + drain.stop() + + expect(getCachedHighlights(USER, JHN3)).toEqual([ + { version_id: 111, passage_id: 'JHN.3.16', color: GREEN }, + { version_id: 111, passage_id: 'JHN.3.17', color: GREEN }, + ]) + }) + + it('un-paints the verse entirely when the server had nothing', async () => { + setCachedHighlights(USER, JHN3, [{ version_id: 111, passage_id: 'JHN.3.16', color: YELLOW }]) + queueApply(JHN3, [16], YELLOW) + const { api } = createApi({ onCreate: () => err(REFUSED) }) + + const drain = startHighlightQueueDrain({ + api, + getAuth: signedIn({ refreshNow: jest.fn(async () => undefined) }), + }) + drain.drainNow() + await settle() + drain.stop() + + expect(getCachedHighlights(USER, JHN3)).toEqual([]) + }) + + it('leaves every other entry alone, in its scope and in others', async () => { + queueApply(JHN3, [16], YELLOW) + queueApply(JHN3, [20], GREEN) + queueApply(JHN4, [1], GREEN) + const { api } = createApi({ + onCreate: (data) => (data.color === YELLOW ? err(REFUSED) : err(UNREACHABLE)), + }) + + const drain = startHighlightQueueDrain({ + api, + getAuth: signedIn({ refreshNow: jest.fn(async () => undefined) }), + }) + drain.drainNow() + await settle() + drain.stop() + + expect(getQueuedWrites(USER, JHN3)).toEqual({ 20: { local: GREEN, server: null } }) + expect(getQueuedWrites(USER, JHN4)).toEqual({ 1: { local: GREEN, server: null } }) + }) + + it('keeps the entry when the retry fails for a different reason', async () => { + queueApply(JHN3, [16], YELLOW) + const { api, calls } = createApi({ onCreate: refusingOnce(() => err(UNREACHABLE)) }) + + const drain = startHighlightQueueDrain({ + api, + getAuth: signedIn({ refreshNow: jest.fn(async () => undefined) }), + }) + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toHaveLength(2) + expect(getQueuedWrites(USER, JHN3)).toEqual({ 16: { local: YELLOW, server: null } }) + }) + + it('keeps the entry when the forced refresh throws', async () => { + queueApply(JHN3, [16], YELLOW) + const { api, calls } = createApi({ onCreate: () => err(REFUSED) }) + + const drain = startHighlightQueueDrain({ + api, + getAuth: signedIn({ + refreshNow: jest.fn(async () => { + throw new Error('network down') + }), + }), + }) + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toHaveLength(1) + expect(getQueuedWrites(USER, JHN3)).toEqual({ 16: { local: YELLOW, server: null } }) + }) + + it('keeps the entry when there is no refresh to force', async () => { + queueApply(JHN3, [16], YELLOW) + const { api, calls } = createApi({ onCreate: () => err(REFUSED) }) + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn({ refreshNow: null }) }) + drain.drainNow() + await settle() + drain.stop() + + // No mint means no second statement under a fresh token, so nothing the + // server said qualifies as definitive. + expect(calls).toHaveLength(1) + expect(getQueuedWrites(USER, JHN3)).toEqual({ 16: { local: YELLOW, server: null } }) + }) + + it('does not drop when the refresh ends the session it was retrying for', async () => { + queueApply(JHN3, [16], YELLOW) + let auth: DrainAuth = { + userId: USER, + accessToken: 'token-1', + ensureFreshToken: null, + refreshNow: async () => { + auth = { userId: null, accessToken: null, ensureFreshToken: null, refreshNow: null } + }, + } + const { api, calls } = createApi({ onCreate: () => err(REFUSED) }) + + const drain = startHighlightQueueDrain({ api, getAuth: () => auth }) + drain.drainNow() + await settle() + drain.stop() + + // Sign-out purges the queue itself; the drain must not be what decides the + // departed user's write was refused for good. + expect(calls).toHaveLength(1) + expect(getQueuedWrites(USER, JHN3)).toEqual({ 16: { local: YELLOW, server: null } }) + }) + + it('drops a refused removal, restoring the color it was removing', async () => { + setCachedHighlights(USER, JHN3, []) + enqueueWrites({ + userId: USER, + scope: JHN3, + verses: [16], + color: null, + currentColors: { 16: GREEN }, + }) + const { api } = createApi({ + deleteHighlight: jest.fn(async () => err(FORBIDDEN)), + }) + + const drain = startHighlightQueueDrain({ + api, + getAuth: signedIn({ refreshNow: jest.fn(async () => undefined) }), + }) + drain.drainNow() + await settle() + drain.stop() + + expect(api.deleteHighlight).toHaveBeenCalledTimes(2) + expect(getQueuedWrites(USER, JHN3)).toEqual({}) + expect(getCachedHighlights(USER, JHN3)).toEqual([ + { version_id: 111, passage_id: 'JHN.3.16', color: GREEN }, + ]) + }) + }) + + it('sends the newest intent when a verse was re-tapped while parked', async () => { + queueApply(JHN3, [16], YELLOW) + queueApply(JHN3, [16], GREEN) + const { api, calls } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toEqual([{ kind: 'create', passageId: 'JHN.3.16', color: GREEN }]) + }) + + it('does nothing with no user', async () => { + queueApply(JHN3, [16], YELLOW) + const { api, calls } = createApi() + + const drain = startHighlightQueueDrain({ + api, + getAuth: signedIn({ userId: null }), + }) + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toEqual([]) + }) + + it('does nothing with no access token', async () => { + queueApply(JHN3, [16], YELLOW) + const { api, calls } = createApi() + + const drain = startHighlightQueueDrain({ + api, + getAuth: signedIn({ accessToken: null }), + }) + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toEqual([]) + }) + + it('refreshes the token once per pass before sending', async () => { + queueApply(JHN3, [16], YELLOW) + queueApply(JHN4, [1], YELLOW) + const ensureFreshToken = jest.fn(async () => {}) + const { api } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn({ ensureFreshToken }) }) + drain.drainNow() + await settle() + drain.stop() + + expect(ensureFreshToken).toHaveBeenCalledTimes(1) + }) + + it('stops mid-pass rather than sending one user’s passage under another’s token', async () => { + queueApply(JHN3, [16], YELLOW) + queueApply(JHN4, [1], YELLOW) + const { api, calls } = createApi() + + let auth: DrainAuth = { + userId: USER, + accessToken: 'token-1', + ensureFreshToken: null, + refreshNow: null, + } + const drain = startHighlightQueueDrain({ api, getAuth: () => auth }) + // Signs out during the refresh that precedes the first scope. + auth = { + userId: USER, + accessToken: 'token-1', + ensureFreshToken: async () => { + auth = { + userId: 'user-2', + accessToken: 'token-2', + ensureFreshToken: null, + refreshNow: null, + } + }, + refreshNow: null, + } + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toEqual([]) + }) + + it('abandons the remaining scopes when the user changes after the first one is sent', async () => { + queueApply(JHN3, [16], YELLOW) + queueApply(JHN4, [1], YELLOW) + + let auth: DrainAuth = { + userId: USER, + accessToken: 'token-1', + ensureFreshToken: null, + refreshNow: null, + } + const { api, calls } = createApi({ + onCreate: () => { + auth = { + userId: 'user-2', + accessToken: 'token-2', + ensureFreshToken: null, + refreshNow: null, + } + return ok({} as never) as CreateResult + }, + }) + + const drain = startHighlightQueueDrain({ api, getAuth: () => auth }) + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toEqual([{ kind: 'create', passageId: 'JHN.3.16', color: YELLOW }]) + expect((api.createHighlight as jest.Mock).mock.calls.map(([token]) => token)).toEqual([ + 'token-1', + ]) + expect(getQueuedWrites(USER, JHN4)).toEqual({ 1: { local: YELLOW, server: null } }) + expect(getCachedHighlights('user-2', JHN3)).toBeNull() + expect(getCachedHighlights('user-2', JHN4)).toBeNull() + }) + + it('survives a throwing token refresh', async () => { + queueApply(JHN3, [16], YELLOW) + const { api, calls } = createApi() + const drain = startHighlightQueueDrain({ + api, + getAuth: signedIn({ + ensureFreshToken: async () => { + throw new Error('boom') + }, + }), + }) + + drain.drainNow() + await settle() + drain.stop() + + expect(calls).toEqual([]) + expect(getQueuedWrites(USER, JHN3)).toEqual({ 16: { local: YELLOW, server: null } }) + }) + + it('coalesces overlapping triggers into a single pass', async () => { + queueApply(JHN3, [16], YELLOW) + let resolveCreate = (): void => {} + const { api, calls } = createApi({ + onCreate: async () => { + await new Promise((resolve) => { + resolveCreate = resolve + }) + return ok({} as never) as CreateResult + }, + }) + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await Promise.resolve() + drain.drainNow() + drain.drainNow() + resolveCreate() + await settle() + drain.stop() + + // The re-run happens, but finds nothing owed. + expect(calls).toHaveLength(1) + }) + + it('sends nothing after stop()', async () => { + queueApply(JHN3, [16], YELLOW) + const { api, calls } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.stop() + drain.drainNow() + await settle() + + expect(calls).toEqual([]) + }) +}) + +describe('drain retries', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + /** Advances until the next request goes out, and reports how long that took. */ + async function timeToNextAttempt(calls: Call[], step = 1000, maxSteps = 8000): Promise { + const before = calls.length + for (let taken = 1; taken <= maxSteps; taken++) { + await jest.advanceTimersByTimeAsync(step) + if (calls.length > before) { + return taken * step + } + } + return Infinity + } + + it('waits longer after each consecutive failure, at least doubling', async () => { + queueApply(JHN3, [16], YELLOW) + const { api, calls } = createApi({ onCreate: () => err(UNREACHABLE) }) + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await jest.advanceTimersByTimeAsync(0) + expect(calls).toHaveLength(1) + + const gaps = [ + await timeToNextAttempt(calls), + await timeToNextAttempt(calls), + await timeToNextAttempt(calls), + ] + drain.stop() + + expect(gaps.every(Number.isFinite)).toBe(true) + expect(gaps[1]).toBeGreaterThanOrEqual((gaps[0] ?? 0) * 2) + expect(gaps[2]).toBeGreaterThanOrEqual((gaps[1] ?? 0) * 2) + }) + + it('lands the write on a retry once the service comes back', async () => { + queueApply(JHN3, [16], YELLOW) + let reachable = false + const { api, calls } = createApi({ + onCreate: () => (reachable ? (ok({} as never) as CreateResult) : err(UNREACHABLE)), + }) + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await jest.advanceTimersByTimeAsync(0) + expect(getQueuedWrites(USER, JHN3)).not.toEqual({}) + + reachable = true + await timeToNextAttempt(calls) + drain.stop() + + expect(getQueuedWrites(USER, JHN3)).toEqual({}) + expect(getCachedHighlights(USER, JHN3)).toEqual([ + { version_id: 111, passage_id: 'JHN.3.16', color: YELLOW }, + ]) + }) + + it('sends a backed-off entry immediately when a trigger fires', async () => { + // The whole point of the connectivity edge: the wait was a guess about a + // network nobody had asked, and the trigger is the answer arriving. + queueApply(JHN3, [16], YELLOW) + const { api, calls } = createApi({ onCreate: () => err(UNREACHABLE) }) + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await jest.advanceTimersByTimeAsync(0) + for (let failure = 0; failure < 4; failure++) { + await timeToNextAttempt(calls) + } + const attempts = calls.length + + drain.drainNow() + await jest.advanceTimersByTimeAsync(0) + drain.stop() + + expect(calls).toHaveLength(attempts + 1) + }) + + it('keeps widening the wait after a trigger rather than starting the decay over', async () => { + queueApply(JHN3, [16], YELLOW) + const { api, calls } = createApi({ onCreate: () => err(UNREACHABLE) }) + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await jest.advanceTimersByTimeAsync(0) + const first = await timeToNextAttempt(calls) + + // A trigger buys an attempt, not a fresh start. + drain.drainNow() + await jest.advanceTimersByTimeAsync(0) + const next = await timeToNextAttempt(calls) + drain.stop() + + expect(next).toBeGreaterThanOrEqual((first ?? 0) * 2) + }) + + it('retries a parked write on noteParkedWrite() without re-sending it first', async () => { + // The write path already tried and failed; the drain owes it a later attempt. + queueApply(JHN3, [16], YELLOW) + const { api, calls } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.noteParkedWrite() + await jest.advanceTimersByTimeAsync(0) + expect(calls).toEqual([]) + + await timeToNextAttempt(calls) + drain.stop() + + expect(calls).toEqual([{ kind: 'create', passageId: 'JHN.3.16', color: YELLOW }]) + }) + + it('sends nothing more once the queue is empty', async () => { + queueApply(JHN3, [16], YELLOW) + const { api, calls } = createApi() + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await jest.advanceTimersByTimeAsync(0) + expect(getQueuedWrites(USER, JHN3)).toEqual({}) + + expect(await timeToNextAttempt(calls, 1000, 100)).toBe(Infinity) + drain.stop() + }) + + it('cancels the retry clock on stop()', async () => { + queueApply(JHN3, [16], YELLOW) + const { api, calls } = createApi({ onCreate: () => err(UNREACHABLE) }) + + const drain = startHighlightQueueDrain({ api, getAuth: signedIn() }) + drain.drainNow() + await jest.advanceTimersByTimeAsync(0) + expect(calls).toHaveLength(1) + + drain.stop() + expect(await timeToNextAttempt(calls, 1000, 100)).toBe(Infinity) + }) +}) diff --git a/packages/core/src/highlights/__tests__/highlight-queue-identity.test.tsx b/packages/core/src/highlights/__tests__/highlight-queue-identity.test.tsx new file mode 100644 index 00000000..1f725394 --- /dev/null +++ b/packages/core/src/highlights/__tests__/highlight-queue-identity.test.tsx @@ -0,0 +1,230 @@ +/** + * Identity seam for the Highlight Write Queue: whose writes these are. + * + * Real provider, real sign-out routine, real queue and drain. Only MMKV, the + * highlights API and the auth edges are faked, so the assertions are about what + * reached the API and what a mounted reader paints. + */ + +import type { Collection, Highlight } from '@youversion/platform-core' +import { render, screen, userEvent, waitFor } from '@testing-library/react-native' +import { Pressable, Text, View } from 'react-native' + +import { useYVAuth } from '../../auth' +import type { Result } from '../../result' +import { MMKV_AUTH_KEYS } from '../../auth/constants' +import { loadTokens } from '../../auth/token-storage' +import { signInWithPKCE } from '../../auth/pkce-flow' +import YouVersionProvider from '../../youversion-provider' +import type { HighlightsApiError } from '../api' +import type { HighlightScope } from '../constants' +import { enqueueWrites, listQueuedScopes } from '../queue' +import { useHighlights } from '../use-highlights' + +const mockMmkv = new Map() + +jest.mock('../../storage/mmkv-storage', () => ({ + mmkvStorage: { + set: jest.fn((k: string, v: string) => { + mockMmkv.set(k, v) + }), + getString: jest.fn((k: string) => mockMmkv.get(k)), + remove: jest.fn((k: string) => { + mockMmkv.delete(k) + }), + getAllKeys: jest.fn(() => Array.from(mockMmkv.keys())), + has: jest.fn((k: string) => mockMmkv.has(k)), + }, +})) + +const mockGetHighlights = jest.fn() +const mockCreateHighlight = jest.fn() +const mockDeleteHighlight = jest.fn() + +jest.mock('../api', () => ({ + createHighlightsApi: jest.fn(() => ({ + getHighlights: mockGetHighlights, + createHighlight: mockCreateHighlight, + deleteHighlight: mockDeleteHighlight, + })), +})) + +jest.mock('../../auth/token-storage', () => ({ + loadTokens: jest.fn(), + saveTokens: jest.fn(() => Promise.resolve()), +})) + +jest.mock('../../auth/pkce-flow', () => ({ signInWithPKCE: jest.fn() })) +jest.mock('../../auth/http', () => ({ + ...jest.requireActual('../../auth/http'), + refreshTokens: jest.fn(), +})) +jest.mock('../../installation-id', () => ({ + getOrSetInstallationId: jest.fn(() => Promise.resolve('install-1')), +})) +jest.mock('expo-network', () => ({ + addNetworkStateListener: jest.fn(() => ({ remove: jest.fn() })), +})) + +const mockLoadTokens = loadTokens as jest.Mock +const mockSignInWithPKCE = signInWithPKCE as jest.Mock + +const YELLOW = 'fffe00' +const GREEN = '5dff79' +const scope: HighlightScope = { versionId: 111, book: 'JHN', chapter: '3' } +const authConfig = { redirectUri: 'https://app/cb', permissions: ['highlights' as const] } + +/** No response at all — the write parks rather than reverting. */ +const unreachable = (): Result => ({ + ok: false, + error: { kind: 'transient', message: 'Network request failed' }, +}) + +function collection(data: Highlight[]): Result, HighlightsApiError> { + return { ok: true, value: { data, next_page_token: null } } +} + +function tokensFor(user: string) { + return { + access_token: `${user}-token`, + refresh_token: `${user}-refresh`, + expires_in: '3600', + token_type: 'Bearer', + } +} + +function Harness() { + const auth = useYVAuth() + const highlights = useHighlights(scope) + + return ( + + {auth.userInfo?.id ?? 'none'} + + {JSON.stringify(highlights.highlights.map((h) => `${h.passage_id}:${h.color}`))} + + { + void highlights.apply(YELLOW, [16]) + }} + > + apply + + auth.signOut()}> + signOut + + auth.signIn()}> + signIn + + + ) +} + +function renderApp() { + return render( + + + , + ) +} + +function getText(id: string): string { + return screen.getByTestId(id).props.children +} + +function painted(): string[] { + return JSON.parse(getText('painted')) as string[] +} + +/** Every token the write path and the drain have sent a highlight under. */ +function tokensSent(): string[] { + return [ + ...mockCreateHighlight.mock.calls.map(([token]) => token as string), + ...mockDeleteHighlight.mock.calls.map(([token]) => token as string), + ] +} + +/** Signs `user` in from stored tokens, the way a relaunch does. */ +function arrangeSignedIn(user: string) { + mockMmkv.set(MMKV_AUTH_KEYS.cachedUserInfo, JSON.stringify({ id: user })) + mockLoadTokens.mockResolvedValue({ + accessToken: `${user}-token`, + refreshToken: `${user}-refresh`, + expiryDate: new Date(Date.now() + 60 * 60 * 1000), + }) +} + +beforeEach(() => { + mockMmkv.clear() + jest.clearAllMocks() + mockGetHighlights.mockResolvedValue(collection([])) + mockCreateHighlight.mockResolvedValue({ ok: true, value: {} }) + mockDeleteHighlight.mockResolvedValue({ ok: true, value: undefined }) + mockLoadTokens.mockResolvedValue({ accessToken: null, refreshToken: null, expiryDate: null }) +}) + +describe('a queued write and the user who made it', () => { + it('never lands on the account of whoever signs in next', async () => { + const user = userEvent.setup() + arrangeSignedIn('user-1') + mockCreateHighlight.mockResolvedValue(unreachable()) + + renderApp() + await waitFor(() => expect(getText('userId')).toBe('user-1')) + await user.press(screen.getByTestId('apply')) + await waitFor(() => expect(listQueuedScopes('user-1')).toEqual([scope])) + + await user.press(screen.getByTestId('signOut')) + await waitFor(() => expect(getText('userId')).toBe('none')) + + mockCreateHighlight.mockResolvedValue({ ok: true, value: {} }) + mockSignInWithPKCE.mockResolvedValue({ + kind: 'success', + tokens: tokensFor('user-2'), + userInfo: { id: 'user-2' }, + grantedPermissions: ['highlights'], + }) + await user.press(screen.getByTestId('signIn')) + await waitFor(() => expect(getText('userId')).toBe('user-2')) + + expect(tokensSent()).toEqual(['user-1-token']) + expect(listQueuedScopes('user-1')).toEqual([]) + expect(listQueuedScopes('user-2')).toEqual([]) + expect(painted()).toEqual([]) + }) + + it('is gone from storage the moment they sign out, paint included', async () => { + const user = userEvent.setup() + arrangeSignedIn('user-1') + mockCreateHighlight.mockResolvedValue(unreachable()) + + renderApp() + await waitFor(() => expect(getText('userId')).toBe('user-1')) + await user.press(screen.getByTestId('apply')) + await waitFor(() => expect(painted()).toEqual([`JHN.3.16:${YELLOW}`])) + + await user.press(screen.getByTestId('signOut')) + + await waitFor(() => expect(listQueuedScopes('user-1')).toEqual([])) + expect(painted()).toEqual([]) + }) + + it('stays scoped to them while another user has entries for the same chapter', async () => { + arrangeSignedIn('user-1') + enqueueWrites({ userId: 'user-2', scope, verses: [1], color: GREEN, currentColors: {} }) + enqueueWrites({ userId: 'user-1', scope, verses: [16], color: YELLOW, currentColors: {} }) + + renderApp() + await waitFor(() => expect(getText('userId')).toBe('user-1')) + + // Only their own verse is painted, and only their own verse is drained. + expect(painted()).toEqual([`JHN.3.16:${YELLOW}`]) + await waitFor(() => + expect(mockCreateHighlight.mock.calls).toEqual([ + ['user-1-token', { version_id: 111, passage_id: 'JHN.3.16', color: YELLOW }], + ]), + ) + expect(listQueuedScopes('user-2')).toEqual([scope]) + }) +}) diff --git a/packages/core/src/highlights/__tests__/highlight-write-queue.test.tsx b/packages/core/src/highlights/__tests__/highlight-write-queue.test.tsx new file mode 100644 index 00000000..184d7b23 --- /dev/null +++ b/packages/core/src/highlights/__tests__/highlight-write-queue.test.tsx @@ -0,0 +1,704 @@ +import type { Collection, Highlight } from '@youversion/platform-core' +import { act, renderHook } from '@testing-library/react-native' +import type { ReactNode } from 'react' + +import { AuthContext, type AccessTokenResult, type AuthContextValue } from '../../auth/auth-context' +import type { Result } from '../../result' +import { YouVersionContext } from '../../youversion-context' +import type { HighlightsApiError } from '../api' +import { isWriteClaimed } from '../claims' +import { startHighlightQueueDrain } from '../drain' +import { onDrainSignal, type DrainSignal } from '../drain-signals' +import { + highlightQueueKey, + highlightsCacheKey, + MMKV_HIGHLIGHT_QUEUE_KEY_PREFIX, + type HighlightScope, + type QueuedWrites, +} from '../constants' +import { + useHighlights, + type HighlightWriteOutcome, + type UseHighlightsResult, +} from '../use-highlights' + +// ── Boundaries ─────────────────────────────────────────────────────────────── +// Only the two real external edges are faked: MMKV and the API client. The +// queue, its projection, the optimistic layer and the cache all run for real. + +const mockMmkv = new Map() + +/** Set to a key prefix to make `mmkvStorage.set` throw for it, as a full store does. */ +let mockFailingSetPrefix: string | null = null + +jest.mock('../../storage/mmkv-storage', () => ({ + mmkvStorage: { + set: jest.fn((k: string, v: string) => { + if (mockFailingSetPrefix !== null && k.startsWith(mockFailingSetPrefix)) { + throw new Error('MMKV is out of space') + } + mockMmkv.set(k, v) + }), + getString: jest.fn((k: string) => mockMmkv.get(k)), + remove: jest.fn((k: string) => { + mockMmkv.delete(k) + }), + getAllKeys: jest.fn(() => Array.from(mockMmkv.keys())), + has: jest.fn((k: string) => mockMmkv.has(k)), + }, +})) + +const mockGetHighlights = jest.fn() +const mockCreateHighlight = jest.fn() +const mockDeleteHighlight = jest.fn() + +jest.mock('../api', () => ({ + createHighlightsApi: jest.fn(() => ({ + getHighlights: mockGetHighlights, + createHighlight: mockCreateHighlight, + deleteHighlight: mockDeleteHighlight, + })), +})) + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const YELLOW = 'fffe00' +const GREEN = '5dff79' +const BLUE = '00d6ff' + +const scope: HighlightScope = { versionId: 111, book: 'JHN', chapter: '3' } +const options = { versionId: 111, book: 'JHN', chapter: '3' } +const userId = 'user-1' + +function highlight(passageId: string, color: string, versionId = scope.versionId): Highlight { + return { version_id: versionId, passage_id: passageId, color } +} + +function collection(data: Highlight[]): Result, HighlightsApiError> { + return { ok: true, value: { data, next_page_token: null } } +} + +function apiError(error: HighlightsApiError): Result { + return { ok: false, error } +} + +/** No response at all — airplane mode, dead wifi, a timeout. Queue-eligible. */ +const unreachable = () => apiError({ kind: 'transient', message: 'Network request failed' }) + +/** The server answered and said no. Reverts rather than parking. */ +const rejected = () => apiError({ kind: 'transient', status: 422, message: 'no' }) + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +const ensureFreshToken = jest.fn(async () => undefined) + +/** Hoisted rather than built per render, so a test can drive a failed refresh. */ +const getAccessToken = jest.fn, []>() + +/** + * Swapped per test so a case can render against a different auth state without + * remounting. `userInfo` stays seeded in every shape: it comes from its own + * synchronous initializer in `AuthProvider`, so the id is there before the token. + */ +let authOverrides: Partial = {} + +/** Signed in, `userInfo` already read from cache, `loadTokens()` not yet resolved. */ +const tokenLoading: Partial = { + isAuthenticated: false, + accessToken: null, + isLoading: true, +} + +function authValue(): AuthContextValue { + return { + isAuthenticated: true, + accessToken: 'token-1', + userInfo: { id: userId }, + error: null, + signIn: jest.fn(async () => undefined), + signOut: jest.fn(async () => undefined), + refreshNow: jest.fn(async () => undefined), + ensureFreshToken, + getAccessToken, + isLoading: false, + requestedPermissions: ['highlights'], + grantedPermissions: null, + hasPermission: jest.fn(() => false), + invalidatePermissions: jest.fn(), + requestPermissions: jest.fn(), + ...authOverrides, + } +} + +function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function renderUseHighlights(initialProps = options) { + return renderHook((props: typeof options) => useHighlights(props), { + wrapper: Wrapper, + initialProps, + }) +} + +function seedServer(highlights: Highlight[]) { + mockMmkv.set(highlightsCacheKey(userId, scope), JSON.stringify(highlights)) + mockGetHighlights.mockResolvedValue(collection(highlights)) +} + +function colorsOf(result: UseHighlightsResult): Record { + return Object.fromEntries(result.highlights.map((h) => [h.passage_id, h.color])) +} + +function queuedWrites(): QueuedWrites | null { + const raw = mockMmkv.get(highlightQueueKey(userId, scope)) + return raw === undefined ? null : (JSON.parse(raw) as QueuedWrites) +} + +function readCache(forScope = scope): Highlight[] | null { + const raw = mockMmkv.get(highlightsCacheKey(userId, forScope)) + return raw === undefined ? null : (JSON.parse(raw) as Highlight[]) +} + +/** Let the mount fetch (and anything else already queued) settle. */ +async function flush() { + await act(async () => { + await Promise.resolve() + }) +} + +beforeEach(() => { + mockMmkv.clear() + mockFailingSetPrefix = null + authOverrides = {} + jest.clearAllMocks() + mockGetHighlights.mockReset() + mockCreateHighlight.mockReset() + mockDeleteHighlight.mockReset() + ensureFreshToken.mockReset() + ensureFreshToken.mockResolvedValue(undefined) + getAccessToken.mockReset() + getAccessToken.mockResolvedValue({ status: 'ok', token: 'token-1', userId }) + mockGetHighlights.mockResolvedValue(collection([])) + mockCreateHighlight.mockResolvedValue({ ok: true, value: highlight('JHN.3.16', YELLOW) }) + mockDeleteHighlight.mockResolvedValue({ ok: true, value: undefined }) +}) + +describe('a write that cannot reach the server', () => { + it('keeps the paint and reports queued', async () => { + mockCreateHighlight.mockResolvedValue(unreachable()) + + const { result } = renderUseHighlights() + await flush() + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [16]) + }) + + expect(outcome).toEqual({ status: 'queued', verses: [16] }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + expect(queuedWrites()).toEqual({ 16: { local: YELLOW, server: null } }) + }) + + it('keeps a removal hidden and reports queued', async () => { + seedServer([highlight('JHN.3.16', YELLOW)]) + mockDeleteHighlight.mockResolvedValue(unreachable()) + + const { result } = renderUseHighlights() + await flush() + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.remove(YELLOW, [16]) + }) + + expect(outcome).toEqual({ status: 'queued', verses: [16] }) + expect(colorsOf(result.current)).toEqual({}) + expect(queuedWrites()).toEqual({ 16: { local: null, server: YELLOW } }) + }) + + it('does not spend a GET when nothing reached the server', async () => { + mockCreateHighlight.mockResolvedValue(unreachable()) + + const { result } = renderUseHighlights() + await flush() + mockGetHighlights.mockClear() + + await act(async () => { + await result.current.apply(YELLOW, [16]) + }) + + expect(mockGetHighlights).not.toHaveBeenCalled() + }) + + it('leaves nothing queued once the write lands', async () => { + const { result } = renderUseHighlights() + await flush() + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [16]) + }) + + expect(outcome).toEqual({ status: 'ok', verses: [16] }) + expect(queuedWrites()).toBeNull() + }) +}) + +describe('a write stopped by a failed token refresh', () => { + beforeEach(() => { + getAccessToken.mockResolvedValue({ status: 'unavailable', reason: 'refresh-failed' }) + }) + + it('takes the paint back and parks nothing', async () => { + seedServer([highlight('JHN.3.16', GREEN)]) + + const { result } = renderUseHighlights() + await flush() + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [16]) + }) + + // `transient`, not `queued`: the paint is gone, so the caller must not be + // told the highlight is saved offline. + expect(outcome).toMatchObject({ status: 'error', reason: 'transient', failedVerses: [16] }) + expect(mockCreateHighlight).not.toHaveBeenCalled() + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) + expect(queuedWrites()).toBeNull() + }) + + it('puts a removal back on screen and parks nothing', async () => { + seedServer([highlight('JHN.3.16', YELLOW)]) + + const { result } = renderUseHighlights() + await flush() + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.remove(YELLOW, [16]) + }) + + expect(outcome).toMatchObject({ status: 'error', reason: 'transient', failedVerses: [16] }) + expect(mockDeleteHighlight).not.toHaveBeenCalled() + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + expect(queuedWrites()).toBeNull() + }) + + it('leaves the next launch nothing owed', async () => { + seedServer([highlight('JHN.3.16', GREEN)]) + + const first = renderUseHighlights() + await flush() + await act(async () => { + await first.result.current.apply(YELLOW, [16]) + }) + first.unmount() + + mockGetHighlights.mockResolvedValue(unreachable()) + const second = renderUseHighlights() + + expect(colorsOf(second.result.current)).toEqual({ 'JHN.3.16': GREEN }) + expect(queuedWrites()).toBeNull() + }) +}) + +describe('a queued write after a relaunch', () => { + it('is painted again before anything touches the network', async () => { + mockCreateHighlight.mockResolvedValue(unreachable()) + + const first = renderUseHighlights() + await flush() + await act(async () => { + await first.result.current.apply(YELLOW, [16]) + }) + first.unmount() + + // Cold start, still no service. + mockGetHighlights.mockResolvedValue(unreachable()) + const second = renderUseHighlights() + + expect(colorsOf(second.result.current)).toEqual({ 'JHN.3.16': YELLOW }) + expect(queuedWrites()).toEqual({ 16: { local: YELLOW, server: null } }) + }) + + // MMKV is written queue first, cache second. A process that died between the + // two comes back owing a write it does not show, and the mount repairs it. + it('is painted again even if the cache never recorded it', async () => { + mockCreateHighlight.mockResolvedValue(unreachable()) + + const first = renderUseHighlights() + await flush() + await act(async () => { + await first.result.current.apply(YELLOW, [16]) + }) + first.unmount() + mockMmkv.delete(highlightsCacheKey(userId, scope)) + + mockGetHighlights.mockResolvedValue(unreachable()) + const second = renderUseHighlights() + + expect(colorsOf(second.result.current)).toEqual({ 'JHN.3.16': YELLOW }) + }) +}) + +describe('an unreadable queue', () => { + it.each([ + ['not JSON', 'not json at all'], + ['the wrong shape', '{"16":"fffe00"}'], + ['an unusable verse number', '{"0":{"local":"fffe00","server":null}}'], + ])('falls back to the cache rather than throwing when it holds %s', async (_label, raw) => { + seedServer([highlight('JHN.3.16', GREEN)]) + mockMmkv.set(highlightQueueKey(userId, scope), raw) + mockGetHighlights.mockResolvedValue(unreachable()) + + const { result } = renderUseHighlights() + + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) + }) +}) + +describe('the queue holds desired state, not a log of operations', () => { + it('overwrites the entry when the same verse is written again', async () => { + mockCreateHighlight.mockResolvedValue(unreachable()) + + const { result } = renderUseHighlights() + await flush() + + await act(async () => { + await result.current.apply(YELLOW, [16]) + }) + await act(async () => { + await result.current.apply(GREEN, [16]) + }) + + expect(queuedWrites()).toEqual({ 16: { local: GREEN, server: null } }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) + }) + + it('drops the entry when the user undoes the write offline', async () => { + mockCreateHighlight.mockResolvedValue(unreachable()) + mockDeleteHighlight.mockResolvedValue(unreachable()) + + const { result } = renderUseHighlights() + await flush() + + await act(async () => { + await result.current.apply(YELLOW, [16]) + }) + await act(async () => { + await result.current.remove(YELLOW, [16]) + }) + + expect(queuedWrites()).toBeNull() + expect(colorsOf(result.current)).toEqual({}) + expect(mockDeleteHighlight).not.toHaveBeenCalled() + }) + + // A verse holds one color at a time, so the color the user replaced is not a + // state the server ever needs to see. + it('sends only the newest color when a tap supersedes one still in flight', async () => { + const slowYellow = deferred>() + mockCreateHighlight.mockReturnValueOnce(slowYellow.promise) + + const { result } = renderUseHighlights() + await flush() + + let yellow: Promise | undefined + act(() => { + yellow = result.current.apply(YELLOW, [16]) + }) + act(() => { + void result.current.apply(GREEN, [16]) + }) + + await act(async () => { + slowYellow.resolve({ ok: true, value: highlight('JHN.3.16', YELLOW) }) + await yellow + }) + + expect(await yellow).toEqual({ status: 'noop' }) + expect(mockCreateHighlight).toHaveBeenCalledTimes(1) + expect(mockCreateHighlight).toHaveBeenCalledWith('token-1', { + version_id: 111, + passage_id: 'JHN.3.16', + color: GREEN, + }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) + }) + + // The entry's server state is captured once, by the first write to that verse, + // so a rejection puts the verse back where the server had it — not where an + // intervening local write left it. + it('reverts to what the server had, not to the write it replaced', async () => { + seedServer([highlight('JHN.3.16', GREEN)]) + mockCreateHighlight.mockResolvedValueOnce(unreachable()) + + const { result } = renderUseHighlights() + await flush() + + await act(async () => { + await result.current.apply(YELLOW, [16]) + }) + + mockCreateHighlight.mockResolvedValue(rejected()) + await act(async () => { + await result.current.apply(BLUE, [16]) + }) + + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) + expect(queuedWrites()).toBeNull() + }) +}) + +describe('a write still held in the token-loading window when the reader unmounts', () => { + it('parks it, keeps the entry, and releases the claim', async () => { + authOverrides = tokenLoading + const signals: DrainSignal[] = [] + const unsubscribe = onDrainSignal((signal) => signals.push(signal)) + + const { result, unmount } = renderUseHighlights() + + let outcome: Promise | undefined + act(() => { + outcome = result.current.apply(YELLOW, [16]) + }) + + // Painted and persisted, but held: no request has gone out. + expect(queuedWrites()).toEqual({ 16: { local: YELLOW, server: null } }) + expect(mockCreateHighlight).not.toHaveBeenCalled() + + unmount() + await act(async () => { + await outcome + }) + + expect(await outcome).toEqual({ status: 'queued', verses: [16] }) + // The entry stands. Resuming against the still-null token would classify + // `not-signed-in` and revert, deleting a write the server never saw. + expect(queuedWrites()).toEqual({ 16: { local: YELLOW, server: null } }) + // And the claim is released, so the drain can send it in this same session + // rather than skipping the verse until relaunch. + expect(isWriteClaimed(userId, scope, 16)).toBe(false) + expect(signals).toContain('write-parked') + + unsubscribe() + }) + + it('parks a held removal the same way', async () => { + seedServer([highlight('JHN.3.16', YELLOW)]) + authOverrides = tokenLoading + + const { result, unmount } = renderUseHighlights() + + let outcome: Promise | undefined + act(() => { + outcome = result.current.remove(YELLOW, [16]) + }) + + unmount() + await act(async () => { + await outcome + }) + + expect(await outcome).toEqual({ status: 'queued', verses: [16] }) + expect(queuedWrites()).toEqual({ 16: { local: null, server: YELLOW } }) + expect(isWriteClaimed(userId, scope, 16)).toBe(false) + expect(mockDeleteHighlight).not.toHaveBeenCalled() + }) +}) + +describe('a store that refuses the queue entry', () => { + it('reports transient before anything paints, claims, or is sent', async () => { + const { result } = renderUseHighlights() + await flush() + mockFailingSetPrefix = MMKV_HIGHLIGHT_QUEUE_KEY_PREFIX + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [16]) + }) + + // Resolves an outcome — `apply` never rejects, whatever storage does. + expect(outcome).toMatchObject({ + status: 'error', + reason: 'transient', + failedVerses: [16], + succeededVerses: [], + }) + expect(colorsOf(result.current)).toEqual({}) + expect(queuedWrites()).toBeNull() + expect(isWriteClaimed(userId, scope, 16)).toBe(false) + expect(mockCreateHighlight).not.toHaveBeenCalled() + }) +}) + +describe('a refusal that settles after the reader has changed chapter', () => { + const JHN4 = { versionId: 111, book: 'JHN', chapter: '4' } + const refused = () => apiError({ kind: 'auth', status: 403, message: 'no' }) + + it('reverts the cache of the chapter the write was made in', async () => { + seedServer([highlight('JHN.3.16', GREEN)]) + const held = deferred>() + mockCreateHighlight.mockReturnValueOnce(held.promise) + + const { result, rerender } = renderUseHighlights() + await flush() + + let outcome: Promise | undefined + act(() => { + outcome = result.current.apply(YELLOW, [16]) + }) + await act(async () => { + await Promise.resolve() + }) + expect(mockCreateHighlight).toHaveBeenCalledTimes(1) + expect(readCache()).toEqual([highlight('JHN.3.16', YELLOW)]) + + // The reader moves on while the write is on the wire, so the un-paint has no + // mounted state to land in — only the cache can carry it. + act(() => { + rerender(JHN4) + }) + + await act(async () => { + held.resolve(refused()) + await outcome + }) + + expect(await outcome).toMatchObject({ status: 'error', reason: 'auth', failedVerses: [16] }) + expect(readCache()).toEqual([highlight('JHN.3.16', GREEN)]) + expect(queuedWrites()).toBeNull() + }) + + it('still un-paints and matches the cache when the refusal lands in scope', async () => { + seedServer([highlight('JHN.3.16', GREEN)]) + mockCreateHighlight.mockResolvedValue(refused()) + + const { result } = renderUseHighlights() + await flush() + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.apply(YELLOW, [16]) + }) + + expect(outcome).toMatchObject({ status: 'error', reason: 'auth', failedVerses: [16] }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) + expect(readCache()).toEqual([highlight('JHN.3.16', GREEN)]) + expect(queuedWrites()).toBeNull() + }) +}) + +describe('a parked write the drain drops', () => { + const JHN4: HighlightScope = { versionId: 111, book: 'JHN', chapter: '4' } + + const refused = () => apiError({ kind: 'auth', status: 401, message: 'no' }) + + function queuedWritesFor(target: HighlightScope): QueuedWrites | null { + const raw = mockMmkv.get(highlightQueueKey(userId, target)) + return raw === undefined ? null : (JSON.parse(raw) as QueuedWrites) + } + + /** One full drain pass against the same fake MMKV the mounted hooks read. */ + async function drainOnce() { + const drain = startHighlightQueueDrain({ + api: { + getHighlights: mockGetHighlights, + createHighlight: mockCreateHighlight, + deleteHighlight: mockDeleteHighlight, + }, + getAuth: () => ({ + userId, + accessToken: 'token-1', + ensureFreshToken: null, + refreshNow: async () => undefined, + }), + }) + await act(async () => { + drain.drainNow() + for (let tick = 0; tick < 12; tick++) { + await Promise.resolve() + } + }) + drain.stop() + } + + it('un-paints on the reader still mounted, with no remount', async () => { + seedServer([highlight('JHN.3.16', GREEN)]) + mockCreateHighlight.mockResolvedValue(unreachable()) + + const { result } = renderUseHighlights() + await flush() + + await act(async () => { + await result.current.apply(YELLOW, [16]) + }) + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + + // The service returns and states twice that it will not take this write. + mockCreateHighlight.mockResolvedValue(refused()) + await drainOnce() + + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) + expect(queuedWrites()).toBeNull() + }) + + it('leaves the paint alone when the refusal heals on the forced refresh', async () => { + mockCreateHighlight.mockResolvedValue(unreachable()) + + const { result } = renderUseHighlights() + await flush() + + await act(async () => { + await result.current.apply(YELLOW, [16]) + }) + + mockCreateHighlight + .mockResolvedValueOnce(refused()) + .mockResolvedValue({ ok: true, value: highlight('JHN.3.16', YELLOW) }) + await drainOnce() + + expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': YELLOW }) + expect(queuedWrites()).toBeNull() + }) + + it('leaves a reader on another chapter painted and still owed its write', async () => { + mockCreateHighlight.mockResolvedValue(unreachable()) + + const { result } = renderUseHighlights() + const other = renderHook(() => useHighlights(JHN4), { wrapper: Wrapper }) + await flush() + + await act(async () => { + await result.current.apply(YELLOW, [16]) + await other.result.current.apply(GREEN, [1]) + }) + expect(colorsOf(other.result.current)).toEqual({ 'JHN.4.1': GREEN }) + + // Only JHN 3's write is refused; JHN 4's still cannot reach the server. + mockCreateHighlight.mockImplementation(async (_token, data) => + data.color === YELLOW ? refused() : unreachable(), + ) + await drainOnce() + + expect(colorsOf(result.current)).toEqual({}) + expect(queuedWrites()).toBeNull() + expect(colorsOf(other.result.current)).toEqual({ 'JHN.4.1': GREEN }) + expect(queuedWritesFor(JHN4)).toEqual({ 1: { local: GREEN, server: null } }) + }) +}) diff --git a/packages/core/src/highlights/__tests__/offline-permission-flow.test.tsx b/packages/core/src/highlights/__tests__/offline-permission-flow.test.tsx new file mode 100644 index 00000000..75feac38 --- /dev/null +++ b/packages/core/src/highlights/__tests__/offline-permission-flow.test.tsx @@ -0,0 +1,402 @@ +import { act, renderHook } from '@testing-library/react-native' +import * as WebBrowser from 'expo-web-browser' +import type { ReactNode } from 'react' + +import type { AuthPermission, DataExchangeOutcome } from '../../auth' +import { AuthContext, type AuthContextValue } from '../../auth/auth-context' +import type { DataExchangeApiResult } from '../../auth/data-exchange-api' +import { requestDataExchange } from '../../auth/data-exchange' +import { + loadCachedGrantedPermissions, + saveGrantedPermissions, +} from '../../auth/granted-permissions-cache' +import type { Result } from '../../result' +import { YouVersionContext } from '../../youversion-context' +import type { HighlightsApiError } from '../api' +import { highlightQueueKey, type HighlightScope, type QueuedWrites } from '../constants' +import { hasQueuedHighlightWrites } from '../queue' +import { + useHighlightPermissionFlow, + type UseHighlightPermissionFlowResult, +} from '../use-highlight-permission-flow' +import type { HighlightWriteOutcome, UseHighlightsOptions } from '../use-highlights' + +// ── Boundaries ─────────────────────────────────────────────────────────────── +// One vertical seam, faked at the external edges: MMKV, the highlights API +// client, the data-exchange mint, and the browser. The one internal double is +// the auth context — a real `AuthProvider` would drag in secure storage and the +// whole PKCE flow for nothing this suite asks about. See `hasPermission`. +// +// The whole suite runs offline: every network edge is unreachable. + +const mockMmkv = new Map() + +jest.mock('../../storage/mmkv-storage', () => ({ + mmkvStorage: { + set: jest.fn((k: string, v: string) => { + mockMmkv.set(k, v) + }), + getString: jest.fn((k: string) => mockMmkv.get(k)), + remove: jest.fn((k: string) => { + mockMmkv.delete(k) + }), + getAllKeys: jest.fn(() => Array.from(mockMmkv.keys())), + has: jest.fn((k: string) => mockMmkv.has(k)), + }, +})) + +const mockGetHighlights = jest.fn() +const mockCreateHighlight = jest.fn() +const mockDeleteHighlight = jest.fn() + +jest.mock('../api', () => ({ + createHighlightsApi: jest.fn(() => ({ + getHighlights: mockGetHighlights, + createHighlight: mockCreateHighlight, + deleteHighlight: mockDeleteHighlight, + })), +})) + +jest.mock('expo-web-browser', () => ({ + openAuthSessionAsync: jest.fn(), +})) + +const mockOpenAuthSession = WebBrowser.openAuthSessionAsync as jest.Mock + +/** The data-exchange mint. Offline, so it never gets an answer. */ +const mockMintToken = jest.fn>, [string, readonly string[]]>() + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const YELLOW = 'fffe00' + +const scope: HighlightScope = { versionId: 111, book: 'JHN', chapter: '3' } +const options: UseHighlightsOptions = { versionId: 111, book: 'JHN', chapter: '3' } +const userId = 'user-1' + +const NETWORK_DOWN = 'Network request failed' + +function apiError(error: HighlightsApiError): Result { + return { ok: false, error } +} + +/** No response at all — airplane mode, dead wifi, a timeout. */ +const unreachable = () => apiError({ kind: 'transient', message: NETWORK_DOWN }) + +const mockSignIn = jest.fn, []>() +const mockInvalidatePermissions = jest.fn() + +/** The one thing that varies across the three describe blocks. */ +let signedIn = true + +/** + * The initiator identity `requestDataExchange` guards on. Constant here: nothing + * in this suite changes user mid-flow. + */ +const identity = { sessionId: 1, userId } + +/** Whatever the MMKV grant cache holds for this user — `null` when signed out. */ +function cachedGrant(): AuthPermission[] | null { + return signedIn ? loadCachedGrantedPermissions(userId) : null +} + +/** + * Shaped after `AuthProvider`'s own read: an `includes` over a grant it seeded + * from the **local MMKV cache**, never a call to anyone. + * + * Two halves of ADR 0014, pinned in two places. That the grant is *seeded* from + * cache belongs to the provider, and is pinned there — see + * `auth/__tests__/auth-provider.test.tsx`, 'seeds the grant synchronously from + * cache on a cold start'. + * + * What this suite pins is the other half: that the cached answer is **enough on + * its own** to reach the write path. Every network edge here is unreachable, so + * anything the pre-flight might add in front of the write — a server check, a + * re-consent, a mint "just to be sure the hint is true" — fails the granted + * block below. Checked by mutating the branch, not assumed. + */ +function hasPermission(permission: AuthPermission): boolean { + return cachedGrant()?.includes(permission) ?? false +} + +/** The real just-in-time grant, wired to a mint that cannot reach the server. */ +function requestPermissions(permissions: readonly AuthPermission[]): Promise { + return requestDataExchange({ + api: { mintToken: mockMintToken }, + appKey: 'app-key', + apiHost: 'api.youversion.com', + accessToken: 'token-1', + redirectUri: 'youversionauth://callback', + initiator: identity, + permissions, + getCurrentIdentity: () => identity, + }) +} + +function authValue(): AuthContextValue { + return { + isAuthenticated: signedIn, + accessToken: signedIn ? 'token-1' : null, + userInfo: signedIn ? { id: userId } : null, + error: null, + signIn: mockSignIn, + signOut: jest.fn(), + refreshNow: jest.fn(), + ensureFreshToken: jest.fn(async () => undefined), + getAccessToken: jest.fn(async () => + signedIn + ? ({ status: 'ok', token: 'token-1', userId } as const) + : ({ status: 'unavailable', reason: 'signed-out' } as const), + ), + isLoading: false, + requestedPermissions: ['highlights'], + grantedPermissions: cachedGrant(), + hasPermission, + invalidatePermissions: mockInvalidatePermissions, + requestPermissions, + } +} + +function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +type FlowResult = { current: UseHighlightPermissionFlowResult } + +function renderFlow() { + return renderHook(() => useHighlightPermissionFlow(options), { wrapper: Wrapper }) +} + +/** + * Start an apply and hand back its promise **unawaited** — the consent flow is + * still waiting on the user when this returns, so awaiting here would hang. + * Wrapped in an object because `await` unwraps a returned promise recursively. + */ +async function startApply( + result: FlowResult, +): Promise<{ promise: Promise }> { + let promise: Promise | undefined + await act(async () => { + promise = result.current.apply(YELLOW, [16]) + }) + if (promise === undefined) { + throw new Error('apply() did not return a promise') + } + return { promise } +} + +function paintedColors(result: FlowResult): Record { + return Object.fromEntries( + result.current.highlights.highlights.map((h) => [h.passage_id, h.color]), + ) +} + +function queuedWrites(): QueuedWrites | null { + const raw = mockMmkv.get(highlightQueueKey(userId, scope)) + return raw === undefined ? null : (JSON.parse(raw) as QueuedWrites) +} + +/** Let the mount fetch (and anything else already queued) settle. */ +async function flush() { + await act(async () => { + await Promise.resolve() + }) +} + +beforeEach(() => { + mockMmkv.clear() + jest.clearAllMocks() + signedIn = true + + // Offline, everywhere. + mockGetHighlights.mockResolvedValue(unreachable()) + mockCreateHighlight.mockResolvedValue(unreachable()) + mockDeleteHighlight.mockResolvedValue(unreachable()) + mockMintToken.mockResolvedValue({ + ok: false, + error: { kind: 'transient', message: NETWORK_DOWN }, + }) + mockSignIn.mockResolvedValue(undefined) +}) + +describe('offline, signed in, without the highlights grant', () => { + it('paints nothing and persists no queued write — the mint fails before any browser opens', async () => { + const { result } = renderFlow() + await flush() + + const { promise: outcome } = await startApply(result) + + // The tap opened the consent prompt. Nothing has been painted or persisted: + // a Pending Highlight waits on a permission, not on the server. + expect(result.current.isConfirming).toBe(true) + expect(paintedColors(result)).toEqual({}) + expect(queuedWrites()).toBeNull() + + await act(async () => { + result.current.confirm() + }) + await flush() + + expect(await outcome).toEqual({ + status: 'error', + reason: 'transient', + message: NETWORK_DOWN, + failedVerses: [16], + succeededVerses: [], + }) + + // The mint was attempted and failed; the browser was never opened, so the + // user never saw a consent page they could not have completed. + expect(mockMintToken).toHaveBeenCalledWith('token-1', ['highlights']) + expect(mockOpenAuthSession).not.toHaveBeenCalled() + + // The rejected alternative (ADR 0018): queue it optimistically and let the + // drain un-paint it later, with no explanation. + expect(paintedColors(result)).toEqual({}) + expect(queuedWrites()).toBeNull() + expect(hasQueuedHighlightWrites(userId)).toBe(false) + expect(mockCreateHighlight).not.toHaveBeenCalled() + }) + + it('discards the pending highlight rather than persisting it — a remount shows nothing', async () => { + const { result, unmount } = renderFlow() + await flush() + + const { promise: outcome } = await startApply(result) + await act(async () => { + result.current.confirm() + }) + await flush() + await outcome + unmount() + + const remounted = renderFlow() + await flush() + + expect(paintedColors(remounted.result)).toEqual({}) + expect(hasQueuedHighlightWrites(userId)).toBe(false) + }) + + it('reports the failure through the existing flow error surface, unchanged', async () => { + const { result } = renderFlow() + await flush() + + const { promise: outcome } = await startApply(result) + await act(async () => { + result.current.confirm() + }) + await flush() + await outcome + + expect(result.current.isConfirming).toBe(false) + expect(result.current.flowError).toEqual({ reason: 'transient', message: NETWORK_DOWN }) + }) + + it('leaves nothing behind when the user declines the prompt instead', async () => { + const { result } = renderFlow() + await flush() + + const { promise: outcome } = await startApply(result) + await act(async () => { + result.current.decline() + }) + await flush() + + expect(await outcome).toEqual({ status: 'noop' }) + expect(result.current.flowError).toBeNull() + expect(paintedColors(result)).toEqual({}) + expect(hasQueuedHighlightWrites(userId)).toBe(false) + expect(mockMintToken).not.toHaveBeenCalled() + }) +}) + +describe('offline, signed in, WITH the cached highlights grant', () => { + // The contrast that keeps the rule straight. This user sails past the flow + // entirely and lands in an ordinary write that fails at the network and parks. + // + // It works only because the CACHED grant is taken at its word (ADR 0014). Make + // the pre-flight confirm that hint with the server and offline highlighting + // stops working for every user — these two tests are what fail when it does. + // See `hasPermission` above for the half of ADR 0014 pinned elsewhere. + beforeEach(() => { + saveGrantedPermissions(userId, ['highlights']) + }) + + it('writes straight through and queues normally, on the strength of the cached grant alone', async () => { + const { result } = renderFlow() + await flush() + + const { promise: outcome } = await startApply(result) + + // Checked before the outcome is awaited, and deliberately: a pre-flight that + // went asking the server would park here waiting on a consent nobody can + // reach, and awaiting first would report that as a five-second timeout + // instead of as the assertion it is. + expect(result.current.isConfirming).toBe(false) + + await flush() + + expect(await outcome).toEqual({ status: 'queued', verses: [16] }) + + // Painted and owed: the write is parked, not lost. + expect(paintedColors(result)).toEqual({ 'JHN.3.16': YELLOW }) + expect(queuedWrites()).toEqual({ 16: { local: YELLOW, server: null } }) + + // No flow ran at all — no prompt, no mint, no browser. + expect(result.current.isConfirming).toBe(false) + expect(result.current.flowError).toBeNull() + expect(mockMintToken).not.toHaveBeenCalled() + expect(mockOpenAuthSession).not.toHaveBeenCalled() + }) + + // Also the positive control for the ungranted user's remount above: without a + // case where a remount *does* repaint, that test's empty result proves nothing. + it('keeps the parked write across a relaunch', async () => { + const { result, unmount } = renderFlow() + await flush() + const { promise } = await startApply(result) + await promise + unmount() + + const remounted = renderFlow() + await flush() + + expect(paintedColors(remounted.result)).toEqual({ 'JHN.3.16': YELLOW }) + expect(hasQueuedHighlightWrites(userId)).toBe(true) + }) +}) + +describe('offline, not signed in', () => { + beforeEach(() => { + signedIn = false + }) + + it('takes the sign-in branch exactly as it does online, and persists nothing', async () => { + const { result } = renderFlow() + await flush() + + const { promise: outcome } = await startApply(result) + await flush() + + // Sign-in was attempted and the user is still signed out, so the flow ends + // with nothing to report — the same path as a cancelled sign-in online. + expect(mockSignIn).toHaveBeenCalledTimes(1) + expect(await outcome).toEqual({ status: 'noop' }) + expect(result.current.flowError).toBeNull() + + // Consent never came up, so the mint was never reached. + expect(mockMintToken).not.toHaveBeenCalled() + expect(mockOpenAuthSession).not.toHaveBeenCalled() + + // Nothing is keyed under the signed-out user, and nothing under the user who + // would be signed in if they had completed it. + expect(hasQueuedHighlightWrites(userId)).toBe(false) + expect(paintedColors(result)).toEqual({}) + }) +}) diff --git a/packages/core/src/highlights/__tests__/optimistic.test.ts b/packages/core/src/highlights/__tests__/optimistic.test.ts index 6abea215..c646d3b1 100644 --- a/packages/core/src/highlights/__tests__/optimistic.test.ts +++ b/packages/core/src/highlights/__tests__/optimistic.test.ts @@ -1,19 +1,23 @@ import { deriveServerColors } from '../cache' -import { HIGHLIGHT_COLORS, isHighlightColor, type HighlightScope } from '../constants' +import { + HIGHLIGHT_COLORS, + isHighlightColor, + type HighlightScope, + type QueuedWrites, +} from '../constants' import { - claim, collapseVerseRuns, + confirm, createOptimisticState, - createWriteToken, formatPassageId, normalizeVerseSelection, + paint, + restore, selectHighlights, - selectMergedColors, selectVersesInColor, serverColorsEqual, serverUpdated, - settle, shouldRetire, versesInRun, type OptimisticState, @@ -36,8 +40,10 @@ const YELLOW = 'fffe00' const GREEN = '5dff79' const BLUE = '00d6ff' -function stateWith(serverColors: Record = {}): OptimisticState { - return createOptimisticState({ scope, userId: 'user-1', serverColors }) +const NO_QUEUE: QueuedWrites = {} + +function stateWith(colors: Record = {}): OptimisticState { + return createOptimisticState({ scope, userId: 'user-1', colors }) } describe('the highlight palette', () => { @@ -57,335 +63,213 @@ describe('the highlight palette', () => { }) }) -describe('claim', () => { - it('paints an apply, stamps ownership, and drops a pending reconcile', () => { - const token = createWriteToken('apply') - const claimed = claim(stateWith({ 16: GREEN }), [16, 17], token, YELLOW) - - expect(claimed.overlay).toEqual({ 16: YELLOW, 17: YELLOW }) - expect(claimed.writeIntent.get(16)).toBe(token) - expect(claimed.writeIntent.get(17)).toBe(token) - expect(selectMergedColors(claimed)).toEqual({ 16: YELLOW, 17: YELLOW }) +describe('paint', () => { + it('paints an apply over whatever was there', () => { + expect(paint(stateWith({ 16: GREEN }), [16, 17], YELLOW).colors).toEqual({ + 16: YELLOW, + 17: YELLOW, + }) }) - it('paints a remove as a null overlay entry that hides server truth', () => { - const claimed = claim( - stateWith({ 16: YELLOW, 17: GREEN }), - [16], - createWriteToken('remove'), - null, - ) - - expect(claimed.overlay).toEqual({ 16: null }) - expect(selectMergedColors(claimed)).toEqual({ 17: GREEN }) + it('paints a remove as an absence', () => { + expect(paint(stateWith({ 16: YELLOW, 17: GREEN }), [16], null).colors).toEqual({ 17: GREEN }) }) it('supersedes a pending reconcile entry for the same verse', () => { - const first = createWriteToken('apply') - const claimed = claim(stateWith(), [16], first, YELLOW) - const settled = settle(claimed, { - token: first, + const confirmed = confirm(paint(stateWith(), [16], YELLOW), { op: 'apply', color: YELLOW, - succeededVerses: [16], - failedVerses: [], + verses: [16], }) - expect(settled.reconcile.has(16)).toBe(true) + expect(confirmed.reconcile.has(16)).toBe(true) - const reclaimed = claim(settled, [16], createWriteToken('apply'), GREEN) - expect(reclaimed.reconcile.has(16)).toBe(false) + expect(paint(confirmed, [16], GREEN).reconcile.has(16)).toBe(false) }) it('returns the same state for an empty verse list', () => { const state = stateWith({ 16: YELLOW }) - expect(claim(state, [], createWriteToken('apply'), YELLOW)).toBe(state) + expect(paint(state, [], YELLOW)).toBe(state) }) }) -describe('settle', () => { - it('keeps paint for succeeded verses and registers them for reconciliation', () => { - const token = createWriteToken('apply') - const claimed = claim(stateWith(), [16, 17], token, YELLOW) +describe('confirm', () => { + it('registers accepted verses for reconciliation without touching the paint', () => { + const painted = paint(stateWith(), [16, 17], YELLOW) + const confirmed = confirm(painted, { op: 'apply', color: YELLOW, verses: [16, 17] }) - const settled = settle(claimed, { - token, - op: 'apply', - color: YELLOW, - succeededVerses: [16, 17], - failedVerses: [], - }) - - expect(settled.overlay).toEqual({ 16: YELLOW, 17: YELLOW }) - expect(settled.reconcile.get(16)).toEqual({ op: 'apply', color: YELLOW }) - // Settled writes release their claim so intents cannot accumulate. - expect(settled.writeIntent.size).toBe(0) + expect(confirmed.colors).toEqual({ 16: YELLOW, 17: YELLOW }) + expect(confirmed.reconcile.get(16)).toEqual({ op: 'apply', color: YELLOW }) }) - it('reverts paint for failed verses', () => { - const token = createWriteToken('apply') - const claimed = claim(stateWith({ 16: GREEN }), [16], token, YELLOW) - - const settled = settle(claimed, { - token, - op: 'apply', - color: YELLOW, - succeededVerses: [], - failedVerses: [16], - }) - - expect(settled.overlay).toEqual({}) - expect(selectMergedColors(settled)).toEqual({ 16: GREEN }) + it('returns the same state for an empty verse list', () => { + const state = stateWith({ 16: YELLOW }) + expect(confirm(state, { op: 'apply', color: YELLOW, verses: [] })).toBe(state) }) +}) - it('restores the highlight when a remove fails', () => { - const token = createWriteToken('remove') - const claimed = claim(stateWith({ 16: YELLOW }), [16], token, null) - expect(selectMergedColors(claimed)).toEqual({}) - - const settled = settle(claimed, { - token, - op: 'remove', - color: YELLOW, - succeededVerses: [], - failedVerses: [16], - }) - - expect(selectMergedColors(settled)).toEqual({ 16: YELLOW }) +describe('restore', () => { + it('puts back the color the server had', () => { + const painted = paint(stateWith({ 16: GREEN }), [16], YELLOW) + expect(restore(painted, { restored: { 16: GREEN }, cleared: [] }).colors).toEqual({ 16: GREEN }) }) - it('splits a partial batch: succeeded verses hold, failed verses revert', () => { - const token = createWriteToken('apply') - const claimed = claim(stateWith(), [16, 20], token, YELLOW) - - const settled = settle(claimed, { - token, - op: 'apply', - color: YELLOW, - succeededVerses: [16], - failedVerses: [20], - }) - - expect(selectMergedColors(settled)).toEqual({ 16: YELLOW }) - expect(settled.reconcile.get(16)).toEqual({ op: 'apply', color: YELLOW }) - expect(settled.reconcile.has(20)).toBe(false) + it('un-paints a verse the server had nothing for', () => { + const painted = paint(stateWith(), [16], YELLOW) + expect(restore(painted, { restored: {}, cleared: [16] }).colors).toEqual({}) }) - // AC 4 — the ownership token. This is the whole reason writeIntent exists. - it('does not clobber a newer claim when an older write fails (ownership token)', () => { - const yellowToken = createWriteToken('apply') - const greenToken = createWriteToken('apply') - - // Tap yellow on 16, then tap green on 16 before yellow's POST returns. - let state = claim(stateWith(), [16], yellowToken, YELLOW) - state = claim(state, [16], greenToken, GREEN) - expect(selectMergedColors(state)).toEqual({ 16: GREEN }) - - // Now yellow's POST fails. It no longer owns verse 16. - const settled = settle(state, { - token: yellowToken, - op: 'apply', - color: YELLOW, - succeededVerses: [], - failedVerses: [16], + it('restores the highlight a failed remove hid', () => { + const painted = paint(stateWith({ 16: YELLOW }), [16], null) + expect(painted.colors).toEqual({}) + expect(restore(painted, { restored: { 16: YELLOW }, cleared: [] }).colors).toEqual({ + 16: YELLOW, }) - - expect(selectMergedColors(settled)).toEqual({ 16: GREEN }) - expect(settled.writeIntent.get(16)).toBe(greenToken) - // Nothing this op owned, so the state object is untouched. - expect(settled).toBe(state) }) - it('does not register a reconcile entry for a verse it no longer owns', () => { - const first = createWriteToken('apply') - const second = createWriteToken('apply') - let state = claim(stateWith(), [16], first, YELLOW) - state = claim(state, [16], second, GREEN) - - const settled = settle(state, { - token: first, - op: 'apply', - color: YELLOW, - succeededVerses: [16], - failedVerses: [], - }) - - expect(settled.reconcile.has(16)).toBe(false) - expect(settled.writeIntent.get(16)).toBe(second) + it('returns the same state when there is nothing to put back', () => { + const state = stateWith({ 16: YELLOW }) + expect(restore(state, { restored: {}, cleared: [] })).toBe(state) + expect(restore(state, { restored: { 16: YELLOW }, cleared: [] })).toBe(state) }) }) describe('reset (createOptimisticState)', () => { - it('clears overlay, reconcile and write intents, and re-seeds identity', () => { - const applyToken = createWriteToken('apply') - const pending = createWriteToken('apply') - let state = settle(claim(stateWith(), [16], applyToken, YELLOW), { - token: applyToken, + it('clears reconciliation and re-seeds identity', () => { + const state = confirm(paint(stateWith(), [16], YELLOW), { op: 'apply', color: YELLOW, - succeededVerses: [16], - failedVerses: [], + verses: [16], }) - // A second write is still in flight when the user navigates away. - state = claim(state, [20], pending, GREEN) expect(state.reconcile.size).toBe(1) - expect(state.writeIntent.size).toBe(1) const nextScope: HighlightScope = { versionId: 111, book: 'JHN', chapter: '4' } const reset = createOptimisticState({ scope: nextScope, userId: 'user-2', - serverColors: { 1: BLUE }, + colors: { 1: BLUE }, }) expect(reset.scope).toEqual(nextScope) expect(reset.userId).toBe('user-2') - expect(reset.overlay).toEqual({}) + expect(reset.colors).toEqual({ 1: BLUE }) expect(reset.reconcile.size).toBe(0) - // Clearing writeIntent is what stops the in-flight write from settling onto - // a colliding verse number in the new scope. - expect(reset.writeIntent.size).toBe(0) - expect( - settle(reset, { - token: pending, - op: 'apply', - color: GREEN, - succeededVerses: [20], - failedVerses: [], - }), - ).toBe(reset) }) }) describe('serverUpdated', () => { - it('retires an apply overlay once the server reports the written color', () => { - const token = createWriteToken('apply') - const claimed = claim(stateWith(), [16], token, YELLOW) - const settled = settle(claimed, { - token, + it('retires a confirmed apply once the server reports the written color', () => { + const state = confirm(paint(stateWith(), [16], YELLOW), { op: 'apply', color: YELLOW, - succeededVerses: [16], - failedVerses: [], + verses: [16], }) - const reconciled = serverUpdated(settled, { 16: YELLOW }) + const reconciled = serverUpdated(state, { 16: YELLOW }, NO_QUEUE) - expect(reconciled.overlay).toEqual({}) expect(reconciled.reconcile.size).toBe(0) - expect(selectMergedColors(reconciled)).toEqual({ 16: YELLOW }) + expect(reconciled.colors).toEqual({ 16: YELLOW }) }) - it('holds an apply overlay while the server still reports the old color', () => { - const token = createWriteToken('apply') - const settled = settle(claim(stateWith({ 16: GREEN }), [16], token, YELLOW), { - token, + it('holds a confirmed apply while the server still reports the old color', () => { + const state = confirm(paint(stateWith({ 16: GREEN }), [16], YELLOW), { op: 'apply', color: YELLOW, - succeededVerses: [16], - failedVerses: [], + verses: [16], }) - const reconciled = serverUpdated(settled, { 16: GREEN }) - - expect(reconciled.overlay).toEqual({ 16: YELLOW }) - expect(selectMergedColors(reconciled)).toEqual({ 16: YELLOW }) + expect(serverUpdated(state, { 16: GREEN }, NO_QUEUE).colors).toEqual({ 16: YELLOW }) }) // AC 5 — the vapor fix. A stale read replica echoing the color we just // deleted must not resurrect the highlight. it('never resurrects a removed verse when a stale fetch echoes the deleted color', () => { - const token = createWriteToken('remove') - const settled = settle(claim(stateWith({ 16: YELLOW }), [16], token, null), { - token, + const state = confirm(paint(stateWith({ 16: YELLOW }), [16], null), { op: 'remove', color: YELLOW, - succeededVerses: [16], - failedVerses: [], + verses: [16], }) // The replica has not caught up: it still reports the yellow we deleted. - const reconciled = serverUpdated(settled, { 16: YELLOW }) + const reconciled = serverUpdated(state, { 16: YELLOW }, NO_QUEUE) - expect(reconciled.overlay).toEqual({ 16: null }) - expect(selectMergedColors(reconciled)).toEqual({}) + expect(reconciled.colors).toEqual({}) // Still held — a later fetch gets another chance to confirm it. expect(reconciled.reconcile.has(16)).toBe(true) }) // The other half of the color-aware retirement pair: our deliberate // divergence from web, which would suppress this repaint indefinitely. - it('retires a remove overlay when the server reports a DIFFERENT color', () => { - const token = createWriteToken('remove') - const settled = settle(claim(stateWith({ 16: YELLOW }), [16], token, null), { - token, + it('retires a confirmed remove when the server reports a DIFFERENT color', () => { + const state = confirm(paint(stateWith({ 16: YELLOW }), [16], null), { op: 'remove', color: YELLOW, - succeededVerses: [16], - failedVerses: [], + verses: [16], }) // Another device set green on this verse after our delete landed. A green // echo cannot be vapor from deleting yellow, so it is newer data. - const reconciled = serverUpdated(settled, { 16: GREEN }) + const reconciled = serverUpdated(state, { 16: GREEN }, NO_QUEUE) - expect(reconciled.overlay).toEqual({}) - expect(selectMergedColors(reconciled)).toEqual({ 16: GREEN }) + expect(reconciled.colors).toEqual({ 16: GREEN }) + expect(reconciled.reconcile.has(16)).toBe(false) }) - it('retires a remove overlay once the verse is genuinely gone server-side', () => { - const token = createWriteToken('remove') - const settled = settle(claim(stateWith({ 16: YELLOW }), [16], token, null), { - token, + it('paints nothing once the verse is genuinely gone server-side', () => { + const state = confirm(paint(stateWith({ 16: YELLOW }), [16], null), { op: 'remove', color: YELLOW, - succeededVerses: [16], - failedVerses: [], + verses: [16], }) - // The plan's rule holds the overlay while the deleted color is echoed; an - // absent verse is not an echo, but it also is not "a different color" — the - // overlay stays until the server stops lying, and the rendered result is - // identical either way. - const reconciled = serverUpdated(settled, {}) - expect(selectMergedColors(reconciled)).toEqual({}) + // The rule holds the entry while the deleted color is echoed; an absent + // verse is not an echo, but it also is not "a different color" — the entry + // stays until the server stops lying, and the paint is identical either way. + expect(serverUpdated(state, {}, NO_QUEUE).colors).toEqual({}) + }) + + it('re-applies unsent writes over fresh server truth', () => { + const state = stateWith({ 16: YELLOW }) + const queued: QueuedWrites = { + 16: { local: GREEN, server: YELLOW }, + 20: { local: null, server: BLUE }, + } + + expect(serverUpdated(state, { 16: YELLOW, 20: BLUE }, queued).colors).toEqual({ 16: GREEN }) + }) + + it('lets an unsent write win over a confirmed one for the same verse', () => { + const state = confirm(paint(stateWith(), [16], YELLOW), { + op: 'apply', + color: YELLOW, + verses: [16], + }) + const queued: QueuedWrites = { 16: { local: GREEN, server: YELLOW } } + + expect(serverUpdated(state, {}, queued).colors).toEqual({ 16: GREEN }) }) it('returns the same object when nothing changed (bridge stability)', () => { const state = stateWith({ 16: YELLOW }) - expect(serverUpdated(state, { 16: YELLOW })).toBe(state) + expect(serverUpdated(state, { 16: YELLOW }, NO_QUEUE)).toBe(state) }) it('returns a new object when server colors change', () => { const state = stateWith({ 16: YELLOW }) - const next = serverUpdated(state, { 16: GREEN }) + const next = serverUpdated(state, { 16: GREEN }, NO_QUEUE) expect(next).not.toBe(state) - expect(next.serverColors).toEqual({ 16: GREEN }) + expect(next.colors).toEqual({ 16: GREEN }) }) it('keeps holding entries that did not retire while retiring the ones that did', () => { - const applyToken = createWriteToken('apply') - const removeToken = createWriteToken('remove') let state = stateWith({ 20: BLUE }) - state = settle(claim(state, [16], applyToken, YELLOW), { - token: applyToken, - op: 'apply', - color: YELLOW, - succeededVerses: [16], - failedVerses: [], - }) - state = settle(claim(state, [20], removeToken, null), { - token: removeToken, - op: 'remove', - color: BLUE, - succeededVerses: [20], - failedVerses: [], - }) + state = confirm(paint(state, [16], YELLOW), { op: 'apply', color: YELLOW, verses: [16] }) + state = confirm(paint(state, [20], null), { op: 'remove', color: BLUE, verses: [20] }) - const reconciled = serverUpdated(state, { 16: YELLOW, 20: BLUE }) + const reconciled = serverUpdated(state, { 16: YELLOW, 20: BLUE }, NO_QUEUE) expect(reconciled.reconcile.has(16)).toBe(false) // apply confirmed expect(reconciled.reconcile.has(20)).toBe(true) // remove echo held - expect(selectMergedColors(reconciled)).toEqual({ 16: YELLOW }) + expect(reconciled.colors).toEqual({ 16: YELLOW }) }) }) @@ -416,7 +300,7 @@ describe('serverColorsEqual', () => { describe('selectHighlights', () => { it('emits one per-verse highlight, ascending', () => { - const state = claim(stateWith({ 20: BLUE }), [16, 17], createWriteToken('apply'), YELLOW) + const state = paint(stateWith({ 20: BLUE }), [16, 17], YELLOW) expect(selectHighlights(state)).toEqual([ { version_id: 111, passage_id: 'JHN.3.16', color: YELLOW }, @@ -425,21 +309,16 @@ describe('selectHighlights', () => { ]) }) - it('omits verses the overlay removed', () => { - const state = claim( - stateWith({ 16: YELLOW, 17: GREEN }), - [16], - createWriteToken('remove'), - null, - ) + it('omits removed verses', () => { + const state = paint(stateWith({ 16: YELLOW, 17: GREEN }), [16], null) expect(selectHighlights(state)).toEqual([ { version_id: 111, passage_id: 'JHN.3.17', color: GREEN }, ]) }) it('round-trips exactly through deriveServerColors', () => { - const state = claim(stateWith({ 20: BLUE }), [16, 17], createWriteToken('apply'), YELLOW) - expect(deriveServerColors(selectHighlights(state), scope)).toEqual(selectMergedColors(state)) + const state = paint(stateWith({ 20: BLUE }), [16, 17], YELLOW) + expect(deriveServerColors(selectHighlights(state), scope)).toEqual(state.colors) }) // Defensive contract test, NOT a production path: the API stores highlights @@ -450,10 +329,9 @@ describe('selectHighlights', () => { [{ version_id: 111, passage_id: 'JHN.3.16-18', color: YELLOW }], scope, ) - const state = claim( - createOptimisticState({ scope, userId: 'user-1', serverColors: fromRange }), + const state = paint( + createOptimisticState({ scope, userId: 'user-1', colors: fromRange }), [17], - createWriteToken('remove'), null, ) @@ -471,13 +349,13 @@ describe('selectVersesInColor', () => { }) it('counts optimistic paint, not just server truth', () => { - const state = claim(stateWith({ 16: BLUE }), [16], createWriteToken('apply'), YELLOW) + const state = paint(stateWith({ 16: BLUE }), [16], YELLOW) expect(selectVersesInColor(state, [16], YELLOW)).toEqual([16]) expect(selectVersesInColor(state, [16], BLUE)).toEqual([]) }) it('ignores verses an optimistic remove has already hidden', () => { - const state = claim(stateWith({ 16: YELLOW }), [16], createWriteToken('remove'), null) + const state = paint(stateWith({ 16: YELLOW }), [16], null) expect(selectVersesInColor(state, [16], YELLOW)).toEqual([]) }) }) @@ -494,11 +372,11 @@ describe('USFM range helpers', () => { }) it('drops non-positive and non-integer verse numbers', () => { - expect(collapseVerseRuns([0, -1, 2, 3.5])).toEqual([{ start: 2, end: 2 }]) + expect(collapseVerseRuns([0, -1, 1.5, 2])).toEqual([{ start: 2, end: 2 }]) }) it('formats a run as a range USFM, collapsing single verses', () => { - expect(formatPassageId('JHN', '3', { start: 16, end: 18 })).toBe('JHN.3.16-18') + expect(formatPassageId('JHN', '3', { start: 2, end: 3 })).toBe('JHN.3.2-3') expect(formatPassageId('JHN', '3', { start: 5, end: 5 })).toBe('JHN.3.5') }) @@ -508,7 +386,7 @@ describe('USFM range helpers', () => { }) it('normalizes a verse list through the same run machinery', () => { - expect(normalizeVerseSelection([18, 16, 16, 0, 17, 20])).toEqual([16, 17, 18, 20]) + expect(normalizeVerseSelection([18, 16, 17, 16, 0])).toEqual([16, 17, 18]) expect(normalizeVerseSelection([])).toEqual([]) }) }) diff --git a/packages/core/src/highlights/__tests__/use-highlights.test.tsx b/packages/core/src/highlights/__tests__/use-highlights.test.tsx index 40335dee..14cf71af 100644 --- a/packages/core/src/highlights/__tests__/use-highlights.test.tsx +++ b/packages/core/src/highlights/__tests__/use-highlights.test.tsx @@ -678,9 +678,9 @@ describe('apply', () => { }) // AC 3 - it('reverts the paint and returns a typed error when the write fails', async () => { + it('reverts the paint and returns a typed error when the server rejects the write', async () => { seedServer([highlight('JHN.3.16', GREEN)]) - mockCreateHighlight.mockResolvedValue(transient(500)) + mockCreateHighlight.mockResolvedValue(transient(422, 'boom')) const { result } = renderUseHighlights() await act(async () => { @@ -694,7 +694,7 @@ describe('apply', () => { expect(outcome).toEqual({ status: 'error', - reason: 'transient', + reason: 'invalid', message: 'boom', failedVerses: [16], succeededVerses: [], @@ -730,7 +730,9 @@ describe('apply', () => { expect(outcome).toMatchObject({ status: 'error', reason: 'invalid' }) }) - it('classifies a 5xx and a network failure as transient', async () => { + // A 5xx and an unreachable network are the same thing to the caller: the write + // did not land, and retrying it later may. + it('parks a 5xx and a network failure alike', async () => { mockCreateHighlight.mockResolvedValueOnce(transient(503)) const { result } = renderUseHighlights() @@ -738,14 +740,14 @@ describe('apply', () => { await act(async () => { first = await result.current.apply(YELLOW, [16]) }) - expect(first).toMatchObject({ reason: 'transient' }) + expect(first).toEqual({ status: 'queued', verses: [16] }) mockCreateHighlight.mockResolvedValueOnce(transient(undefined, 'Network request failed')) let second: HighlightWriteOutcome | undefined await act(async () => { - second = await result.current.apply(YELLOW, [16]) + second = await result.current.apply(YELLOW, [17]) }) - expect(second).toMatchObject({ reason: 'transient' }) + expect(second).toEqual({ status: 'queued', verses: [17] }) }) it('is a noop for an empty verse list, with no request', async () => { @@ -763,10 +765,10 @@ describe('apply', () => { // ── Partial batches ────────────────────────────────────────────────────────── describe('partial batches', () => { - it('retains succeeded verses, reverts failed ones, and reports both', async () => { + it('retains succeeded verses, reverts rejected ones, and reports both', async () => { mockCreateHighlight .mockResolvedValueOnce({ ok: true, value: highlight('JHN.3.16-17', YELLOW) }) - .mockResolvedValueOnce(transient(500)) + .mockResolvedValueOnce(transient(422, 'boom')) const { result } = renderUseHighlights() let outcome: HighlightWriteOutcome | undefined @@ -776,7 +778,7 @@ describe('partial batches', () => { expect(outcome).toEqual({ status: 'error', - reason: 'transient', + reason: 'invalid', message: 'boom', failedVerses: [20], succeededVerses: [16, 17], @@ -848,8 +850,9 @@ describe('overlapping writes', () => { expect(mockCreateHighlight).toHaveBeenCalledTimes(2) }) - // AC 4 — the ownership token, end to end. - it('a failed older write does not wipe the color a newer write painted', async () => { + // AC 4 — ownership, end to end. A settling write only touches entries still + // asking for what it sent, so a rejection cannot revert a newer intent. + it('a rejected older write does not wipe the color a newer write painted', async () => { const slowYellow = deferred>() mockCreateHighlight.mockReturnValueOnce(slowYellow.promise) @@ -863,6 +866,13 @@ describe('overlapping writes', () => { yellowOutcome = result.current.apply(YELLOW, [16]) }) + // Let yellow's POST actually go out. Without this it would be superseded + // before it sends, and the ownership guard below would never be exercised. + await act(async () => { + await Promise.resolve() + }) + expect(mockCreateHighlight).toHaveBeenCalledTimes(1) + // The user re-taps verse 16 in green before yellow's POST comes back. let greenOutcome: Promise | undefined act(() => { @@ -871,12 +881,12 @@ describe('overlapping writes', () => { expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) await act(async () => { - slowYellow.resolve(transient(500)) + slowYellow.resolve(transient(422)) await yellowOutcome await greenOutcome }) - // Yellow failed, but it no longer owned verse 16 — green survives. + // Yellow was rejected, but it no longer owned verse 16 — green survives. expect(await yellowOutcome).toMatchObject({ status: 'error', failedVerses: [16] }) expect(colorsOf(result.current)).toEqual({ 'JHN.3.16': GREEN }) }) @@ -969,9 +979,9 @@ describe('remove', () => { expect(mockDeleteHighlight).not.toHaveBeenCalled() }) - it('restores the highlight when the delete fails', async () => { + it('restores the highlight when the server rejects the delete', async () => { seedServer([highlight('JHN.3.16', YELLOW)]) - mockDeleteHighlight.mockResolvedValue(transient(500)) + mockDeleteHighlight.mockResolvedValue(transient(422)) const { result } = renderUseHighlights() await act(async () => { @@ -1009,7 +1019,11 @@ describe('remove', () => { // React, so nothing has re-rendered and the ref-sync effect has not run. If // the selection were read from the last committed render, this would no-op and // strand the highlight the apply just painted. - it('sees a claim made earlier in the same tick, before any re-render', async () => { + // + // Neither request goes out: the two writes cancel each other in the queue + // before either reaches the send path, so the server is never given a + // highlight only to be asked to delete it again. + it('sees a write made earlier in the same tick, before any re-render', async () => { const { result } = renderUseHighlights() await act(async () => { await Promise.resolve() @@ -1027,9 +1041,12 @@ describe('remove', () => { await removed }) - expect(await removed).toEqual({ status: 'ok', verses: [16] }) - expect(mockDeleteHighlight).toHaveBeenCalledWith('token-1', 'JHN.3.16', { version_id: 111 }) + // Both discriminate: had the remove read the last committed render it would + // target no verses, leaving the apply's paint on 16 and its entry alive to + // send. expect(result.current.highlights).toEqual([]) + expect(mockCreateHighlight).not.toHaveBeenCalled() + expect(mockDeleteHighlight).not.toHaveBeenCalled() }) }) @@ -1393,7 +1410,7 @@ describe('error surface', () => { it('never lets a failed write evict a fetch error that is still true', async () => { seedCache([highlight('JHN.3.16', GREEN)]) mockGetHighlights.mockResolvedValue(transient(500, 'fetch died')) - mockCreateHighlight.mockResolvedValue(transient(503, 'write died')) + mockCreateHighlight.mockResolvedValue(transient(422, 'write died')) const { result } = renderUseHighlights() await act(async () => { diff --git a/packages/core/src/highlights/backoff.ts b/packages/core/src/highlights/backoff.ts new file mode 100644 index 00000000..e4ef7d28 --- /dev/null +++ b/packages/core/src/highlights/backoff.ts @@ -0,0 +1,21 @@ +/** + * How long a queue entry waits before the drain tries it again. + * + * Exponential and capped, per ADR 0018: an entry the server permanently refuses + * is never dropped, so the only thing keeping it cheap is that it settles at one + * attempt an hour rather than one per drain tick. + */ + +const BASE_DELAY_MS = 10_000 +const GROWTH_FACTOR = 2 +const MAX_DELAY_MS = 60 * 60 * 1000 + +/** + * `failures` is the count of failures BEFORE the one being recorded — `drain.ts` + * reads the stored count, calls this, and increments in the same step. A fresh + * entry therefore passes 0, so the first failure waits the base delay. + */ +export function nextBackoffDelay(failures: number): number { + const steps = Number.isFinite(failures) ? Math.max(0, Math.trunc(failures)) : 0 + return Math.min(MAX_DELAY_MS, BASE_DELAY_MS * GROWTH_FACTOR ** steps) +} diff --git a/packages/core/src/highlights/cache.ts b/packages/core/src/highlights/cache.ts index 32cf74ad..b68f3468 100644 --- a/packages/core/src/highlights/cache.ts +++ b/packages/core/src/highlights/cache.ts @@ -7,6 +7,7 @@ import { type HighlightScope, type ServerColors, } from './constants' +import { highlightsFromColors } from './optimistic' export { highlightsCacheKey, @@ -155,10 +156,48 @@ export function setCachedHighlights( mmkvStorage.set(highlightsCacheKey(userId, scope), JSON.stringify(highlights)) } +/** + * Folds a landed write into the cached paint for one scope — a color for an + * apply, `null` for a remove. + * + * The drain has no `useHighlights` state to update: it may be landing a scope no + * hook is mounted on. Since the cache *is* the paint (ADR 0018), writing it here + * is what makes the landing survive to the next mount. + */ +export function mergeCachedHighlights( + userId: string, + scope: HighlightScope, + verses: readonly number[], + color: string | null, +): void { + if (!userId || verses.length === 0) { + return + } + const colors = deriveServerColors(getCachedHighlights(userId, scope) ?? [], scope) + for (const verse of verses) { + if (color === null) { + delete colors[verse] + } else { + colors[verse] = color + } + } + setCachedHighlights(userId, scope, highlightsFromColors(scope, colors)) +} + +/** + * Empties the cached paint for every user — sign-out's job. + * + * Never throws: sign-out purges before it clears the tokens, so an unreadable + * store costs stale paint, not a user who is still signed in. + */ export function clearHighlightsCache(): void { - for (const key of mmkvStorage.getAllKeys()) { - if (key.startsWith(MMKV_HIGHLIGHTS_KEY_PREFIX)) { - mmkvStorage.remove(key) + try { + for (const key of mmkvStorage.getAllKeys()) { + if (key.startsWith(MMKV_HIGHLIGHTS_KEY_PREFIX)) { + mmkvStorage.remove(key) + } } + } catch { + // Purge failed; sign-out continues. } } diff --git a/packages/core/src/highlights/claims.ts b/packages/core/src/highlights/claims.ts new file mode 100644 index 00000000..ed4c77c9 --- /dev/null +++ b/packages/core/src/highlights/claims.ts @@ -0,0 +1,51 @@ +/** + * Verses a mounted `useHighlights` is currently sending. + * + * Queue-first writes leave an entry in MMKV for the whole life of a write, so + * the queue alone cannot tell the drain which entries are already in hand. + * Without this the drain re-sends them, and a re-tap in that window races two + * paths that can settle in either order. + * + * One-directional: the drain defers to the hook, never the reverse. + */ + +import type { HighlightScope } from './constants' + +/** Refcounted — overlapping writes to one verse each hold their own claim. */ +const claims = new Map() + +function claimKey(userId: string, scope: HighlightScope, verse: number): string { + return `${userId}|${scope.versionId}|${scope.book}|${scope.chapter}|${verse}` +} + +/** Returns an idempotent release; settle and unmount may both call it. */ +export function claimWrites( + userId: string, + scope: HighlightScope, + verses: readonly number[], +): () => void { + const keys = verses.map((verse) => claimKey(userId, scope, verse)) + for (const key of keys) { + claims.set(key, (claims.get(key) ?? 0) + 1) + } + + let released = false + return () => { + if (released) { + return + } + released = true + for (const key of keys) { + const remaining = (claims.get(key) ?? 0) - 1 + if (remaining > 0) { + claims.set(key, remaining) + } else { + claims.delete(key) + } + } + } +} + +export function isWriteClaimed(userId: string, scope: HighlightScope, verse: number): boolean { + return claims.has(claimKey(userId, scope, verse)) +} diff --git a/packages/core/src/highlights/constants.ts b/packages/core/src/highlights/constants.ts index 571f187c..0e149c02 100644 --- a/packages/core/src/highlights/constants.ts +++ b/packages/core/src/highlights/constants.ts @@ -1,5 +1,8 @@ export const MMKV_HIGHLIGHTS_KEY_PREFIX = 'yvp.highlights.' as const +/** Distinct from the cache prefix, so sign-out purges the queue by its own call, not by a prefix match. */ +export const MMKV_HIGHLIGHT_QUEUE_KEY_PREFIX = 'yvp.highlightqueue.' as const + /** * The five highlight swatches, a company-wide standard across every YouVersion * SDK. Custom colors are not supported by the product, so both write paths @@ -30,6 +33,18 @@ export type HighlightScope = { export type ServerColors = Record +export type QueuedWrite = { + local: string | null + /** + * What the server had before the user started editing this verse, restored if + * the write is rejected. Survives a later write to the same verse. + */ + server: string | null +} + +/** Verse number -> its unsent write. An entry where the two states agree is dropped. */ +export type QueuedWrites = Record + /** * One copy of the message, shared by the write path and the permission flow that * wraps it. Both can report the same refusal, and two drifting copies of a @@ -41,3 +56,19 @@ export const NOT_SIGNED_IN_MESSAGE = export function highlightsCacheKey(userId: string, scope: HighlightScope): string { return `${MMKV_HIGHLIGHTS_KEY_PREFIX}${userId}.${scope.versionId}.${scope.book}.${scope.chapter}` } + +/** + * Every queue key belonging to one user, and the only thing the prefix scans in + * `queue.ts` may match on. Built here rather than at each scan so it cannot + * disagree with {@link highlightQueueKey}: a scan that stops matching fails + * silently in both directions — the drain finds no scope to send, and sign-out + * reports nothing to lose. + */ +export function highlightQueueUserPrefix(userId: string): string { + return `${MMKV_HIGHLIGHT_QUEUE_KEY_PREFIX}${userId}.` +} + +/** Keyed like the cache, so a tap rewrites one chapter's slice, not a global blob. */ +export function highlightQueueKey(userId: string, scope: HighlightScope): string { + return `${highlightQueueUserPrefix(userId)}${scope.versionId}.${scope.book}.${scope.chapter}` +} diff --git a/packages/core/src/highlights/drain-signals.ts b/packages/core/src/highlights/drain-signals.ts new file mode 100644 index 00000000..641273a6 --- /dev/null +++ b/packages/core/src/highlights/drain-signals.ts @@ -0,0 +1,31 @@ +/** + * The two things only the write path knows and the drain needs to hear. + * + * Not a queue subscription: queue-first writes persist an entry for every tap, + * so observing storage would wake the drain on writes a mounted hook is already + * sending. These fire only when the drain has something to do. + */ + +/** + * `service-reached` — a request just came back, so the network is up; try + * everything due, now. `write-parked` — a write is owed; start the backoff loop + * rather than waiting on a foreground, which may never come. + */ +export type DrainSignal = 'service-reached' | 'write-parked' + +export type DrainSignalListener = (signal: DrainSignal) => void + +const listeners = new Set() + +export function onDrainSignal(listener: DrainSignalListener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function notifyDrain(signal: DrainSignal): void { + for (const listener of [...listeners]) { + listener(signal) + } +} diff --git a/packages/core/src/highlights/drain.ts b/packages/core/src/highlights/drain.ts new file mode 100644 index 00000000..614c4ee0 --- /dev/null +++ b/packages/core/src/highlights/drain.ts @@ -0,0 +1,351 @@ +/** + * Sends Queued Writes the write path could not. See ADR 0018. + * + * Lives at the provider, not in `useHighlights`, because a parked write outlives + * the chapter it was made in: the queue is the only record of it, and after a + * relaunch nothing in memory remembers the scope. + */ + +import { nextBackoffDelay } from './backoff' +import { mergeCachedHighlights } from './cache' +import { isWriteClaimed } from './claims' +import { toWriteUnits } from './optimistic' +import { dropRejectedWrites, dropWrites, getQueuedWrites, listQueuedScopes } from './queue' +import type { HighlightsApi } from './api' +import type { HighlightScope } from './constants' + +/** Read fresh on every pass: a sign-out mid-drain must stop it. */ +export type DrainAuth = { + userId: string | null + accessToken: string | null + ensureFreshToken: (() => Promise) | null + /** Unconditional mint, for the one retry a refusal earns. */ + refreshNow: (() => Promise) | null +} + +export type HighlightQueueDrain = { + /** Try everything due now. Coalesces while a pass is running. */ + drainNow: () => void + /** A write just failed; start the retry clock without re-sending it. */ + noteParkedWrite: () => void + stop: () => void +} + +type BackoffRecord = { failures: number; nextAttemptAt: number } + +export function startHighlightQueueDrain(deps: { + api: HighlightsApi + getAuth: () => DrainAuth +}): HighlightQueueDrain { + const { api, getAuth } = deps + + /** + * In memory: a relaunch is itself a drain trigger, so persisting the wait would + * only deny a fresh start the attempt it is entitled to. + */ + const backoff = new Map() + + let stopped = false + let running = false + let rerun = false + let timer: ReturnType | null = null + + function backoffKey(scope: HighlightScope, verse: number): string { + return `${scope.versionId}|${scope.book}|${scope.chapter}|${verse}` + } + + function noteFailure(scope: HighlightScope, verses: readonly number[]): void { + const now = Date.now() + for (const verse of verses) { + const key = backoffKey(scope, verse) + const failures = backoff.get(key)?.failures ?? 0 + backoff.set(key, { failures: failures + 1, nextAttemptAt: now + nextBackoffDelay(failures) }) + } + } + + /** + * Settles a write the server took: the paint is now server truth, so the entry + * is no longer owed. The counterpart is {@link revert}; between them they are + * the only two ways an entry leaves this drain. + */ + function land( + userId: string, + scope: HighlightScope, + verses: readonly number[], + color: string | null, + ): void { + const owed = getQueuedWrites(userId, scope) + const landed = verses.filter((verse) => owed[verse]?.local === color) + if (landed.length > 0) { + // Cache before queue: a crash between the two must leave the write owed + // rather than leave the paint gone. + mergeCachedHighlights(userId, scope, landed, color) + dropWrites({ userId, scope, verses: landed, color }) + } + for (const verse of verses) { + backoff.delete(backoffKey(scope, verse)) + } + } + + /** + * Settles a write the server refused twice by taking its paint back to the + * entry's `server` side, and tells mounted readers so the verse un-paints + * without a remount. The counterpart to {@link land}. See ADR 0018. + */ + function revert( + userId: string, + scope: HighlightScope, + verses: readonly number[], + color: string | null, + ): void { + const owed = getQueuedWrites(userId, scope) + const byServer = new Map() + for (const verse of verses) { + const entry = owed[verse] + if (entry?.local === color) { + push(byServer, entry.server, verse) + } + } + + // Cache before queue, as `land` does — here the cache write is the un-paint. + for (const [server, group] of byServer) { + mergeCachedHighlights(userId, scope, group, server) + } + dropRejectedWrites({ userId, scope, verses, color }) + + for (const verse of verses) { + backoff.delete(backoffKey(scope, verse)) + } + } + + /** + * A fresh token for the retry, or `null` if there is no route to one — no + * refresh to call, one that threw, or one that ended the session this write + * belongs to. Only a refusal of a genuinely minted token may revert. + */ + async function forcedToken(userId: string): Promise { + const refreshNow = getAuth().refreshNow + if (refreshNow === null) { + return null + } + try { + await refreshNow() + } catch { + return null + } + const next = getAuth() + return stopped || next.userId !== userId ? null : next.accessToken + } + + async function sendColor( + auth: { userId: string; accessToken: string }, + scope: HighlightScope, + color: string | null, + verses: readonly number[], + ): Promise { + const send = (token: string, passageId: string) => + color === null + ? api.deleteHighlight(token, passageId, { version_id: scope.versionId }) + : api.createHighlight(token, { + version_id: scope.versionId, + passage_id: passageId, + color, + }) + + await Promise.all( + toWriteUnits(scope, verses, color).map(async (unit) => { + const result = await send(auth.accessToken, unit.passageId) + if (result.ok) { + land(auth.userId, scope, unit.verses, color) + return + } + if (result.error.kind !== 'auth') { + noteFailure(scope, unit.verses) + return + } + + // The pass already refreshed on leeway, so a refusal means that read was + // wrong. Mint unconditionally and state the write once more; only a + // second refusal is the server saying it will never take it. + const token = await forcedToken(auth.userId) + if (token === null) { + noteFailure(scope, unit.verses) + return + } + + const retry = await send(token, unit.passageId) + if (retry.ok) { + land(auth.userId, scope, unit.verses, color) + } else if (retry.error.kind === 'auth') { + revert(auth.userId, scope, unit.verses, color) + } else { + noteFailure(scope, unit.verses) + } + }), + ) + } + + async function drainScope( + auth: { userId: string; accessToken: string }, + scope: HighlightScope, + ): Promise { + const queued = getQueuedWrites(auth.userId, scope) + const now = Date.now() + const due = new Map() + const satisfied = new Map() + + for (const [verseKey, entry] of Object.entries(queued)) { + const verse = Number(verseKey) + if (entry.local === entry.server) { + push(satisfied, entry.local, verse) + } else if ( + !isWriteClaimed(auth.userId, scope, verse) && + (backoff.get(backoffKey(scope, verse))?.nextAttemptAt ?? 0) <= now + ) { + push(due, entry.local, verse) + } + } + + for (const [color, verses] of satisfied) { + dropWrites({ userId: auth.userId, scope, verses, color }) + } + await Promise.all([...due].map(([color, verses]) => sendColor(auth, scope, color, verses))) + } + + async function drainDue(): Promise { + const initial = getAuth() + if (initial.userId === null || initial.accessToken === null) { + return + } + if (listQueuedScopes(initial.userId).length === 0) { + return + } + + await initial.ensureFreshToken?.() + + for (const scope of listQueuedScopes(initial.userId)) { + // Re-read per scope: a sign-out or user switch mid-pass must not send the + // departed user's passage under whatever token is current now. + const auth = getAuth() + if (stopped || auth.userId !== initial.userId || auth.accessToken === null) { + return + } + await drainScope({ userId: auth.userId, accessToken: auth.accessToken }, scope) + } + } + + async function runPass(): Promise { + running = true + try { + do { + rerun = false + try { + await drainDue() + } catch { + // `ensureFreshToken` is documented not to throw, but a drain that dies + // here would stop rescheduling and park every write permanently. + } + } while (rerun && !stopped) + } finally { + running = false + } + scheduleNext() + } + + function scheduleNext(): void { + if (timer !== null) { + clearTimeout(timer) + timer = null + } + if (stopped) { + return + } + + const { userId } = getAuth() + const scopes = userId === null ? [] : listQueuedScopes(userId) + if (userId === null || scopes.length === 0) { + backoff.clear() + return + } + + const now = Date.now() + const live = new Set() + let soonest = Infinity + for (const scope of scopes) { + for (const verseKey of Object.keys(getQueuedWrites(userId, scope))) { + const key = backoffKey(scope, Number(verseKey)) + live.add(key) + soonest = Math.min(soonest, backoff.get(key)?.nextAttemptAt ?? now + nextBackoffDelay(0)) + } + } + // A record outlives its entry when a re-tap cancels the write it counted. + for (const key of [...backoff.keys()]) { + if (!live.has(key)) { + backoff.delete(key) + } + } + if (soonest === Infinity) { + return + } + + // Floored: a claimed verse keeps a past-due record, and an unfloored delay + // would spin on it until the hook releases. + timer = setTimeout( + () => { + timer = null + startPass(false) + }, + Math.max(soonest - now, nextBackoffDelay(0)), + ) + } + + /** + * A `prompted` pass retires every wait: the wait was a guess about a network + * nobody had asked, and a trigger is the answer arriving. Failure counts stay, + * so the decay widens from where it was rather than starting over. + */ + function startPass(prompted: boolean): void { + if (stopped) { + return + } + if (prompted) { + for (const record of backoff.values()) { + record.nextAttemptAt = 0 + } + } + if (running) { + rerun = true + return + } + void runPass() + } + + return { + drainNow() { + startPass(true) + }, + noteParkedWrite() { + // Does not drain: the network just refused this write. Only start the clock. + if (!stopped && !running) { + scheduleNext() + } + }, + stop() { + stopped = true + if (timer !== null) { + clearTimeout(timer) + timer = null + } + backoff.clear() + }, + } +} + +function push(groups: Map, key: Key, verse: number): void { + const existing = groups.get(key) + if (existing === undefined) { + groups.set(key, [verse]) + } else { + existing.push(verse) + } +} diff --git a/packages/core/src/highlights/highlight-queue-drain-host.tsx b/packages/core/src/highlights/highlight-queue-drain-host.tsx new file mode 100644 index 00000000..1a04fc16 --- /dev/null +++ b/packages/core/src/highlights/highlight-queue-drain-host.tsx @@ -0,0 +1,94 @@ +/** + * Mounts the Highlight Write Queue drain and wires it to the moments a parked + * write is worth retrying. Renders nothing. + * + * Rendered inside `AuthProvider`, so an app with no auth configured has no drain + * at all rather than an inert one. + */ + +import { addNetworkStateListener } from 'expo-network' +import { useEffect, useMemo, useRef } from 'react' +import { AppState } from 'react-native' + +import { useYVAuthOptional } from '../auth' +import { useYouVersion } from '../use-youversion' +import { createHighlightsApi } from './api' +import { onDrainSignal } from './drain-signals' +import { startHighlightQueueDrain, type DrainAuth, type HighlightQueueDrain } from './drain' + +export default function HighlightQueueDrainHost() { + const { appKey, apiHost, installationId } = useYouVersion() + const auth = useYVAuthOptional() + + const userId = auth?.userInfo?.id ?? null + const accessToken = auth?.accessToken ?? null + const ensureFreshToken = auth?.ensureFreshToken ?? null + const refreshNow = auth?.refreshNow ?? null + + const authRef = useRef({ userId, accessToken, ensureFreshToken, refreshNow }) + // Declared before the effects that read it: effects run in order, so the drain + // starts against real values rather than the mount-time snapshot. + useEffect(() => { + authRef.current = { userId, accessToken, ensureFreshToken, refreshNow } + }) + + const api = useMemo( + () => createHighlightsApi({ appKey, apiHost, installationId }), + [appKey, apiHost, installationId], + ) + + const drainRef = useRef(null) + + useEffect(() => { + const drain = startHighlightQueueDrain({ api, getAuth: () => authRef.current }) + drainRef.current = drain + return () => { + drain.stop() + drainRef.current = null + } + }, [api]) + + // Mount, sign-in, and token refresh. + useEffect(() => { + drainRef.current?.drainNow() + }, [userId, accessToken]) + + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + drainRef.current?.drainNow() + } + }) + return () => subscription.remove() + }, []) + + useEffect( + () => + onDrainSignal((signal) => { + if (signal === 'service-reached') { + drainRef.current?.drainNow() + } else { + drainRef.current?.noteParkedWrite() + } + }), + [], + ) + + useEffect(() => { + // Rising edge only, seeded connected: a redundant event on subscribe must not + // duplicate the mount drain. `undefined` is unknown and changes nothing. + let wasConnected = true + const subscription = addNetworkStateListener(({ isConnected }) => { + if (isConnected === undefined) { + return + } + if (isConnected && !wasConnected) { + drainRef.current?.drainNow() + } + wasConnected = isConnected + }) + return () => subscription.remove() + }, []) + + return null +} diff --git a/packages/core/src/highlights/index.ts b/packages/core/src/highlights/index.ts index 7238d42b..cec747f0 100644 --- a/packages/core/src/highlights/index.ts +++ b/packages/core/src/highlights/index.ts @@ -26,6 +26,11 @@ export { export { HIGHLIGHT_COLORS, isHighlightColor, type HighlightColor } from './constants' +// `clearHighlightQueue` is sign-out's, and stays internal. +// `hasQueuedHighlightWrites` is public: the reader has to know whether signing +// out costs the user work before it can ask them about it. +export { clearHighlightQueue, hasQueuedHighlightWrites } from './queue' + // The reducer, its events, and `PendingHighlight` stay internal — the flow's // public surface is the hook plus what a caller has to render or report. export type { PermissionFlowError, PermissionFlowErrorReason } from './permission-flow' diff --git a/packages/core/src/highlights/optimistic.ts b/packages/core/src/highlights/optimistic.ts index 214c46b3..648ee3e2 100644 --- a/packages/core/src/highlights/optimistic.ts +++ b/packages/core/src/highlights/optimistic.ts @@ -1,161 +1,112 @@ /** - * Pure optimistic-overlay math for the native highlights layer. + * Pure paint math for the native highlights layer. React-free and + * side-effect-free: no storage, no network, no hooks. * - * Ported from the web SDK's `bible-reader-highlights-machine.ts` (`claimVerses`, - * `settleWrite`, `reconcileOverlay`) so the two SDKs agree on what the user sees - * while a write is in flight. Two semantics are load-bearing and are adopted - * rather than re-derived: + * `colors` is what the reader shows — server truth with the user's unconfirmed + * edits already folded in, not a base plus an overlay. The record of which edits + * are unconfirmed lives in the Highlight Write Queue, which is persisted; this + * module takes it as an argument at {@link serverUpdated} and never reads it + * otherwise. See ADR 0013 and ADR 0018. * - * 1. **Ownership tokens.** Every write allocates a fresh token object and stamps - * the verses it claims. A settling write only touches verses it *still* owns, - * so a slow failure cannot wipe paint a newer write has since put down. - * 2. **Remove overlays survive reconciliation.** A stale read replica can echo - * back the color that was just deleted; retiring the overlay on that echo - * repaints the verse for a beat ("vapor"). See {@link shouldRetire} — our - * rule diverges from web's, deliberately. - * - * This module is React-free and side-effect-free: no storage, no network, no - * hooks. Every state transition returns the *same* object when nothing changed, - * because the projected output crosses the native/DOM bridge as a serialized - * prop. + * Every transition returns the *same* object when nothing changed, because the + * projected output crosses the native/DOM bridge as a serialized prop. */ import type { Highlight } from '@youversion/platform-core' -import type { HighlightScope, ServerColors } from './constants' - -/** - * Pending local edits for a scope: a hex color where the user just applied one, - * `null` where they just removed one. Sits on top of Server Colors. Never - * persisted. - */ -export type HighlightOverlay = Record +import type { HighlightScope, QueuedWrites, ServerColors } from './constants' export type WriteOp = 'apply' | 'remove' /** - * Per-write ownership marker. Compared by **object identity**, never by value — - * the `op` field is for debugging only. Allocate a fresh one per write. + * A write the server accepted, held until a fetch agrees. Without it a read + * replica one step behind repaints what was just deleted ("vapor"). */ -export type WriteToken = { readonly op: WriteOp } - -/** What a settled write is waiting for the server to confirm. */ export type ReconcileEntry = { op: WriteOp; color: string } export type OptimisticState = { scope: HighlightScope userId: string | null - serverColors: ServerColors - overlay: HighlightOverlay + /** What the reader paints. */ + colors: ServerColors reconcile: ReadonlyMap - writeIntent: ReadonlyMap -} - -export function createWriteToken(op: WriteOp): WriteToken { - return { op } } -/** - * A fresh state for an identity (scope + user), seeded with whatever server - * truth is already known. Also the `reset` transition: clearing `writeIntent` is - * what stops an in-flight write from settling onto a colliding verse number in - * the scope the user has since navigated to. - */ export function createOptimisticState(input: { scope: HighlightScope userId: string | null - serverColors: ServerColors + colors: ServerColors }): OptimisticState { return { scope: input.scope, userId: input.userId, - serverColors: input.serverColors, - overlay: {}, + colors: input.colors, reconcile: new Map(), - writeIntent: new Map(), } } /** - * Claims verses for a write: stamps each with the op's ownership `token`, drops - * any pending reconciliation (a newer write supersedes it), and paints the - * optimistic overlay (`color` for an apply, `null` for a remove). - * - * Dropping the reconcile entry is also the third retirement path for a remove - * overlay — the other two are a scope/identity change and a confirming fetch. + * Paints `verses` — a color for an apply, `null` for a remove — and drops any + * reconciliation pending on them, which a newer write supersedes. */ -export function claim( +export function paint( state: OptimisticState, verses: readonly number[], - token: WriteToken, color: string | null, ): OptimisticState { if (verses.length === 0) { return state } - const writeIntent = new Map(state.writeIntent) + const colors = { ...state.colors } const reconcile = new Map(state.reconcile) - const overlay = { ...state.overlay } for (const verse of verses) { - writeIntent.set(verse, token) + if (color === null) { + delete colors[verse] + } else { + colors[verse] = color + } reconcile.delete(verse) - overlay[verse] = color } - return { ...state, writeIntent, reconcile, overlay } + return { ...state, colors, reconcile } } /** - * Cleanup for a finished batch, deciding what happens to paint already on - * screen. Succeeded verses keep their paint and register a reconcile entry so a - * later fetch knows when to retire it; failed verses have their overlay entry - * **deleted** — for a failed remove that restores the highlight, same mechanism, - * correct result. - * - * Both loops are guarded on `writeIntent.get(verse) === token` by object - * identity. The scenario: tap yellow on verse 16; before that POST returns, tap - * green on 16 (which re-stamps the intent); then yellow's POST fails. Unguarded, - * yellow's settle deletes the overlay and wipes the green the user is looking at - * over a failure that has nothing to do with it. Guarded, yellow sees green's - * token instead of its own and leaves it alone. - * - * Releasing the claim (`writeIntent.delete`) is what stops intents accumulating - * until sign-out. + * Registers verses the server has accepted, so the next fetch knows when it is + * safe to stop trusting the local value. Leaves the paint alone — it is already + * what was written. */ -export function settle( +export function confirm( state: OptimisticState, - batch: { - token: WriteToken - op: WriteOp - color: string - succeededVerses: readonly number[] - failedVerses: readonly number[] - }, + batch: { op: WriteOp; color: string; verses: readonly number[] }, ): OptimisticState { - const overlay = { ...state.overlay } + if (batch.verses.length === 0) { + return state + } const reconcile = new Map(state.reconcile) - const writeIntent = new Map(state.writeIntent) - let changed = false - - for (const verse of batch.succeededVerses) { - if (state.writeIntent.get(verse) !== batch.token) { - continue - } + for (const verse of batch.verses) { reconcile.set(verse, { op: batch.op, color: batch.color }) - writeIntent.delete(verse) - changed = true } + return { ...state, reconcile } +} - for (const verse of batch.failedVerses) { - if (state.writeIntent.get(verse) !== batch.token) { - continue - } - if (verse in overlay) { - delete overlay[verse] - } - writeIntent.delete(verse) - changed = true +/** + * Puts verses back to what the server had, for a write the server rejected. Two + * halves because a verse the server had nothing for is an absence, not a color. + */ +export function restore( + state: OptimisticState, + batch: { restored: ServerColors; cleared: readonly number[] }, +): OptimisticState { + if (Object.keys(batch.restored).length === 0 && batch.cleared.length === 0) { + return state } - - return changed ? { ...state, overlay, reconcile, writeIntent } : state + const colors = { ...state.colors } + for (const [verse, color] of Object.entries(batch.restored)) { + colors[Number(verse)] = color + } + for (const verse of batch.cleared) { + delete colors[verse] + } + return serverColorsEqual(state.colors, colors) ? state : { ...state, colors } } /** @@ -171,8 +122,8 @@ export function settle( * * Narrower failure mode this introduces: verse was green, user set yellow, user * removed it, and a replica stale enough to still report *green* retires the - * overlay and briefly paints green. That needs the server two steps behind - * rather than one. + * entry and briefly paints green. That needs the server two steps behind rather + * than one. * * Reverting to web's behavior is `return false` in the remove branch. */ @@ -184,42 +135,57 @@ export function shouldRetire(entry: ReconcileEntry, serverColor: string | undefi } /** - * Stores fresh server truth and retires any reconcile entries it confirms. + * Rebuilds the paint from fresh server truth, re-applying everything not yet + * confirmed: writes the server has accepted but may not be serving back yet + * (`reconcile`), then writes it has not received at all (`queued`), which are + * newer and win. + * * Returns the same state object when the fetch changed nothing, so the projected * highlights prop stays referentially stable across the bridge. */ -export function serverUpdated(state: OptimisticState, serverColors: ServerColors): OptimisticState { - const colorsChanged = !serverColorsEqual(state.serverColors, serverColors) - - if (state.reconcile.size === 0) { - return colorsChanged ? { ...state, serverColors } : state - } - - const overlay = { ...state.overlay } +export function serverUpdated( + state: OptimisticState, + serverColors: ServerColors, + queued: QueuedWrites, +): OptimisticState { const reconcile = new Map(state.reconcile) let retired = false - let overlayChanged = false - for (const [verse, entry] of state.reconcile) { - if (!shouldRetire(entry, serverColors[verse])) { - continue + if (shouldRetire(entry, serverColors[verse])) { + reconcile.delete(verse) + retired = true } - reconcile.delete(verse) - retired = true - if (verse in overlay) { - delete overlay[verse] - overlayChanged = true + } + + const colors: ServerColors = { ...serverColors } + for (const [verse, entry] of reconcile) { + if (entry.op === 'apply') { + colors[verse] = entry.color + } else { + delete colors[verse] } } + applyQueuedWrites(colors, queued) + const colorsChanged = !serverColorsEqual(state.colors, colors) if (!colorsChanged && !retired) { return state } return { ...state, - serverColors, + colors: colorsChanged ? colors : state.colors, reconcile: retired ? reconcile : state.reconcile, - overlay: overlayChanged ? overlay : state.overlay, + } +} + +/** Folds unsent writes onto `colors` in place. */ +export function applyQueuedWrites(colors: ServerColors, queued: QueuedWrites): void { + for (const [verse, entry] of Object.entries(queued)) { + if (entry.local === null) { + delete colors[Number(verse)] + } else { + colors[Number(verse)] = entry.local + } } } @@ -241,32 +207,23 @@ export function serverColorsEqual(a: ServerColors, b: ServerColors): boolean { // ── Selectors ──────────────────────────────────────────────────────────────── -/** Server truth with the optimistic overlay applied — what the user sees. */ -export function selectMergedColors(state: OptimisticState): Record { - const merged: Record = { ...state.serverColors } - for (const [verse, color] of Object.entries(state.overlay)) { - if (color === null) { - delete merged[Number(verse)] - } else { - merged[Number(verse)] = color - } - } - return merged -} - /** * The rendered state as one `Highlight` per verse, ascending. Per-verse (never * ranges) so that `deriveServerColors(selectHighlights(state), scope)` is an * exact round trip. */ export function selectHighlights(state: OptimisticState): Highlight[] { - const merged = selectMergedColors(state) - const { versionId, book, chapter } = state.scope - return Object.keys(merged) + return highlightsFromColors(state.scope, state.colors) +} + +/** The same projection for callers holding colors without a state (the cache). */ +export function highlightsFromColors(scope: HighlightScope, colors: ServerColors): Highlight[] { + const { versionId, book, chapter } = scope + return Object.keys(colors) .map(Number) .sort((a, b) => a - b) .flatMap((verse) => { - const color = merged[verse] + const color = colors[verse] return color === undefined ? [] : [{ version_id: versionId, passage_id: `${book}.${chapter}.${verse}`, color }] @@ -274,18 +231,17 @@ export function selectHighlights(state: OptimisticState): Highlight[] { } /** - * Of `verses`, the ones the user currently *sees* in `color` — optimistic paint - * included. The remove path targets what is on screen, not what the server last - * said, because a DELETE carries a passage id and no color: removing yellow - * across a selection that also holds a blue verse must not destroy the blue one. + * Of `verses`, the ones the user currently *sees* in `color`. The remove path + * targets what is on screen, not what the server last said, because a DELETE + * carries a passage id and no color: removing yellow across a selection that + * also holds a blue verse must not destroy the blue one. */ export function selectVersesInColor( state: OptimisticState, verses: readonly number[], color: string, ): number[] { - const merged = selectMergedColors(state) - return verses.filter((verse) => merged[verse] === color) + return verses.filter((verse) => state.colors[verse] === color) } // ── USFM range helpers (ported from web's `usfm-ranges.ts`) ─────────────────── @@ -333,6 +289,35 @@ export function versesInRun(run: VerseRun): number[] { return verses } +/** One request's worth of a write: the passage it addresses, the verses it answers for. */ +export type WriteUnit = { passageId: string; verses: number[] } + +/** + * Splits a write into the requests that carry it. Applies collapse contiguous + * verses into a single ranged POST per run — [16,17,18,20] is two requests, not + * four. Removals (`color === null`) go one verse at a time, never a range, + * because range DELETE is unsupported server-side and a DELETE carries no color, + * so a wholesale run would take any other color caught inside it. + * + * Shared by the tap-time write path and the drain: the two must put identical + * requests on the wire, and this is the rule that decides what they look like. + */ +export function toWriteUnits( + scope: HighlightScope, + verses: readonly number[], + color: string | null, +): WriteUnit[] { + return color === null + ? verses.map((verse) => ({ + passageId: formatPassageId(scope.book, scope.chapter, { start: verse, end: verse }), + verses: [verse], + })) + : collapseVerseRuns(verses).map((run) => ({ + passageId: formatPassageId(scope.book, scope.chapter, run), + verses: versesInRun(run), + })) +} + /** * A raw verse selection reduced to its canonical form: de-duplicated, sorted * ascending, non-positive numbers dropped. Reuses {@link collapseVerseRuns} so @@ -340,7 +325,7 @@ export function versesInRun(run: VerseRun): number[] { * * Not web's `normalizeVerses` — that one is private to `verse-share.ts` and * serves copy/share reference formatting. This exists because writes need the - * canonical *verse list* (to claim the overlay and to report outcomes), while + * canonical *verse list* (to paint and to report outcomes), while * `collapseVerseRuns` yields runs. */ export function normalizeVerseSelection(verses: readonly number[]): number[] { diff --git a/packages/core/src/highlights/queue.ts b/packages/core/src/highlights/queue.ts new file mode 100644 index 00000000..bc7c84af --- /dev/null +++ b/packages/core/src/highlights/queue.ts @@ -0,0 +1,270 @@ +/** + * Writes that have not reached the server, persisted until they do. See ADR 0018. + * + * Storage, plus a notification for the one change no one else can see (see + * {@link onWritesDropped}). Eligibility belongs to the write path, and the drain + * belongs to the provider. + */ + +import { z } from 'zod' + +import { mmkvStorage } from '../storage/mmkv-storage' +import { + highlightQueueKey, + highlightQueueUserPrefix, + MMKV_HIGHLIGHT_QUEUE_KEY_PREFIX, + type HighlightScope, + type QueuedWrites, + type ServerColors, +} from './constants' + +/** Never mutate a returned map — a miss returns this shared instance. */ +const EMPTY: QueuedWrites = Object.freeze({}) + +/** `null` is a removed highlight, on either side of an entry. */ +const colorSchema = z.union([z.string().regex(/^[0-9a-f]{6}$/i), z.null()]) + +const queuedWritesSchema = z.record( + z.string().regex(/^\d+$/), + z.object({ local: colorSchema, server: colorSchema }), +) + +function normalizeColor(color: string | null): string | null { + return color === null ? null : color.toLowerCase() +} + +/** Reads a scope's unsent writes. A corrupt payload reads as empty; never throws. */ +export function getQueuedWrites(userId: string | null, scope: HighlightScope): QueuedWrites { + if (!userId) { + return EMPTY + } + + try { + const raw = mmkvStorage.getString(highlightQueueKey(userId, scope)) + if (raw == null) { + return EMPTY + } + const parsed = queuedWritesSchema.safeParse(JSON.parse(raw)) + if (!parsed.success) { + return EMPTY + } + + const queued: QueuedWrites = {} + for (const [verse, entry] of Object.entries(parsed.data)) { + const verseNumber = Number(verse) + if (!Number.isInteger(verseNumber) || verseNumber < 1) { + continue + } + queued[verseNumber] = { + local: normalizeColor(entry.local), + server: normalizeColor(entry.server), + } + } + return queued + } catch { + return EMPTY + } +} + +/** + * Every scope this user has unsent writes for. The drain's only way to find a + * chapter after a relaunch, where nothing in memory remembers one. + */ +export function listQueuedScopes(userId: string | null): HighlightScope[] { + if (!userId) { + return [] + } + + const prefix = highlightQueueUserPrefix(userId) + try { + return mmkvStorage + .getAllKeys() + .filter((key) => key.startsWith(prefix)) + .flatMap((key) => { + const scope = parseScopeSuffix(key.slice(prefix.length)) + return scope === null ? [] : [scope] + }) + } catch { + return [] + } +} + +/** + * Does this user have writes the server has not taken yet? + * + * Read at the moment sign-out is offered, not subscribed to. The answer is only + * ever needed on that one gesture, and it is a plain key scan — a store and a + * subscription would buy nothing but a value nobody watches. + * + * Any key under the user's prefix counts, including one whose scope suffix + * {@link listQueuedScopes} would reject. The drain cannot send that entry, which + * makes it more certain to be lost on sign-out, not less. + */ +export function hasQueuedHighlightWrites(userId: string | null): boolean { + if (!userId) { + return false + } + + const prefix = highlightQueueUserPrefix(userId) + try { + return mmkvStorage.getAllKeys().some((key) => key.startsWith(prefix)) + } catch { + return false + } +} + +/** `111.JHN.3` — the tail of a queue key. Book codes and chapters carry no dots. */ +function parseScopeSuffix(suffix: string): HighlightScope | null { + const parts = suffix.split('.') + if (parts.length !== 3) { + return null + } + const [versionIdRaw, book, chapter] = parts + if (!versionIdRaw || !book || !chapter || !/^\d+$/.test(versionIdRaw)) { + return null + } + return { versionId: Number(versionIdRaw), book, chapter } +} + +function persist(userId: string, scope: HighlightScope, queued: QueuedWrites): void { + const key = highlightQueueKey(userId, scope) + if (Object.keys(queued).length === 0) { + mmkvStorage.remove(key) + return + } + mmkvStorage.set(key, JSON.stringify(queued)) +} + +/** + * Records the end state `verses` should reach — a color, or `null` to remove. + * + * `currentColors` seeds `server` for a verse with no entry yet. Pass what is on + * screen: with no entry, nothing about that verse is unconfirmed, so the painted + * color *is* what the server last gave us. + */ +export function enqueueWrites(input: { + userId: string + scope: HighlightScope + verses: readonly number[] + color: string | null + currentColors: ServerColors +}): QueuedWrites { + const { userId, scope, verses, color, currentColors } = input + const local = normalizeColor(color) + const queued: QueuedWrites = { ...getQueuedWrites(userId, scope) } + + for (const verse of verses) { + const existing = queued[verse] + const server = existing === undefined ? (currentColors[verse] ?? null) : existing.server + if (local === server) { + delete queued[verse] + } else { + queued[verse] = { local, server } + } + } + + persist(userId, scope, queued) + return queued +} + +/** + * Drops the entries a settled write is responsible for, reporting each one's + * `server` state so a rejected write can be reverted. + * + * Only entries still asking for `color` are dropped. A verse the user has since + * re-tapped holds a different intent, and the settling write — which knows + * nothing about it — must not retire or revert it. Two taps of the same color + * need no such guard: they ask for the same thing, so either may retire it. + */ +export function dropWrites(input: { + userId: string + scope: HighlightScope + verses: readonly number[] + color: string | null +}): { restored: ServerColors; cleared: number[] } { + const { userId, scope, verses, color } = input + const local = normalizeColor(color) + const queued: QueuedWrites = { ...getQueuedWrites(userId, scope) } + const restored: ServerColors = {} + const cleared: number[] = [] + let changed = false + + for (const verse of verses) { + const entry = queued[verse] + if (entry === undefined || entry.local !== local) { + continue + } + if (entry.server === null) { + cleared.push(verse) + } else { + restored[verse] = entry.server + } + delete queued[verse] + changed = true + } + + if (changed) { + persist(userId, scope, queued) + } + return { restored, cleared } +} + +export type DroppedWrites = { + userId: string + scope: HighlightScope + restored: ServerColors + cleared: number[] +} + +const dropListeners = new Set<(dropped: DroppedWrites) => void>() + +/** Subscribes to {@link dropRejectedWrites}. Returns an unsubscribe. */ +export function onWritesDropped(listener: (dropped: DroppedWrites) => void): () => void { + dropListeners.add(listener) + return () => { + dropListeners.delete(listener) + } +} + +/** + * {@link dropWrites}, plus an announcement of what to un-paint. Only the drain + * needs it: a settling write already owns its own paint, while a drop takes back + * paint a mounted reader is still showing (ADR 0018). + */ +export function dropRejectedWrites(input: { + userId: string + scope: HighlightScope + verses: readonly number[] + color: string | null +}): { restored: ServerColors; cleared: number[] } { + const dropped = dropWrites(input) + if (Object.keys(dropped.restored).length === 0 && dropped.cleared.length === 0) { + return dropped + } + + const event: DroppedWrites = { userId: input.userId, scope: input.scope, ...dropped } + for (const listener of [...dropListeners]) { + listener(event) + } + return dropped +} + +/** + * Drops every unsent write on the device — sign-out's job, alongside the + * highlights cache. Every user's entries, not just the current one's: only a + * departure can leave an entry under somebody else's id. + * + * Never throws: sign-out purges before it clears the tokens, so an unreadable + * store costs a surviving entry, not a user who is still signed in. + */ +export function clearHighlightQueue(): void { + try { + for (const key of mmkvStorage.getAllKeys()) { + if (key.startsWith(MMKV_HIGHLIGHT_QUEUE_KEY_PREFIX)) { + mmkvStorage.remove(key) + } + } + } catch { + // Purge failed; sign-out continues. + } +} diff --git a/packages/core/src/highlights/use-highlights.ts b/packages/core/src/highlights/use-highlights.ts index cb42ff80..42fc35c0 100644 --- a/packages/core/src/highlights/use-highlights.ts +++ b/packages/core/src/highlights/use-highlights.ts @@ -5,24 +5,30 @@ import type { AccessTokenResult, AuthPermission } from '../auth' import { useYVAuthOptional } from '../auth' import { useYouVersion } from '../use-youversion' import { createHighlightsApi, type HighlightsApi, type HighlightsApiError } from './api' -import { deriveServerColors, getCachedHighlights, setCachedHighlights } from './cache' +import { + deriveServerColors, + getCachedHighlights, + mergeCachedHighlights, + setCachedHighlights, +} from './cache' +import { claimWrites } from './claims' import { isHighlightColor, NOT_SIGNED_IN_MESSAGE, type HighlightScope } from './constants' +import { notifyDrain } from './drain-signals' import { - claim, - collapseVerseRuns, + applyQueuedWrites, + confirm, createOptimisticState, - createWriteToken, - formatPassageId, normalizeVerseSelection, + paint, + restore, selectHighlights, selectVersesInColor, serverUpdated, - settle, - versesInRun, + toWriteUnits, type OptimisticState, type WriteOp, - type WriteToken, } from './optimistic' +import { dropWrites, enqueueWrites, getQueuedWrites, onWritesDropped } from './queue' export type UseHighlightsOptions = { versionId: number @@ -34,6 +40,20 @@ export type HighlightWriteReason = 'not-signed-in' | 'auth' | 'transient' | 'inv export type HighlightWriteOutcome = | { status: 'ok'; verses: number[] } + /** + * Could not reach the server. The paint stands and the write is persisted as a + * Queued Write; a point-in-time signal at the tap, not a standing state. + * + * It therefore repeats. Every tap on a verse that is still parked resolves + * `queued` again — this reports the write the caller just made, not the + * verse's queue state, and nothing here separates a first park from a later + * one. Two reasons it does not: a batch can mix a parked verse with fresh + * ones, so an honest answer would have to be a per-verse split of `verses`; + * and a verse parked yellow then tapped green is a new write on a parked + * verse, which "repeat" would describe wrongly. A caller that wants to say + * "saved offline" once holds that in its own state. + */ + | { status: 'queued'; verses: number[] } | { status: 'noop' } | { status: 'error' @@ -86,6 +106,11 @@ const INVALID_COLOR_MESSAGE = const TOKEN_REFRESH_FAILED_MESSAGE = 'Could not refresh the session token. Retry when the network recovers.' +const QUEUE_PERSIST_FAILED_MESSAGE = + 'Could not record the highlight for sending. Retry in a moment.' + +const UNEXPECTED_WRITE_FAILURE_MESSAGE = 'The highlight write could not be completed.' + /** * `auth` wins (it changes what the user must do); retrying `invalid` is * pointless. `not-signed-in` is ranked but unreachable here — it is never @@ -151,19 +176,90 @@ function identityKeyFor(userId: string | null, scope: HighlightScope): string { return `${userId ?? ''}|${scope.versionId}|${scope.book}|${scope.chapter}` } +/** + * The cache already holds unsent writes, so re-applying the queue is a repair: + * a process that died between the two MMKV writes would otherwise come back + * owing a write it does not show. + */ function initialStateFor(scope: HighlightScope, userId: string | null): OptimisticState { const cached = userId === null ? null : getCachedHighlights(userId, scope) - return createOptimisticState({ - scope, - userId, - serverColors: cached === null ? {} : deriveServerColors(cached, scope), - }) + const colors = cached === null ? {} : deriveServerColors(cached, scope) + + if (userId !== null) { + applyQueuedWrites(colors, getQueuedWrites(userId, scope)) + } + + return createOptimisticState({ scope, userId, colors }) } function sameIdentity(state: OptimisticState, identity: Identity): boolean { return identityKeyFor(state.userId, state.scope) === identity.key } +/** + * A settling write must write the cache for the scope it was MADE in, which is + * not always the scope on screen. + * + * The render-path cache write covers only the current scope, so a write that + * settles after the reader has moved on leaves its own chapter holding whatever + * the cache last recorded. For a refusal that is paint the server said no to: + * the entry is dropped, nothing repairs the cache, and the next mount of that + * chapter paints the refused color until a successful GET happens to correct it. + * {@link landInCache} and {@link revertInCache} close that, mirroring `land` and + * `revert` in `drain.ts` — which already had to solve it for scopes with no + * mounted hook at all. + * + * Both filter on `local === color`, the same guard `dropWrites` applies, so a + * verse the user has since re-tapped keeps its newer intent. Both are called + * BEFORE the entry is dropped, as the drain does: a crash between the two must + * leave the write still owed rather than leave a refused paint in the cache with + * nothing left to correct it. + */ +function landInCache( + userId: string, + scope: HighlightScope, + verses: readonly number[], + color: string | null, +): void { + const owed = getQueuedWrites(userId, scope) + const landed = verses.filter((verse) => owed[verse]?.local === color) + if (landed.length > 0) { + mergeCachedHighlights(userId, scope, landed, color) + } +} + +/** {@link landInCache}'s counterpart: back to each entry's `server` side. */ +function revertInCache( + userId: string, + scope: HighlightScope, + verses: readonly number[], + color: string | null, +): void { + const owed = getQueuedWrites(userId, scope) + const byServer = new Map() + for (const verse of verses) { + const entry = owed[verse] + if (entry === undefined || entry.local !== color) { + continue + } + const group = byServer.get(entry.server) + if (group === undefined) { + byServer.set(entry.server, [verse]) + } else { + group.push(verse) + } + } + for (const [server, group] of byServer) { + mergeCachedHighlights(userId, scope, group, server) + } +} + +/** + * How the token-loading hold ended — auth answered, or the hook unmounted while + * a write was still waiting on it. See `authWaitersRef`. + */ +type AuthSettleOutcome = 'settled' | 'aborted' + /** * Instant, optimistic, self-healing highlight state for one chapter. * @@ -242,7 +338,19 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul // Resolve on EITHER a token arriving OR auth settling with none — never on // `isLoading` alone, because `postTokenEndpoint` has no AbortController and a // hung network can leave `isLoading` true indefinitely. - const authWaitersRef = useRef<(() => void)[]>([]) + // + // Unmounting is a third exit, and it carries its own marker. A plain flush on + // unmount would resume `runWrite` against a token that is STILL null, which + // classifies as `not-signed-in` and reverts — deleting the queue entry for a + // write the user made and the server never saw. That is data loss. Not + // flushing at all is the other trap: `runWrite` never resumes, the + // `.finally(release)` in `startWrite` never runs, and the verse's claim in + // `claims.ts` survives until relaunch — which makes the drain skip those + // verses for the whole session while re-arming its timer against them. The + // `'aborted'` marker is the only exit that keeps both the entry and the claim + // honest. + const authWaitersRef = useRef<((outcome: AuthSettleOutcome) => void)[]>([]) + const isUnmountedRef = useRef(false) // Runs after EVERY render, and must stay declared above the fetch effect: // effects fire in declaration order, so this is what guarantees `runFetch` @@ -262,16 +370,41 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul const waiters = authWaitersRef.current authWaitersRef.current = [] for (const resolve of waiters) { - resolve() + resolve('settled') } }) - const waitForAuthSettled = useCallback((): Promise => { + // Mount-only, for its cleanup alone: a write parked in the hold has no other + // way out once this hook is gone, and leaving it there strands its claim. + // The body's reset matters under StrictMode's mount/cleanup/mount cycle: + // without it the simulated unmount latches the flag and every later write + // that needs the hold aborts to the queue while the hook is still mounted. + useEffect(() => { + isUnmountedRef.current = false + return () => { + isUnmountedRef.current = true + const waiters = authWaitersRef.current + authWaitersRef.current = [] + for (const resolve of waiters) { + resolve('aborted') + } + } + }, []) + + const waitForAuthSettled = useCallback((): Promise => { const current = authRef.current if (current.accessToken !== null || !current.isAuthLoading) { - return Promise.resolve() + return Promise.resolve('settled') } - return new Promise((resolve) => { + // The flush above only reaches a write that was already waiting. A write + // still queued behind another one reaches this point AFTER the unmount, with + // nothing left to render, so it would wait on a resolve that can never come. + // Checked here rather than at the top: a write that does not need the hold at + // all is unaffected by the unmount and still goes out. + if (isUnmountedRef.current) { + return Promise.resolve('aborted') + } + return new Promise((resolve) => { authWaitersRef.current.push(resolve) }) }, []) @@ -311,6 +444,11 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul passage_id: `${captured.scope.book}.${captured.scope.chapter}`, }) .then((result) => { + // Ahead of the identity guard: the network is up regardless of which + // scope this answer belongs to. + if (result.ok) { + notifyDrain('service-reached') + } // Late responses for a scope or user the reader has left are dropped — // including the sign-out case, where writing the cache would repopulate // what `clearHighlightsCache()` just emptied. @@ -321,12 +459,11 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul setError({ reason: classifyApiError(result.error), message: result.error.message }) return } - if (captured.userId !== null) { - setCachedHighlights(captured.userId, captured.scope, result.value.data) - } const serverColors = deriveServerColors(result.value.data, captured.scope) + const queued = + captured.userId === null ? {} : getQueuedWrites(captured.userId, captured.scope) setState((prev) => - sameIdentity(prev, captured) ? serverUpdated(prev, serverColors) : prev, + sameIdentity(prev, captured) ? serverUpdated(prev, serverColors, queued) : prev, ) setError(null) }) @@ -351,6 +488,24 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul const refresh = useCallback((): Promise => runFetch(), [runFetch]) + // The drain gave up on a write this reader is still painting. Nothing remounts, + // so the un-paint arrives here (ADR 0018). + useEffect( + () => + onWritesDropped((dropped) => { + const captured = identityRef.current + if (identityKeyFor(dropped.userId, dropped.scope) !== captured.key) { + return + } + setState((prev) => + sameIdentity(prev, captured) + ? restore(prev, { restored: dropped.restored, cleared: dropped.cleared }) + : prev, + ) + }), + [], + ) + // ── Writes ───────────────────────────────────────────────────────────────── // A promise chain, not a queue: the web machine needs an explicit queue only // because xstate cannot await. Claims paint immediately; network writes @@ -371,12 +526,42 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul op: WriteOp color: string verses: number[] - token: WriteToken captured: Identity }): Promise => { - const { op, color, verses, token, captured } = batch + const { op, color, verses, captured } = batch + const paintedColor = op === 'apply' ? color : null - await waitForAuthSettled() + /** Undoes verses the server refused, skipping any the user has re-tapped. */ + const revert = (rejected: number[]): void => { + if (captured.userId === null || rejected.length === 0) { + return + } + // The cache first, for the write's OWN scope — the setState below is + // guarded on the current identity, so on its own it repairs nothing once + // the reader has moved on. See {@link revertInCache}. + revertInCache(captured.userId, captured.scope, rejected, paintedColor) + const { restored, cleared } = dropWrites({ + userId: captured.userId, + scope: captured.scope, + verses: rejected, + color: paintedColor, + }) + setState((prev) => + sameIdentity(prev, captured) ? restore(prev, { restored, cleared }) : prev, + ) + } + + if ((await waitForAuthSettled()) === 'aborted') { + // Unmounted with the token still missing. The paint went with the + // component, the entry stands, and the drain owes the server this write + // — so it is a park, not a failure. Deliberately does NOT run the + // not-signed-in classification below, which would revert and delete the + // entry (see `authWaitersRef`). Returning frees the claim through + // `startWrite`'s `.finally(release)`, which is what lets the drain pick + // these verses up in this same session. + notifyDrain('write-parked') + return { status: 'queued', verses } + } // Fresh token resolved in the send path, not at tap time — `startWrite` // has already painted. A failed refresh must stop the write here: an @@ -416,9 +601,7 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul if (!isSameUser || tokenResult.status === 'unavailable') { // Revert the paint either way — a no-op in the user-switch case, where // the render-time identity reset already covered it. - setState((prev) => - settle(prev, { token, op, color, succeededVerses: [], failedVerses: verses }), - ) + revert(verses) // The session is intact and no request went out, so `transient` — never // `auth` (drops the grant) or `not-signed-in` (prompts sign-in). if ( @@ -444,30 +627,22 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul } const accessTokenNow = tokenResult.token + // Re-read at send time, not tap time: a write is already on the chain by + // the time a later tap can cancel or supersede its entry. A verse the queue + // no longer wants in this color needs no request — the end state it asked + // for is one the server already has, or one a newer write will set. + const owed = getQueuedWrites(captured.userId, captured.scope) + const sendable = verses.filter((verse) => owed[verse]?.local === paintedColor) + if (sendable.length === 0) { + return { status: 'noop' } + } const succeededVerses: number[] = [] + const queuedVerses: number[] = [] const failedVerses: number[] = [] const errors: HighlightsApiError[] = [] - // One request per unit, each covering the verses it is responsible for. - // Apply collapses contiguous verses into a single ranged POST per run — - // [16,17,18,20] is two requests, not four. Remove issues one DELETE per - // verse, never a range, because range DELETE is unsupported server-side; - // if that is ever confirmed to work, this ternary is the only call site - // that changes. - const units = - op === 'apply' - ? collapseVerseRuns(verses).map((run) => ({ - passageId: formatPassageId(captured.scope.book, captured.scope.chapter, run), - verses: versesInRun(run), - })) - : verses.map((verse) => ({ - passageId: formatPassageId(captured.scope.book, captured.scope.chapter, { - start: verse, - end: verse, - }), - verses: [verse], - })) + const units = toWriteUnits(captured.scope, sendable, paintedColor) const results = await Promise.all( units.map((unit) => @@ -491,33 +666,68 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul succeededVerses.push(...unit.verses) return } + // A write the server never saw is owed, not lost: its entry and its paint + // both stand, and there is nothing to settle. Only a refusal — 401, 403, + // any other 4xx — takes the paint back. + if (result !== undefined && classifyApiError(result.error) === 'transient') { + queuedVerses.push(...unit.verses) + return + } failedVerses.push(...unit.verses) if (result !== undefined) { errors.push(result.error) } }) - setState((prev) => settle(prev, { token, op, color, succeededVerses, failedVerses })) + if (succeededVerses.length > 0 && captured.userId !== null) { + // Same reason as the revert path, same order: the paint this write asked + // for belongs in ITS scope's cache, which the render-path write covers + // only while the reader is still on that chapter. + landInCache(captured.userId, captured.scope, succeededVerses, paintedColor) + dropWrites({ + userId: captured.userId, + scope: captured.scope, + verses: succeededVerses, + color: paintedColor, + }) + setState((prev) => + sameIdentity(prev, captured) + ? confirm(prev, { op, color, verses: succeededVerses }) + : prev, + ) + } + revert(failedVerses) - // Exactly one GET per settled batch, success or failure — this is what - // reconciles a partial success back to server truth. Guarded internally - // against a scope change or sign-out landing mid-write. - void runFetch() + // The drain owns it from here; this hook will not retry it. + if (queuedVerses.length > 0) { + notifyDrain('write-parked') + } - if (failedVerses.length === 0) { - return { status: 'ok', verses: succeededVerses } + // One GET per write that reached the server; a queued one changed nothing + // there and has nothing to reconcile. + if (succeededVerses.length > 0 || failedVerses.length > 0) { + void runFetch() } - const reasons = errors.map(classifyApiError) - const reason = reasons.reduce( - (worst, candidate) => (REASON_RANK[candidate] > REASON_RANK[worst] ? candidate : worst), - 'transient', - ) - const message = - errors.find((candidate) => classifyApiError(candidate) === reason)?.message ?? - 'Highlight write failed.' + // A refusal outranks a park: `useHighlightPermissionFlow` branches on `reason`. + if (failedVerses.length > 0) { + const reasons = errors.map(classifyApiError) + const reason = reasons.reduce( + (worst, candidate) => (REASON_RANK[candidate] > REASON_RANK[worst] ? candidate : worst), + 'transient', + ) + const message = + errors.find((candidate) => classifyApiError(candidate) === reason)?.message ?? + 'Highlight write failed.' + + return { status: 'error', reason, message, failedVerses, succeededVerses } + } + + if (queuedVerses.length > 0) { + return { status: 'queued', verses: queuedVerses } + } - return { status: 'error', reason, message, failedVerses, succeededVerses } + return { status: 'ok', verses: succeededVerses } }, [api, runFetch, waitForAuthSettled], ) @@ -566,22 +776,66 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul return Promise.resolve({ status: 'noop' }) } - // Paint synchronously, before the promise is returned. - const token = createWriteToken(op) - const claimColor = op === 'apply' ? color : null - setState((prev) => claim(prev, verses, token, claimColor)) + // Queue before paint: dying between the two leaves a write that is owed + // but unpainted, which the next mount repairs. The other order leaves one + // painted that nothing will ever send. + const paintedColor = op === 'apply' ? color : null + try { + enqueueWrites({ + userId: captured.userId, + scope: captured.scope, + verses, + color: paintedColor, + currentColors: stateRef.current.colors, + }) + } catch { + // MMKV refused the entry. Reported here, before any paint and before any + // claim, because this is the last point where nothing has happened yet. + // `enqueueWrites` must NOT swallow it instead: that would let the paint + // go down with no entry behind it — the one state queue-first ordering + // exists to prevent. + return Promise.resolve({ + status: 'error', + reason: 'transient', + message: QUEUE_PERSIST_FAILED_MESSAGE, + failedVerses: verses, + succeededVerses: [], + }) + } + setState((prev) => paint(prev, verses, paintedColor)) // Advance the ref with it. The effect that syncs `stateRef` only runs // after a render, so a second write issued in the same tick — a toggle // that applies and removes inside one handler — would otherwise select - // against the pre-claim paint, no-op, and strand what the apply painted. + // against the pre-paint colors, no-op, and strand what the apply painted. // Chaining off `stateRef.current` instead of capturing the updater's // result keeps the updater pure (React may invoke it twice) and computes - // the same thing React will: the same claims, in the same order, over the + // the same thing React will: the same writes, in the same order, over the // same committed state. - stateRef.current = claim(stateRef.current, verses, token, claimColor) - - return enqueue(() => runWrite({ op, color, verses, token, captured })) + stateRef.current = paint(stateRef.current, verses, paintedColor) + + // Claimed for the life of the write. The entry stays in MMKV until it + // settles, so without this the drain would read it as owed and send it + // twice. + const release = claimWrites(captured.userId, captured.scope, verses) + return ( + enqueue(() => runWrite({ op, color, verses, captured })) + .finally(release) + // `apply` and `remove` resolve an outcome; they never reject, and every + // consumer doc says so loudly enough that nothing wraps them in a + // `try`/`catch`. The realistic throw is MMKV refusing a write from the + // queue or the cache repair deep inside `runWrite`, so this is what + // keeps that promise true — including for whatever throws next. + .catch( + (): HighlightWriteOutcome => ({ + status: 'error', + reason: 'transient', + message: UNEXPECTED_WRITE_FAILURE_MESSAGE, + failedVerses: verses, + succeededVerses: [], + }), + ) + ) }, [enqueue, runWrite], ) @@ -598,6 +852,20 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul const highlights = useMemo(() => selectHighlights(renderedState), [renderedState]) + // One place, so no write path can paint without persisting. + useEffect(() => { + if (userId === null) { + return + } + try { + setCachedHighlights(userId, scope, highlights) + } catch { + // The cache is the paint, but it is still only a hint about it: a store + // that refuses this write costs a slower next mount, and must not take the + // component rendering the chapter down with it. + } + }, [userId, scope, highlights]) + return { highlights, scope, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 47f41c80..24229bc6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,6 +16,7 @@ export type { export { deriveServerColors, + hasQueuedHighlightWrites, HIGHLIGHT_COLORS, isHighlightColor, useHighlightPermissionFlow, diff --git a/packages/core/src/youversion-provider.tsx b/packages/core/src/youversion-provider.tsx index 116193a0..cc376634 100644 --- a/packages/core/src/youversion-provider.tsx +++ b/packages/core/src/youversion-provider.tsx @@ -2,6 +2,7 @@ import { useMemo, useState, type ReactNode } from 'react' import AuthProvider from './auth/auth-provider' import type { AuthConfig } from './auth/types' import { DEFAULT_API_HOST } from './constants' +import HighlightQueueDrainHost from './highlights/highlight-queue-drain-host' import { getOrSetInstallationId } from './installation-id' import { YouVersionContext } from './youversion-context' @@ -35,6 +36,7 @@ export default function YouVersionProvider({ {auth ? ( + {children} ) : ( diff --git a/packages/ui/src/native/__tests__/bible-reader-sign-out.test.tsx b/packages/ui/src/native/__tests__/bible-reader-sign-out.test.tsx new file mode 100644 index 00000000..ca590bf9 --- /dev/null +++ b/packages/ui/src/native/__tests__/bible-reader-sign-out.test.tsx @@ -0,0 +1,226 @@ +/** + * Layer 3 — the confirmation the reader raises before it signs anyone out. + * + * Sign-out purges the highlights cache, the grant cache, and the Highlight Write + * Queue, so it is never immediate. The alert escalates when the queue still holds + * writes the server has not taken, matching the Swift SDK. + */ +import { render, screen, userEvent } from '@testing-library/react-native' +import * as core from '@youversion/platform-react-native-expo-core' +import type { ReactNode } from 'react' +import { Alert } from 'react-native' + +import en from '../../i18n/locales/en.json' +import { BibleReader } from '../bible-reader' +import { YouVersionProvider } from '../youversion-provider' + +jest.mock('expo-clipboard', () => ({ + setStringAsync: jest.fn(() => Promise.resolve(true)), +})) + +jest.mock('expo-application', () => ({ applicationName: 'Test App' })) + +const VERSION_ID = 111 +const USER_ID = 'user-1' + +const signOut = jest.fn(async () => undefined) + +type AuthValue = NonNullable> + +function stubAuth() { + const value: AuthValue = { + isAuthenticated: true, + accessToken: 'test-token', + userInfo: { id: USER_ID }, + error: null, + signIn: jest.fn(async () => undefined), + signOut, + refreshNow: jest.fn(async () => undefined), + ensureFreshToken: jest.fn(async () => undefined), + getAccessToken: jest.fn( + async () => ({ status: 'ok', token: 'test-token', userId: USER_ID }) as const, + ), + isLoading: false, + requestedPermissions: ['highlights'], + grantedPermissions: ['highlights'], + hasPermission: () => true, + invalidatePermissions: jest.fn(), + requestPermissions: jest.fn(async () => ({ status: 'cancel' }) as const), + } + jest.spyOn(core, 'useYVAuthOptional').mockReturnValue(value) +} + +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: { onSignOutPress?: () => Promise }) { + return ( + + void props.onSignOutPress?.()}> + Sign out + + + ) + }, + } +}) + +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, + } +}) + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +) + +const user = userEvent.setup() + +type AlertButton = { text?: string; style?: string; onPress?: () => void } + +function alertCall() { + const call = (Alert.alert as jest.Mock).mock.calls[0] + 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?.() +} + +async function renderAndPressSignOut(hasQueuedWrites: boolean) { + jest.spyOn(core, 'hasQueuedHighlightWrites').mockReturnValue(hasQueuedWrites) + stubAuth() + render(, { wrapper }) + await user.press(screen.getByTestId('trigger-sign-out')) +} + +beforeEach(() => { + signOut.mockClear() + jest.spyOn(Alert, 'alert').mockImplementation(() => undefined) +}) + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe('BibleReader — sign-out confirmation', () => { + it('asks before signing out, and does not sign out on its own', async () => { + await renderAndPressSignOut(false) + + 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', async () => { + await renderAndPressSignOut(false) + + pressAlertButton(en.signOut) + + expect(signOut).toHaveBeenCalledTimes(1) + }) + + it('keeps the user signed in when they cancel', async () => { + await renderAndPressSignOut(false) + + const cancel = alertCall().buttons.find((button) => button.text === en.cancel) + expect(cancel?.style).toBe('cancel') + cancel?.onPress?.() + + expect(signOut).not.toHaveBeenCalled() + }) + + it('escalates to the unsent-highlights variant when the queue is not empty', async () => { + await renderAndPressSignOut(true) + + 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() + }) + + it('signs out anyway once the user confirms the unsent-highlights variant', async () => { + await renderAndPressSignOut(true) + + pressAlertButton(en.signOutPendingHighlightsConfirm) + + expect(signOut).toHaveBeenCalledTimes(1) + }) + + it('asks the queue about the signed-in user', async () => { + const hasQueued = jest.spyOn(core, 'hasQueuedHighlightWrites').mockReturnValue(false) + stubAuth() + render(, { wrapper }) + + await user.press(screen.getByTestId('trigger-sign-out')) + + expect(hasQueued).toHaveBeenCalledWith(USER_ID) + }) + + it('leaves the DOM sign-out unwired when no auth is configured', () => { + jest.spyOn(core, 'useYVAuthOptional').mockReturnValue(null) + render(, { wrapper }) + + expect(screen.getByTestId('trigger-sign-out')).toBeTruthy() + expect(Alert.alert).not.toHaveBeenCalled() + }) +}) diff --git a/packages/ui/src/native/bible-reader.tsx b/packages/ui/src/native/bible-reader.tsx index 2903f3e1..12ac8819 100644 --- a/packages/ui/src/native/bible-reader.tsx +++ b/packages/ui/src/native/bible-reader.tsx @@ -1,6 +1,7 @@ import { useControllableState } from '@radix-ui/react-use-controllable-state' import { deriveServerColors, + hasQueuedHighlightWrites, useHighlightPermissionFlow, useYouVersion, useYVAuthOptional, @@ -17,13 +18,14 @@ import type { import * as Clipboard from 'expo-clipboard' import * as WebBrowser from 'expo-web-browser' import { useCallback, useMemo, useRef, useState } from 'react' -import { Platform, Share, StyleSheet, View } from 'react-native' +import { Alert, 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' @@ -170,6 +172,7 @@ export function BibleReader({ const signIn = auth?.signIn const signOut = auth?.signOut const resolvedTheme = useTheme(theme) + const { t } = useSdkTranslation() const { setFontFamily, setFontSize, setLineSpacing, fontSize, fontFamily, lineSpacing } = useReaderSettingsStore() @@ -464,6 +467,29 @@ 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, { @@ -514,7 +540,8 @@ export function BibleReader({ onVerseSelect={handleVerseSelect} clearSelectionSignal={clearSelectionSignal + internalClearCount} onSignInPress={signIn} - onSignOutPress={signOut} + // `Alert.alert` is a no-op on react-native-web, so web signs out unprompted. + onSignOutPress={Platform.OS === 'web' || !signOut ? signOut : handleSignOutPress} userInfo={userInfo} theme={resolvedTheme} book={book} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be52a1db..0325d27a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: expo-linking: specifier: 56.0.14 version: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) + expo-network: + specifier: 56.0.5 + version: 56.0.5(expo@56.0.12)(react@19.2.3) expo-router: specifier: 56.2.11 version: 56.2.11(a40df961c0dc3b7909b360b8d2f1a919) @@ -141,6 +144,9 @@ importers: expo-crypto: specifier: '>=56.0.0 <57.0.0' version: 56.0.4(expo@56.0.12) + expo-network: + specifier: '>=56.0.0 <57.0.0' + version: 56.0.5(expo@56.0.12)(react@19.2.5) expo-secure-store: specifier: '>=56.0.0 <57.0.0' version: 56.0.4(expo@56.0.12) @@ -4143,6 +4149,12 @@ packages: peerDependencies: react-native: '*' + expo-network@56.0.5: + resolution: {integrity: sha512-zmuyO95jayDY9jyUfOAlNp9XXJrJaAOkBXXLy0TS/nh2kppj7CHirRPkQ/tf0rsxhIL3AEd9nsRTiPtNsGT9Lw==} + peerDependencies: + expo: '*' + react: '*' + expo-router@56.2.11: resolution: {integrity: sha512-08DBTrKv3QanOc9u1JNxSEChW9c/qNFbQ0dO28OLvufWWfdSRkSdHmh365D2FgoZg1qaOzZPCDuL3tM6nGSfkQ==} peerDependencies: @@ -12259,6 +12271,16 @@ snapshots: dependencies: react-native: 0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5) + expo-network@56.0.5(expo@56.0.12)(react@19.2.3): + dependencies: + expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.3))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react: 19.2.3 + + expo-network@56.0.5(expo@56.0.12)(react@19.2.5): + dependencies: + expo: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + react: 19.2.5 + expo-router@56.2.11(395bee51d11edfbc44fb649e096a0f94): dependencies: '@expo/log-box': 56.0.14(@expo/dom-webview@56.0.6)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) From 677de3f9bffe242137e40154885fbe9f3ed1ded6 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 11 Aug 2026 15:31:51 -0500 Subject: [PATCH 17/43] feat(core): refresh highlights when the app becomes active (YPE-4491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounted useHighlights subscriptions now run a Highlights Refresh on AppState → active, matching auth and the write-queue drain, so Cached Highlights update after bg/fg without a remount. Co-authored-by: Cursor --- .changeset/native-highlights-release.md | 2 +- AGENTS.md | 10 + CONTEXT.md | 5 + packages/core/README.md | 2 +- .../__tests__/use-highlights.test.tsx | 51 +++- .../core/src/highlights/use-highlights.ts | 13 + plans/001-highlights-refresh-on-active.md | 262 ++++++++++++++++++ plans/README.md | 23 ++ 8 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 plans/001-highlights-refresh-on-active.md create mode 100644 plans/README.md diff --git a/.changeset/native-highlights-release.md b/.changeset/native-highlights-release.md index 3d986c90..82063cee 100644 --- a/.changeset/native-highlights-release.md +++ b/.changeset/native-highlights-release.md @@ -23,7 +23,7 @@ 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. +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. 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. diff --git a/AGENTS.md b/AGENTS.md index 6df277c5..3b35a4a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -291,3 +291,13 @@ Full guide: [docs/contributing/native-i18n.md](./docs/contributing/native-i18n.m ## Recommended Agent Skill This repo uses `CONTEXT.md` and `docs/adr/` for domain language and architectural decisions. Before planning changes, use the [grill-with-docs](https://www.skills.sh/mattpocock/skills/grill-with-docs) skill to stress-test your plan against the documented domain model — it challenges terminology and updates docs inline as decisions crystallize. + +## Learned User Preferences + +- During grilling or option tradeoffs, when impartial: pick the best reasonable option for robustness and best practices, and avoid spending time optimizing cases that do not need it. +- Ticket *how* is flexible when the *what* is right — rewrite the ticket/Jira to match locked decisions when still solving the same main idea. + +## Learned Workspace Facts + +- **Highlights Refresh** is the domain name for a GET that updates **Cached Highlights** for the current **Highlight Scope** (mount, scope change, and AppState → `active`; host screen-focus / `BibleReader` convenience deferred). +- AppState-driven Highlights Refresh should fire on return to `active` with the same rule as auth refresh and the Highlight Write Queue drain — not a `background → active`-only filter. diff --git a/CONTEXT.md b/CONTEXT.md index 5fa7526b..c7d80148 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -110,6 +110,10 @@ _Avoid_: Persisting this shape (it destroys passage ids — see **Cached Highlig The raw core API shape (`Highlight[]`: `version_id` + `passage_id` + `color`) persisted on native per `userId` + **Highlight Scope**. Passage ids may be verse ranges (`JHN.3.16-18`), so this is the only shape that can feed the web reader's controlled `highlights` prop on a cold start and that supports passage-id-targeted deletes. Reads are synchronous and validated; a valid empty array is a real snapshot (“none”), not a cache miss, and any corrupt or legacy payload reads as a miss. What the reader paints, not raw server truth: a **Queued Write** is folded in, which is what lets an unsent highlight survive a relaunch before anything touches the network. _Avoid_: Flattening to **Server Colors** before writing; treating an empty array as a miss; merging unsent writes into it +**Highlights Refresh**: +A GET that updates **Cached Highlights** for the current **Highlight Scope**. Same operation whether triggered by mount, a scope change, the app returning to `active`, or a host calling `refresh`. Overlapping calls coalesce onto one in-flight request. It asks the server again; it does not promise the server returns every highlight the user has elsewhere. +_Avoid_: Revalidation, refetch, sync; treating a refresh as proof of cross-app completeness + **Reconcile Entry**: A write the server has accepted, held in memory until a fetch agrees with it. Without it a read replica one step behind repaints a highlight that was just deleted ("vapor"); the color-aware retirement rule is in [ADR 0013](docs/adr/0013-native-highlights-optimistic-layer.md) and reads like a bug in both directions. In memory only — persisting it would make the drain re-send a write the server already has. _Avoid_: Highlight Overlay (the separate optimistic layer this replaced — **Cached Highlights** now hold the paint), ownership token / write intent (retired with it; a settling write finds its entries by value) @@ -191,6 +195,7 @@ _Avoid_: Offline queue (5xx entries park here too); a per-scope or per-hook queu - A **Highlight Scope** identifies the chapter for highlights (web-compatible location triple). Native persists **Cached Highlights** keyed by `userId` + **Highlight Scope**; without a known `userId`, the cache does not read or write. This is **Native-Owned State**, distinct from **Reader Location**. - **Server Colors** are derived from **Cached Highlights** for a given **Highlight Scope**, never stored: entries whose version, book, or chapter does not match the scope are ignored, so stale data cannot mispaint. - **Cached Highlights** are the only optimistic layer in the stack — the web reader's controlled `highlights` prop is pure projection. A settling write only touches **Queued Writes** still asking for what it sent, so a rejection cannot revert a newer tap. +- A **Highlights Refresh** replaces **Cached Highlights** from the network for one **Highlight Scope**, then folds **Queued Writes** back in so unsent paint survives the round-trip. Returning to `active` triggers it automatically inside the highlights subscription. Hosts that keep a custom surface mounted may call `refresh` when their screen is shown again — the SDK does not take a navigation library as a dependency to detect focus. - A **Highlight Write Outcome** is the sole report of a write's fate, and is where C3's sign-in branch reads from. - The reader's **Native Wrapper** derives **Cached Highlights** for its current **Highlight Scope** and holds the **Controlled Highlights Latch** with them; the **Expo DOM Component** only projects that array and never fetches, stores, or authenticates for highlights. - The highlights fetch is mounted only when the app **requested** the `highlights` permission on its auth config — not when a grant is known. A never-requested permission means no request; an unknown grant still fetches, because absence of a grant record is not a denial. diff --git a/packages/core/README.md b/packages/core/README.md index 424b951f..9c5c0b52 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -103,7 +103,7 @@ function HighlightSummary() { } ``` -`highlights` is one entry per verse and is always safe to render — `isRefreshing` only means a network refresh is in flight, so pair it with `RefreshControl` rather than gating a spinner on it. +`highlights` is one entry per verse and is always safe to render — `isRefreshing` only means a network refresh is in flight, so pair it with `RefreshControl` rather than gating a spinner on it. Mounted subscriptions also refresh automatically when the app returns to the foreground (`AppState` → `active`), using the same path as `refresh()`. Writes resolve to a typed outcome rather than throwing: `{ status: 'ok', verses }`, `{ status: 'noop' }`, or `{ status: 'error', reason, message, failedVerses, succeededVerses }` where `reason` is `'not-signed-in' | 'auth' | 'invalid' | 'transient'`. Branch on `reason`, not `message` — the message is generic outside development builds. `failedVerses` is what to retry; `succeededVerses` being non-empty alongside it means the batch partly landed. diff --git a/packages/core/src/highlights/__tests__/use-highlights.test.tsx b/packages/core/src/highlights/__tests__/use-highlights.test.tsx index 14cf71af..47a1e4be 100644 --- a/packages/core/src/highlights/__tests__/use-highlights.test.tsx +++ b/packages/core/src/highlights/__tests__/use-highlights.test.tsx @@ -1,6 +1,6 @@ import type { Collection, Highlight } from '@youversion/platform-core' import { act, render, renderHook } from '@testing-library/react-native' -import { Text } from 'react-native' +import { AppState, Text, type AppStateStatus } from 'react-native' import type { ReactNode } from 'react' import { AuthContext, type AccessTokenResult, type AuthContextValue } from '../../auth/auth-context' @@ -224,6 +224,8 @@ function colorsOf(result: UseHighlightsResult): Record { return Object.fromEntries(result.highlights.map((h) => [h.passage_id, h.color])) } +let appStateListener: ((state: AppStateStatus) => void) | null = null + beforeEach(() => { mockMmkv.clear() jest.clearAllMocks() @@ -240,6 +242,11 @@ beforeEach(() => { mockGetHighlights.mockResolvedValue(collection([])) mockCreateHighlight.mockResolvedValue({ ok: true, value: highlight('JHN.3.16', YELLOW) }) mockDeleteHighlight.mockResolvedValue({ ok: true, value: undefined }) + appStateListener = null + jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, listener) => { + appStateListener = listener as (state: AppStateStatus) => void + return { remove: jest.fn() } + }) }) // ── AC 1: instant mount ────────────────────────────────────────────────────── @@ -444,6 +451,24 @@ describe('fetching server truth', () => { }) }) + it('issues a Highlights Refresh GET when the chapter changes', async () => { + const { rerender } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + mockGetHighlights.mockClear() + + rerender({ versionId: 111, book: 'JHN', chapter: '4' }) + await act(async () => { + await Promise.resolve() + }) + + expect(mockGetHighlights).toHaveBeenCalledWith('token-1', { + version_id: 111, + passage_id: 'JHN.4', + }) + }) + it('reconciles a refresh that lands mid-write instead of clobbering the overlay', async () => { const pendingWrite = deferred>() mockCreateHighlight.mockReturnValueOnce(pendingWrite.promise) @@ -539,6 +564,30 @@ describe('fetching server truth', () => { }) }) +describe('Highlights Refresh on AppState', () => { + it('runs a Highlights Refresh when the app returns to active, and not on the way out', async () => { + renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + mockGetHighlights.mockClear() + + act(() => appStateListener?.('background')) + expect(mockGetHighlights).not.toHaveBeenCalled() + + await act(async () => { + appStateListener?.('active') + await Promise.resolve() + }) + + expect(mockGetHighlights).toHaveBeenCalledTimes(1) + expect(mockGetHighlights).toHaveBeenCalledWith('token-1', { + version_id: 111, + passage_id: 'JHN.3', + }) + }) +}) + // ── AC 2 / 3: optimistic apply ─────────────────────────────────────────────── describe('apply', () => { diff --git a/packages/core/src/highlights/use-highlights.ts b/packages/core/src/highlights/use-highlights.ts index 42fc35c0..a8e10248 100644 --- a/packages/core/src/highlights/use-highlights.ts +++ b/packages/core/src/highlights/use-highlights.ts @@ -1,5 +1,6 @@ import type { Highlight } from '@youversion/platform-core' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { AppState } from 'react-native' import type { AccessTokenResult, AuthPermission } from '../auth' import { useYVAuthOptional } from '../auth' @@ -488,6 +489,18 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul const refresh = useCallback((): Promise => runFetch(), [runFetch]) + // Highlights Refresh when the app returns to active — same rule as auth + // refresh and the Highlight Write Queue drain. Do not clear inFlightRef: + // foreground should join an in-flight GET, not abandon it. + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + void runFetch() + } + }) + return () => subscription.remove() + }, [runFetch]) + // The drain gave up on a write this reader is still painting. Nothing remounts, // so the un-paint arrives here (ADR 0018). useEffect( diff --git a/plans/001-highlights-refresh-on-active.md b/plans/001-highlights-refresh-on-active.md new file mode 100644 index 00000000..bbbc2ddc --- /dev/null +++ b/plans/001-highlights-refresh-on-active.md @@ -0,0 +1,262 @@ +# Plan 001: Highlights Refresh when the app becomes `active` + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report — do not improvise. When done, update the status row for this plan +> in `plans/README.md` — unless a reviewer dispatched you and told you they +> maintain the index. +> +> **Drift check (run first)**: +> `git diff --stat f5257fc..HEAD -- packages/core/src/highlights/use-highlights.ts packages/core/src/highlights/__tests__/use-highlights.test.tsx CONTEXT.md packages/core/README.md .changeset/` +> If any in-scope file changed since this plan was written, compare the +> "Current state" excerpts against the live code before proceeding; on a +> mismatch, treat it as a STOP condition. + +## Status + +- **Priority**: P1 +- **Effort**: S +- **Risk**: LOW +- **Depends on**: none +- **Category**: direction (YPE-4491, simplified) +- **Planned at**: commit `f5257fc`, 2026-08-11 + +## Why this matters + +Mounted `useHighlights` already runs a **Highlights Refresh** on mount and when the **Highlight Scope** (or token) changes. If the partner app stays alive in the background, those triggers never fire — **Cached Highlights** stay stale until the user kills the app or changes chapter. + +This plan adds one missing trigger: when React Native reports `AppState` → `active`, call the same `runFetch` path. That matches how auth and the highlight write-queue drain already wake on this branch. It does **not** promise cross-app completeness (a known API gap can still withhold Bible-app highlights); it only asks the server again. + +**Deliberately out of this plan:** a `BibleReader` focus/`ref` API for keep-mounted tab returns. See `plans/README.md`. + +## Current state + +### Files + +- `packages/core/src/highlights/use-highlights.ts` — public `useHighlights`; owns `runFetch` / `refresh` / `inFlightRef`. **No `AppState` listener today.** +- `packages/core/src/highlights/highlight-queue-drain-host.tsx` — exemplar AppState wiring (copy this pattern). +- `packages/core/src/highlights/__tests__/use-highlights.test.tsx` — primary test file; already covers mount fetch, scope change, and concurrent `refresh` coalescing. +- `packages/core/src/highlights/__tests__/highlight-queue-drain-host.test.tsx` — exemplar for mocking `AppState.addEventListener`. +- `CONTEXT.md` — may already define **Highlights Refresh** (from prior domain work on this worktree). Confirm and align; do not duplicate. +- `packages/core/README.md` — documents `refresh()` for pull-to-refresh; does not mention AppState. +- `.changeset/native-highlights-release.md` — open minor for the highlights epic (prefer appending one sentence here over a new changeset). + +### `runFetch` + mount/scope effect (today) + +```415:489:packages/core/src/highlights/use-highlights.ts + const runFetch = useCallback((): Promise => { + // ... + const existing = inFlightRef.current + if (existing !== null) { + return existing + } + // ... getHighlights → setState(serverUpdated(...)) ... + }, [api, canFetchHighlights]) + + useEffect(() => { + inFlightRef.current = null + void runFetch() + }, [identityKey, accessToken, runFetch]) + + const refresh = useCallback((): Promise => runFetch(), [runFetch]) +``` + +### Exemplar AppState listener (copy shape, call `runFetch` instead of `drainNow`) + +```56:63:packages/core/src/highlights/highlight-queue-drain-host.tsx + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + drainRef.current?.drainNow() + } + }) + return () => subscription.remove() + }, []) +``` + +### Domain vocabulary (honor these names in comments/docs) + +From `CONTEXT.md`: + +- **Highlights Refresh**: A GET that updates **Cached Highlights** for the current **Highlight Scope**. Same operation whether triggered by mount, a scope change, the app returning to `active`, or a host calling `refresh`. Overlapping calls coalesce onto one in-flight request. It asks the server again; it does not promise the server returns every highlight the user has elsewhere. +- **Cached Highlights** / **Highlight Scope** / **Queued Write** — existing terms; a successful refresh still folds queued writes into paint (already implemented inside `runFetch` via `serverUpdated` + `getQueuedWrites`). + +### Conventions + +- Exact dependency pins; no new packages (`AppState` is from `react-native`, already a peer). +- Conventional commits, e.g. `feat(core): refresh highlights when the app becomes active (YPE-4491)`. +- Tests: Jest + `jest-expo`; prefer `act()` around async; mock patterns already in the two test files above. +- No non-null assertions in source (`x!`). + +## Commands you will need + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Drift check | `git diff --stat f5257fc..HEAD -- packages/core/src/highlights/use-highlights.ts packages/core/src/highlights/__tests__/use-highlights.test.tsx CONTEXT.md packages/core/README.md .changeset/` | empty, or only expected advisor edits to `CONTEXT.md` | +| Core tests | `pnpm --filter @youversion/platform-react-native-expo-core test -- use-highlights` | all pass | +| Core typecheck | `pnpm --filter @youversion/platform-react-native-expo-core typecheck` | exit 0 | +| Lint touched | `pnpm exec eslint packages/core/src/highlights/use-highlights.ts packages/core/src/highlights/__tests__/use-highlights.test.tsx` | exit 0 | +| Format check (optional) | `pnpm format:check` | exit 0 | + +Run package commands from the **repo root** (this worktree). + +## Scope + +**In scope** (the only files you should modify): + +- `packages/core/src/highlights/use-highlights.ts` +- `packages/core/src/highlights/__tests__/use-highlights.test.tsx` +- `CONTEXT.md` (only if **Highlights Refresh** term/relationship is missing or contradicts this plan) +- `packages/core/README.md` (one short note under Highlights) +- `.changeset/native-highlights-release.md` **or** a new `.changeset/*.md` if that file is gone + +**Out of scope** (do NOT touch): + +- `packages/ui/**` — no `BibleReader` ref/handle, no navigation focus wiring +- `packages/core/src/highlights/highlight-queue-drain-host.tsx` — exemplar only; do not “improve” it +- Connectivity / `expo-network` GET triggers +- Filtering `background → active` vs `inactive → active` +- API / server completeness for Bible-app highlights +- New ADR +- Example app `useFocusEffect` demo (optional follow-up) + +## Git workflow + +- Branch from current `highlights` (or `advisor/001-highlights-refresh-on-active` if you need isolation). +- Commit style: conventional commits — example from history: `feat(core): highlight writes park offline and reconcile on reconnect (YPE-3717) (#125)`. +- Do NOT push or open a PR unless the operator asked. + +## Steps + +### Step 0: Drift check + +Run the drift command in the Executor instructions. If `use-highlights.ts` already has an `AppState` listener, STOP and report (work may already be done). + +**Verify**: drift output reviewed; no unexpected in-scope changes that invalidate excerpts. + +### Step 1: Subscribe to `AppState` in `useHighlights` + +In `packages/core/src/highlights/use-highlights.ts`: + +1. Add import: `import { AppState } from 'react-native'` (keep imports at top of file; no inline imports). +2. After `const refresh = useCallback(...)`, add an effect that mirrors the drain host: + +```ts +useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + void runFetch() + } + }) + return () => subscription.remove() +}, [runFetch]) +``` + +Notes: + +- Call `runFetch`, not `refresh` — same function, fewer indirection layers. +- Do **not** clear `inFlightRef` here (unlike the identity/token effect). Clearing would abandon coalescing and start a duplicate GET while one is in flight. Returning to `active` should join an in-flight request via the existing `inFlightRef` guard. +- Do **not** filter on previous `background` vs `inactive`. Fire on `state === 'active'` only. +- Optional one-line comment is fine if it names **Highlights Refresh** and points at the drain-host parity; do not write an essay. + +**Verify**: `pnpm exec eslint packages/core/src/highlights/use-highlights.ts` → exit 0. + +### Step 2: Tests + +In `packages/core/src/highlights/__tests__/use-highlights.test.tsx`: + +**2a. AppState → active refreshes** + +Model the listener capture after `highlight-queue-drain-host.test.tsx`: + +```ts +jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, listener) => { + appStateListener = listener as (state: AppStateStatus) => void + return { remove: jest.fn() } +}) +``` + +Add a describe block (e.g. `Highlights Refresh on AppState`) that: + +1. Renders `renderUseHighlights()`, waits for the mount GET to settle (`await act(async () => { await Promise.resolve() })` or drain whatever pattern sibling tests use). +2. Clears `mockGetHighlights` mock call history (or records `toHaveBeenCalledTimes` baseline). +3. `act(() => appStateListener?.('background'))` → **no** additional GET. +4. `act(() => appStateListener?.('active'))` → GET called again (same passage scope as mount: `version_id: 111`, `passage_id: 'JHN.3'`). +5. Assert the subscription is removed on unmount (`remove` mock called) — copy the drain-host cleanup spirit if easy; skip if awkward. + +Import `AppState` / `AppStateStatus` from `react-native` at the top of the test file. + +**2b. Scope-change re-fetch (regression pin)** + +Existing tests change chapter but do not clearly assert a second GET for the new passage. Add one focused test under `fetching server truth`: + +1. `renderUseHighlights()`, drain mount fetch. +2. `mockGetHighlights.mockClear()`. +3. `rerender({ versionId: 111, book: 'JHN', chapter: '4' })`. +4. Drain the new fetch. +5. Expect `mockGetHighlights` called with `'token-1'` and `{ version_id: 111, passage_id: 'JHN.4' }`. + +**Verify**: `pnpm --filter @youversion/platform-react-native-expo-core test -- use-highlights` → all pass, including the new cases. + +### Step 3: Docs + domain + changeset + +1. **`CONTEXT.md`**: If **Highlights Refresh** is missing, add the definition from “Current state” after **Cached Highlights**, plus a Relationships bullet: + + > A **Highlights Refresh** replaces **Cached Highlights** from the network for one **Highlight Scope**, then folds **Queued Writes** back in so unsent paint survives the round-trip. Returning to `active` triggers it automatically inside the highlights subscription. Hosts that keep a custom surface mounted may call `refresh` when their screen is shown again — the SDK does not take a navigation library as a dependency to detect focus. + + If the term already exists and matches, leave it. If it promises a `BibleReader` convenience API as required, soften to the wording above (host `refresh` only). + +2. **`packages/core/README.md`**: In the Highlights section near `refresh` / `isRefreshing`, add one sentence: returning the app to the foreground also runs the same refresh automatically for mounted `useHighlights` subscriptions. + +3. **Changeset**: Prefer appending one sentence to `.changeset/native-highlights-release.md` under Reading/writing highlights, e.g. mounted `useHighlights` also refreshes when the app becomes active. If that file is gone, create a new changeset with `pnpm changeset` (patch on `@youversion/platform-react-native-expo-core`) or hand-write a patch markdown matching repo style. + +**Verify**: `pnpm --filter @youversion/platform-react-native-expo-core typecheck` → exit 0. + +### Step 4: Final gate + +**Verify all**: + +- `pnpm --filter @youversion/platform-react-native-expo-core test -- use-highlights` → pass +- `pnpm --filter @youversion/platform-react-native-expo-core typecheck` → exit 0 +- `git status` → only in-scope files (+ this plan’s README status) +- Update `plans/README.md` status for 001 → DONE + +## Test plan + +| Case | File | Asserts | +|------|------|---------| +| `active` triggers GET | `use-highlights.test.tsx` | after mount settle, `active` → another `getHighlights` | +| `background` does not | same | no GET on leave | +| Chapter change GET | same | after clear, chapter `4` → `passage_id: 'JHN.4'` | +| Coalescing (existing) | same | `shares one in-flight request between concurrent refresh calls` still passes — AppState must not break `inFlightRef` | + +Pattern sources: `highlight-queue-drain-host.test.tsx` (AppState mock), `fetching server truth` describe in `use-highlights.test.tsx` (GET assertions). + +## Done criteria + +- [ ] `use-highlights.ts` has an `AppState` listener that calls `runFetch()` on `active` without clearing `inFlightRef` +- [ ] New tests cover AppState refresh + chapter-change GET; `pnpm --filter @youversion/platform-react-native-expo-core test -- use-highlights` exits 0 +- [ ] `pnpm --filter @youversion/platform-react-native-expo-core typecheck` exits 0 +- [ ] `CONTEXT.md` defines **Highlights Refresh** consistently with this plan +- [ ] Core README mentions foreground refresh +- [ ] Changeset updated or added +- [ ] No files outside the in-scope list modified +- [ ] `plans/README.md` status row → DONE + +## STOP conditions + +Stop and report (do not improvise) if: + +- Drift check shows `use-highlights.ts` already implements AppState refresh differently (e.g. `background`-only filter, provider-level refresh of all scopes). +- Adding `AppState` import fails typecheck (unexpected RN types in core) — report; do not add stubs. +- Tests cannot capture the listener because `AppState.addEventListener` is already mocked globally in a conflicting way — fix locally in this file only; if the conflict is in shared jest setup, STOP. +- You believe moment 2 / `BibleReader` handle is required to close YPE-4491 — do not build it here; report so the operator can open a follow-up plan. +- A step’s verification fails twice after a reasonable fix attempt. + +## Maintenance notes + +- Reviewers: confirm the listener does **not** null `inFlightRef` (identity effect does; this one must not). +- Future: keep-mounted navigation focus needs a reader-level way to call the *same* `runFetch` (BibleReader owns the hook). That is a separate plan / ticket slice. +- YPE-4491 Jira Value/AC should be rewritten to match **Highlights Refresh** (not “Bible-app highlight appears”); API Impediment is not a blocker for *this* SDK work. +- If YPE-4499 (hung fetch blocks later refresh) is still open, AppState will share that pain — do not “fix” coalescing in this plan beyond leaving `inFlightRef` intact. diff --git a/plans/README.md b/plans/README.md new file mode 100644 index 00000000..415eea61 --- /dev/null +++ b/plans/README.md @@ -0,0 +1,23 @@ +# Implementation Plans + +Generated by the improve skill on 2026-08-11 against `origin/highlights` (`f5257fc`). Execute in the order below. Each executor: read the plan fully before starting, honor its STOP conditions, and update your row when done. + +## Execution order & status + +| Plan | Title | Priority | Effort | Depends on | Status | +|------|-------|----------|--------|------------|--------| +| 001 | Highlights Refresh when the app becomes `active` | P1 | S | — | DONE | + +Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | REJECTED (with one-line rationale) + +## Dependency notes + +- Single plan. No chain. + +## Findings considered and rejected (or deferred) + +- **BibleReader `ref.refresh` / navigation-focus handle (YPE-4491 moment 2):** Deferred. `BibleReader` owns `useHighlights` internally; a host-only `useHighlights().refresh()` cannot update the reader's paint. Exposing a reader handle is real API surface for a rarer case (keep-mounted tab while another device highlights). Moment 1 (leave app → return) covers the primary journey. Ship core AppState first; file a follow-up if partners need keep-mounted focus refresh. +- **`background → active` only (skip `inactive → active`):** Rejected. Auth token refresh and the **Highlight Write Queue** drain already fire on any `active`. `useHighlights` coalesces overlapping GETs via `inFlightRef`. Matching those listeners is simpler and good enough. +- **Connectivity rising-edge GET:** Rejected for this ticket. `expo-network` already wakes the write-queue drain; a GET on reconnect is a separate freshness claim. +- **New ADR:** Rejected. Vocabulary lives in `CONTEXT.md`; AppState rule matches existing core code. +- **Cross-app “Bible app highlight appears” as done bar:** Rejected (server currently withholds those until a Platform write — API Impediment). This plan only owns asking the server again. From b1d647f34c4a29a360290aef5024ed427e7c7f9a Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 11 Aug 2026 15:37:17 -0500 Subject: [PATCH 18/43] chore: drop advisor plan files from the YPE-4491 PR Co-authored-by: Cursor --- plans/001-highlights-refresh-on-active.md | 262 ---------------------- plans/README.md | 23 -- 2 files changed, 285 deletions(-) delete mode 100644 plans/001-highlights-refresh-on-active.md delete mode 100644 plans/README.md diff --git a/plans/001-highlights-refresh-on-active.md b/plans/001-highlights-refresh-on-active.md deleted file mode 100644 index bbbc2ddc..00000000 --- a/plans/001-highlights-refresh-on-active.md +++ /dev/null @@ -1,262 +0,0 @@ -# Plan 001: Highlights Refresh when the app becomes `active` - -> **Executor instructions**: Follow this plan step by step. Run every -> verification command and confirm the expected result before moving to the -> next step. If anything in the "STOP conditions" section occurs, stop and -> report — do not improvise. When done, update the status row for this plan -> in `plans/README.md` — unless a reviewer dispatched you and told you they -> maintain the index. -> -> **Drift check (run first)**: -> `git diff --stat f5257fc..HEAD -- packages/core/src/highlights/use-highlights.ts packages/core/src/highlights/__tests__/use-highlights.test.tsx CONTEXT.md packages/core/README.md .changeset/` -> If any in-scope file changed since this plan was written, compare the -> "Current state" excerpts against the live code before proceeding; on a -> mismatch, treat it as a STOP condition. - -## Status - -- **Priority**: P1 -- **Effort**: S -- **Risk**: LOW -- **Depends on**: none -- **Category**: direction (YPE-4491, simplified) -- **Planned at**: commit `f5257fc`, 2026-08-11 - -## Why this matters - -Mounted `useHighlights` already runs a **Highlights Refresh** on mount and when the **Highlight Scope** (or token) changes. If the partner app stays alive in the background, those triggers never fire — **Cached Highlights** stay stale until the user kills the app or changes chapter. - -This plan adds one missing trigger: when React Native reports `AppState` → `active`, call the same `runFetch` path. That matches how auth and the highlight write-queue drain already wake on this branch. It does **not** promise cross-app completeness (a known API gap can still withhold Bible-app highlights); it only asks the server again. - -**Deliberately out of this plan:** a `BibleReader` focus/`ref` API for keep-mounted tab returns. See `plans/README.md`. - -## Current state - -### Files - -- `packages/core/src/highlights/use-highlights.ts` — public `useHighlights`; owns `runFetch` / `refresh` / `inFlightRef`. **No `AppState` listener today.** -- `packages/core/src/highlights/highlight-queue-drain-host.tsx` — exemplar AppState wiring (copy this pattern). -- `packages/core/src/highlights/__tests__/use-highlights.test.tsx` — primary test file; already covers mount fetch, scope change, and concurrent `refresh` coalescing. -- `packages/core/src/highlights/__tests__/highlight-queue-drain-host.test.tsx` — exemplar for mocking `AppState.addEventListener`. -- `CONTEXT.md` — may already define **Highlights Refresh** (from prior domain work on this worktree). Confirm and align; do not duplicate. -- `packages/core/README.md` — documents `refresh()` for pull-to-refresh; does not mention AppState. -- `.changeset/native-highlights-release.md` — open minor for the highlights epic (prefer appending one sentence here over a new changeset). - -### `runFetch` + mount/scope effect (today) - -```415:489:packages/core/src/highlights/use-highlights.ts - const runFetch = useCallback((): Promise => { - // ... - const existing = inFlightRef.current - if (existing !== null) { - return existing - } - // ... getHighlights → setState(serverUpdated(...)) ... - }, [api, canFetchHighlights]) - - useEffect(() => { - inFlightRef.current = null - void runFetch() - }, [identityKey, accessToken, runFetch]) - - const refresh = useCallback((): Promise => runFetch(), [runFetch]) -``` - -### Exemplar AppState listener (copy shape, call `runFetch` instead of `drainNow`) - -```56:63:packages/core/src/highlights/highlight-queue-drain-host.tsx - useEffect(() => { - const subscription = AppState.addEventListener('change', (state) => { - if (state === 'active') { - drainRef.current?.drainNow() - } - }) - return () => subscription.remove() - }, []) -``` - -### Domain vocabulary (honor these names in comments/docs) - -From `CONTEXT.md`: - -- **Highlights Refresh**: A GET that updates **Cached Highlights** for the current **Highlight Scope**. Same operation whether triggered by mount, a scope change, the app returning to `active`, or a host calling `refresh`. Overlapping calls coalesce onto one in-flight request. It asks the server again; it does not promise the server returns every highlight the user has elsewhere. -- **Cached Highlights** / **Highlight Scope** / **Queued Write** — existing terms; a successful refresh still folds queued writes into paint (already implemented inside `runFetch` via `serverUpdated` + `getQueuedWrites`). - -### Conventions - -- Exact dependency pins; no new packages (`AppState` is from `react-native`, already a peer). -- Conventional commits, e.g. `feat(core): refresh highlights when the app becomes active (YPE-4491)`. -- Tests: Jest + `jest-expo`; prefer `act()` around async; mock patterns already in the two test files above. -- No non-null assertions in source (`x!`). - -## Commands you will need - -| Purpose | Command | Expected on success | -|---------|---------|---------------------| -| Drift check | `git diff --stat f5257fc..HEAD -- packages/core/src/highlights/use-highlights.ts packages/core/src/highlights/__tests__/use-highlights.test.tsx CONTEXT.md packages/core/README.md .changeset/` | empty, or only expected advisor edits to `CONTEXT.md` | -| Core tests | `pnpm --filter @youversion/platform-react-native-expo-core test -- use-highlights` | all pass | -| Core typecheck | `pnpm --filter @youversion/platform-react-native-expo-core typecheck` | exit 0 | -| Lint touched | `pnpm exec eslint packages/core/src/highlights/use-highlights.ts packages/core/src/highlights/__tests__/use-highlights.test.tsx` | exit 0 | -| Format check (optional) | `pnpm format:check` | exit 0 | - -Run package commands from the **repo root** (this worktree). - -## Scope - -**In scope** (the only files you should modify): - -- `packages/core/src/highlights/use-highlights.ts` -- `packages/core/src/highlights/__tests__/use-highlights.test.tsx` -- `CONTEXT.md` (only if **Highlights Refresh** term/relationship is missing or contradicts this plan) -- `packages/core/README.md` (one short note under Highlights) -- `.changeset/native-highlights-release.md` **or** a new `.changeset/*.md` if that file is gone - -**Out of scope** (do NOT touch): - -- `packages/ui/**` — no `BibleReader` ref/handle, no navigation focus wiring -- `packages/core/src/highlights/highlight-queue-drain-host.tsx` — exemplar only; do not “improve” it -- Connectivity / `expo-network` GET triggers -- Filtering `background → active` vs `inactive → active` -- API / server completeness for Bible-app highlights -- New ADR -- Example app `useFocusEffect` demo (optional follow-up) - -## Git workflow - -- Branch from current `highlights` (or `advisor/001-highlights-refresh-on-active` if you need isolation). -- Commit style: conventional commits — example from history: `feat(core): highlight writes park offline and reconcile on reconnect (YPE-3717) (#125)`. -- Do NOT push or open a PR unless the operator asked. - -## Steps - -### Step 0: Drift check - -Run the drift command in the Executor instructions. If `use-highlights.ts` already has an `AppState` listener, STOP and report (work may already be done). - -**Verify**: drift output reviewed; no unexpected in-scope changes that invalidate excerpts. - -### Step 1: Subscribe to `AppState` in `useHighlights` - -In `packages/core/src/highlights/use-highlights.ts`: - -1. Add import: `import { AppState } from 'react-native'` (keep imports at top of file; no inline imports). -2. After `const refresh = useCallback(...)`, add an effect that mirrors the drain host: - -```ts -useEffect(() => { - const subscription = AppState.addEventListener('change', (state) => { - if (state === 'active') { - void runFetch() - } - }) - return () => subscription.remove() -}, [runFetch]) -``` - -Notes: - -- Call `runFetch`, not `refresh` — same function, fewer indirection layers. -- Do **not** clear `inFlightRef` here (unlike the identity/token effect). Clearing would abandon coalescing and start a duplicate GET while one is in flight. Returning to `active` should join an in-flight request via the existing `inFlightRef` guard. -- Do **not** filter on previous `background` vs `inactive`. Fire on `state === 'active'` only. -- Optional one-line comment is fine if it names **Highlights Refresh** and points at the drain-host parity; do not write an essay. - -**Verify**: `pnpm exec eslint packages/core/src/highlights/use-highlights.ts` → exit 0. - -### Step 2: Tests - -In `packages/core/src/highlights/__tests__/use-highlights.test.tsx`: - -**2a. AppState → active refreshes** - -Model the listener capture after `highlight-queue-drain-host.test.tsx`: - -```ts -jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, listener) => { - appStateListener = listener as (state: AppStateStatus) => void - return { remove: jest.fn() } -}) -``` - -Add a describe block (e.g. `Highlights Refresh on AppState`) that: - -1. Renders `renderUseHighlights()`, waits for the mount GET to settle (`await act(async () => { await Promise.resolve() })` or drain whatever pattern sibling tests use). -2. Clears `mockGetHighlights` mock call history (or records `toHaveBeenCalledTimes` baseline). -3. `act(() => appStateListener?.('background'))` → **no** additional GET. -4. `act(() => appStateListener?.('active'))` → GET called again (same passage scope as mount: `version_id: 111`, `passage_id: 'JHN.3'`). -5. Assert the subscription is removed on unmount (`remove` mock called) — copy the drain-host cleanup spirit if easy; skip if awkward. - -Import `AppState` / `AppStateStatus` from `react-native` at the top of the test file. - -**2b. Scope-change re-fetch (regression pin)** - -Existing tests change chapter but do not clearly assert a second GET for the new passage. Add one focused test under `fetching server truth`: - -1. `renderUseHighlights()`, drain mount fetch. -2. `mockGetHighlights.mockClear()`. -3. `rerender({ versionId: 111, book: 'JHN', chapter: '4' })`. -4. Drain the new fetch. -5. Expect `mockGetHighlights` called with `'token-1'` and `{ version_id: 111, passage_id: 'JHN.4' }`. - -**Verify**: `pnpm --filter @youversion/platform-react-native-expo-core test -- use-highlights` → all pass, including the new cases. - -### Step 3: Docs + domain + changeset - -1. **`CONTEXT.md`**: If **Highlights Refresh** is missing, add the definition from “Current state” after **Cached Highlights**, plus a Relationships bullet: - - > A **Highlights Refresh** replaces **Cached Highlights** from the network for one **Highlight Scope**, then folds **Queued Writes** back in so unsent paint survives the round-trip. Returning to `active` triggers it automatically inside the highlights subscription. Hosts that keep a custom surface mounted may call `refresh` when their screen is shown again — the SDK does not take a navigation library as a dependency to detect focus. - - If the term already exists and matches, leave it. If it promises a `BibleReader` convenience API as required, soften to the wording above (host `refresh` only). - -2. **`packages/core/README.md`**: In the Highlights section near `refresh` / `isRefreshing`, add one sentence: returning the app to the foreground also runs the same refresh automatically for mounted `useHighlights` subscriptions. - -3. **Changeset**: Prefer appending one sentence to `.changeset/native-highlights-release.md` under Reading/writing highlights, e.g. mounted `useHighlights` also refreshes when the app becomes active. If that file is gone, create a new changeset with `pnpm changeset` (patch on `@youversion/platform-react-native-expo-core`) or hand-write a patch markdown matching repo style. - -**Verify**: `pnpm --filter @youversion/platform-react-native-expo-core typecheck` → exit 0. - -### Step 4: Final gate - -**Verify all**: - -- `pnpm --filter @youversion/platform-react-native-expo-core test -- use-highlights` → pass -- `pnpm --filter @youversion/platform-react-native-expo-core typecheck` → exit 0 -- `git status` → only in-scope files (+ this plan’s README status) -- Update `plans/README.md` status for 001 → DONE - -## Test plan - -| Case | File | Asserts | -|------|------|---------| -| `active` triggers GET | `use-highlights.test.tsx` | after mount settle, `active` → another `getHighlights` | -| `background` does not | same | no GET on leave | -| Chapter change GET | same | after clear, chapter `4` → `passage_id: 'JHN.4'` | -| Coalescing (existing) | same | `shares one in-flight request between concurrent refresh calls` still passes — AppState must not break `inFlightRef` | - -Pattern sources: `highlight-queue-drain-host.test.tsx` (AppState mock), `fetching server truth` describe in `use-highlights.test.tsx` (GET assertions). - -## Done criteria - -- [ ] `use-highlights.ts` has an `AppState` listener that calls `runFetch()` on `active` without clearing `inFlightRef` -- [ ] New tests cover AppState refresh + chapter-change GET; `pnpm --filter @youversion/platform-react-native-expo-core test -- use-highlights` exits 0 -- [ ] `pnpm --filter @youversion/platform-react-native-expo-core typecheck` exits 0 -- [ ] `CONTEXT.md` defines **Highlights Refresh** consistently with this plan -- [ ] Core README mentions foreground refresh -- [ ] Changeset updated or added -- [ ] No files outside the in-scope list modified -- [ ] `plans/README.md` status row → DONE - -## STOP conditions - -Stop and report (do not improvise) if: - -- Drift check shows `use-highlights.ts` already implements AppState refresh differently (e.g. `background`-only filter, provider-level refresh of all scopes). -- Adding `AppState` import fails typecheck (unexpected RN types in core) — report; do not add stubs. -- Tests cannot capture the listener because `AppState.addEventListener` is already mocked globally in a conflicting way — fix locally in this file only; if the conflict is in shared jest setup, STOP. -- You believe moment 2 / `BibleReader` handle is required to close YPE-4491 — do not build it here; report so the operator can open a follow-up plan. -- A step’s verification fails twice after a reasonable fix attempt. - -## Maintenance notes - -- Reviewers: confirm the listener does **not** null `inFlightRef` (identity effect does; this one must not). -- Future: keep-mounted navigation focus needs a reader-level way to call the *same* `runFetch` (BibleReader owns the hook). That is a separate plan / ticket slice. -- YPE-4491 Jira Value/AC should be rewritten to match **Highlights Refresh** (not “Bible-app highlight appears”); API Impediment is not a blocker for *this* SDK work. -- If YPE-4499 (hung fetch blocks later refresh) is still open, AppState will share that pain — do not “fix” coalescing in this plan beyond leaving `inFlightRef` intact. diff --git a/plans/README.md b/plans/README.md deleted file mode 100644 index 415eea61..00000000 --- a/plans/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Implementation Plans - -Generated by the improve skill on 2026-08-11 against `origin/highlights` (`f5257fc`). Execute in the order below. Each executor: read the plan fully before starting, honor its STOP conditions, and update your row when done. - -## Execution order & status - -| Plan | Title | Priority | Effort | Depends on | Status | -|------|-------|----------|--------|------------|--------| -| 001 | Highlights Refresh when the app becomes `active` | P1 | S | — | DONE | - -Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | REJECTED (with one-line rationale) - -## Dependency notes - -- Single plan. No chain. - -## Findings considered and rejected (or deferred) - -- **BibleReader `ref.refresh` / navigation-focus handle (YPE-4491 moment 2):** Deferred. `BibleReader` owns `useHighlights` internally; a host-only `useHighlights().refresh()` cannot update the reader's paint. Exposing a reader handle is real API surface for a rarer case (keep-mounted tab while another device highlights). Moment 1 (leave app → return) covers the primary journey. Ship core AppState first; file a follow-up if partners need keep-mounted focus refresh. -- **`background → active` only (skip `inactive → active`):** Rejected. Auth token refresh and the **Highlight Write Queue** drain already fire on any `active`. `useHighlights` coalesces overlapping GETs via `inFlightRef`. Matching those listeners is simpler and good enough. -- **Connectivity rising-edge GET:** Rejected for this ticket. `expo-network` already wakes the write-queue drain; a GET on reconnect is a separate freshness claim. -- **New ADR:** Rejected. Vocabulary lives in `CONTEXT.md`; AppState rule matches existing core code. -- **Cross-app “Bible app highlight appears” as done bar:** Rejected (server currently withholds those until a Platform write — API Impediment). This plan only owns asking the server again. From 057c2ff9b72c51693f57669df72e92d61cba6517 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 11 Aug 2026 16:50:16 -0500 Subject: [PATCH 19/43] chore: drop AGENTS.md learned sections from YPE-4491 Keep the feature PR on the planned file list. Highlights Refresh vocabulary already lives in CONTEXT.md. Co-authored-by: Cursor --- AGENTS.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3b35a4a6..6df277c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -291,13 +291,3 @@ Full guide: [docs/contributing/native-i18n.md](./docs/contributing/native-i18n.m ## Recommended Agent Skill This repo uses `CONTEXT.md` and `docs/adr/` for domain language and architectural decisions. Before planning changes, use the [grill-with-docs](https://www.skills.sh/mattpocock/skills/grill-with-docs) skill to stress-test your plan against the documented domain model — it challenges terminology and updates docs inline as decisions crystallize. - -## Learned User Preferences - -- During grilling or option tradeoffs, when impartial: pick the best reasonable option for robustness and best practices, and avoid spending time optimizing cases that do not need it. -- Ticket *how* is flexible when the *what* is right — rewrite the ticket/Jira to match locked decisions when still solving the same main idea. - -## Learned Workspace Facts - -- **Highlights Refresh** is the domain name for a GET that updates **Cached Highlights** for the current **Highlight Scope** (mount, scope change, and AppState → `active`; host screen-focus / `BibleReader` convenience deferred). -- AppState-driven Highlights Refresh should fire on return to `active` with the same rule as auth refresh and the Highlight Write Queue drain — not a `background → active`-only filter. From 0c1303a2ce02cdde7651e9be62161d0ff860d8e8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 16:16:21 +0000 Subject: [PATCH 20/43] test(core): pin AppState listener cleanup on unmount (YPE-4491) Co-authored-by: Cameron Pak --- .../__tests__/use-highlights.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/core/src/highlights/__tests__/use-highlights.test.tsx b/packages/core/src/highlights/__tests__/use-highlights.test.tsx index 47a1e4be..e1f46b54 100644 --- a/packages/core/src/highlights/__tests__/use-highlights.test.tsx +++ b/packages/core/src/highlights/__tests__/use-highlights.test.tsx @@ -586,6 +586,25 @@ describe('Highlights Refresh on AppState', () => { passage_id: 'JHN.3', }) }) + + it('registers a "change" listener on mount and removes it on unmount', async () => { + const remove = jest.fn() + jest.mocked(AppState.addEventListener).mockImplementation((_event, listener) => { + appStateListener = listener as (state: AppStateStatus) => void + return { remove } + }) + + const { unmount } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)) + expect(remove).not.toHaveBeenCalled() + + unmount() + expect(remove).toHaveBeenCalledTimes(1) + }) }) // ── AC 2 / 3: optimistic apply ─────────────────────────────────────────────── From fe154ff515202202d6cd50ffb621d70287ce664c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 15:54:36 +0000 Subject: [PATCH 21/43] feat(highlights): paint and clear valid non-palette API hex (YPE-4494) Partner apps share a highlights DB with the main Bible app, which can use colors outside the five SDK swatches. RN Expo now paints valid non-palette hex from the API, shows an exact-hex remove swatch (ANY rule), keeps apply palette-only, and drops invalid hex from paint and the action tray. Co-authored-by: Cameron Pak --- .../src/highlights/__tests__/cache.test.ts | 9 ++++ .../__tests__/paint-projection.test.ts | 35 +++++++++++++ .../__tests__/use-highlights.test.tsx | 19 ++++++- packages/core/src/highlights/cache.ts | 4 ++ packages/core/src/highlights/constants.ts | 6 +-- packages/core/src/highlights/index.ts | 1 + .../core/src/highlights/paint-projection.ts | 24 +++++++++ .../core/src/highlights/use-highlights.ts | 13 ++++- packages/core/src/index.ts | 1 + .../__tests__/verse-action-swatches.test.ts | 17 +++++-- packages/ui/src/lib/verse-action-swatches.ts | 51 +++++++++++++------ 11 files changed, 155 insertions(+), 25 deletions(-) create mode 100644 packages/core/src/highlights/__tests__/paint-projection.test.ts create mode 100644 packages/core/src/highlights/paint-projection.ts diff --git a/packages/core/src/highlights/__tests__/cache.test.ts b/packages/core/src/highlights/__tests__/cache.test.ts index f22899ff..ebdec5ac 100644 --- a/packages/core/src/highlights/__tests__/cache.test.ts +++ b/packages/core/src/highlights/__tests__/cache.test.ts @@ -223,4 +223,13 @@ describe('deriveServerColors', () => { 18: 'fffe00', }) }) + + it('drops invalid hex from paint projection', () => { + expect( + deriveServerColors( + [highlight('JHN.3.16', 'fffe00'), highlight('JHN.3.17', 'gg0000'), highlight('JHN.3.18', '123456')], + scope, + ), + ).toEqual({ 16: 'fffe00', 18: '123456' }) + }) }) diff --git a/packages/core/src/highlights/__tests__/paint-projection.test.ts b/packages/core/src/highlights/__tests__/paint-projection.test.ts new file mode 100644 index 00000000..b8108478 --- /dev/null +++ b/packages/core/src/highlights/__tests__/paint-projection.test.ts @@ -0,0 +1,35 @@ +import { HIGHLIGHT_COLORS, type ServerColors } from '../constants' +import { isValidHighlightHex, projectPaintColors } from '../paint-projection' + +const [YELLOW] = HIGHLIGHT_COLORS + +describe('isValidHighlightHex', () => { + it('accepts lowercase and uppercase six-digit hex', () => { + expect(isValidHighlightHex('fffe00')).toBe(true) + expect(isValidHighlightHex('FFFE00')).toBe(true) + expect(isValidHighlightHex('123456')).toBe(true) + }) + + it('rejects hash-prefixed, short, long, and non-hex values', () => { + expect(isValidHighlightHex('#fffe00')).toBe(false) + expect(isValidHighlightHex('fff')).toBe(false) + expect(isValidHighlightHex('gg0000')).toBe(false) + expect(isValidHighlightHex('')).toBe(false) + }) +}) + +describe('projectPaintColors', () => { + it('keeps valid non-palette hex and normalizes case', () => { + const colors: ServerColors = { 1: '123456', 2: 'AaBbCc' } + expect(projectPaintColors(colors)).toEqual({ 1: '123456', 2: 'aabbcc' }) + }) + + it('keeps palette hex', () => { + expect(projectPaintColors({ 16: YELLOW })).toEqual({ 16: YELLOW }) + }) + + it('drops invalid hex from paint', () => { + const colors: ServerColors = { 1: YELLOW, 2: 'gg0000', 3: '123456' } + expect(projectPaintColors(colors)).toEqual({ 1: YELLOW, 3: '123456' }) + }) +}) diff --git a/packages/core/src/highlights/__tests__/use-highlights.test.tsx b/packages/core/src/highlights/__tests__/use-highlights.test.tsx index e1f46b54..4c9fa84e 100644 --- a/packages/core/src/highlights/__tests__/use-highlights.test.tsx +++ b/packages/core/src/highlights/__tests__/use-highlights.test.tsx @@ -1137,7 +1137,7 @@ describe('color validation', () => { expect(mockCreateHighlight).not.toHaveBeenCalled() }) - it('rejects a non-swatch color from remove with no request', async () => { + it('clears valid non-palette hex from remove', async () => { seedServer([highlight('JHN.3.16', 'ff0000')]) const { result } = renderUseHighlights() await act(async () => { @@ -1149,6 +1149,23 @@ describe('color validation', () => { outcome = await result.current.remove('ff0000', [16]) }) + expect(outcome).toMatchObject({ status: 'ok', verses: [16] }) + expect(mockDeleteHighlight).toHaveBeenCalled() + expect(colorsOf(result.current)).toEqual({}) + }) + + it('rejects invalid hex from remove with no request', async () => { + seedServer([highlight('JHN.3.16', 'fffe00')]) + const { result } = renderUseHighlights() + await act(async () => { + await Promise.resolve() + }) + + let outcome: HighlightWriteOutcome | undefined + await act(async () => { + outcome = await result.current.remove('gg0000', [16]) + }) + expect(outcome).toMatchObject({ status: 'error', reason: 'invalid' }) expect(mockDeleteHighlight).not.toHaveBeenCalled() }) diff --git a/packages/core/src/highlights/cache.ts b/packages/core/src/highlights/cache.ts index b68f3468..ff927b3f 100644 --- a/packages/core/src/highlights/cache.ts +++ b/packages/core/src/highlights/cache.ts @@ -7,6 +7,7 @@ import { type HighlightScope, type ServerColors, } from './constants' +import { isValidHighlightHex } from './paint-projection' import { highlightsFromColors } from './optimistic' export { @@ -112,6 +113,9 @@ export function deriveServerColors( continue } const normalizedColor = color.toLowerCase() + if (!isValidHighlightHex(normalizedColor)) { + continue + } for (const verse of expanded.verses) { colors[verse] = normalizedColor } diff --git a/packages/core/src/highlights/constants.ts b/packages/core/src/highlights/constants.ts index 0e149c02..ed2f17c3 100644 --- a/packages/core/src/highlights/constants.ts +++ b/packages/core/src/highlights/constants.ts @@ -4,9 +4,9 @@ export const MMKV_HIGHLIGHTS_KEY_PREFIX = 'yvp.highlights.' as const export const MMKV_HIGHLIGHT_QUEUE_KEY_PREFIX = 'yvp.highlightqueue.' as const /** - * The five highlight swatches, a company-wide standard across every YouVersion - * SDK. Custom colors are not supported by the product, so both write paths - * reject anything outside this list before painting or issuing a request. + * The five highlight swatches for apply. Partner apps may share a highlights DB + * with the main Bible app, which can paint valid non-palette hex from the API; + * only apply is restricted to this list. * * Duplicated from `@youversion/platform-react-ui`'s `HIGHLIGHT_COLORS` rather * than imported: that package peer-depends on `react-dom` (which core must not diff --git a/packages/core/src/highlights/index.ts b/packages/core/src/highlights/index.ts index cec747f0..611bab37 100644 --- a/packages/core/src/highlights/index.ts +++ b/packages/core/src/highlights/index.ts @@ -25,6 +25,7 @@ export { } from './cache' export { HIGHLIGHT_COLORS, isHighlightColor, type HighlightColor } from './constants' +export { isValidHighlightHex, projectPaintColors } from './paint-projection' // `clearHighlightQueue` is sign-out's, and stays internal. // `hasQueuedHighlightWrites` is public: the reader has to know whether signing diff --git a/packages/core/src/highlights/paint-projection.ts b/packages/core/src/highlights/paint-projection.ts new file mode 100644 index 00000000..1b38142f --- /dev/null +++ b/packages/core/src/highlights/paint-projection.ts @@ -0,0 +1,24 @@ +import type { ServerColors } from './constants' + +/** Six-digit hex, no `#`. Case-insensitive at the boundary. */ +const HIGHLIGHT_HEX_PATTERN = /^[0-9a-f]{6}$/i + +/** Whether `color` is a paintable highlight hex (palette or custom). */ +export function isValidHighlightHex(color: string): boolean { + return HIGHLIGHT_HEX_PATTERN.test(color) +} + +/** + * Drops verses whose color is not a valid highlight hex. Normalizes survivors to + * lowercase. Palette membership is not checked — callers gate apply separately. + */ +export function projectPaintColors(colors: ServerColors): ServerColors { + const projected: ServerColors = {} + for (const [verseKey, color] of Object.entries(colors)) { + const normalized = color.toLowerCase() + if (isValidHighlightHex(normalized)) { + projected[Number(verseKey)] = normalized + } + } + return projected +} diff --git a/packages/core/src/highlights/use-highlights.ts b/packages/core/src/highlights/use-highlights.ts index a8e10248..c12d232b 100644 --- a/packages/core/src/highlights/use-highlights.ts +++ b/packages/core/src/highlights/use-highlights.ts @@ -14,6 +14,7 @@ import { } from './cache' import { claimWrites } from './claims' import { isHighlightColor, NOT_SIGNED_IN_MESSAGE, type HighlightScope } from './constants' +import { isValidHighlightHex } from './paint-projection' import { notifyDrain } from './drain-signals' import { applyQueuedWrites, @@ -764,7 +765,17 @@ export function useHighlights(options: UseHighlightsOptions): UseHighlightsResul }) } - if (!isHighlightColor(color)) { + if (!isValidHighlightHex(color)) { + return Promise.resolve({ + status: 'error', + reason: 'invalid', + message: INVALID_COLOR_MESSAGE, + failedVerses: normalizeVerseSelection(rawVerses), + succeededVerses: [], + }) + } + + if (op === 'apply' && !isHighlightColor(color)) { return Promise.resolve({ status: 'error', reason: 'invalid', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 24229bc6..cce9bc7e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,6 +19,7 @@ export { hasQueuedHighlightWrites, HIGHLIGHT_COLORS, isHighlightColor, + isValidHighlightHex, useHighlightPermissionFlow, useHighlights, } from './highlights' diff --git a/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts b/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts index 5711819a..720f0b9d 100644 --- a/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts +++ b/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts @@ -102,11 +102,20 @@ describe('buildVerseActionSwatches', () => { ]) }) - it('ignores colors outside the five swatches', () => { - // The WebView paints from a projection that drops these, so counting them - // would size the tray against paint the user cannot see. Verse 1 therefore - // reads as bare, which is why yellow stays in the apply row. + it('shows a remove circle for valid non-palette hex and drops invalid hex', () => { expect(summarize({ verses: [1, 2], colors: { 1: '123456', 2: YELLOW } })).toEqual([ + `remove:${YELLOW}`, + 'remove:123456', + `apply:${GREEN}`, + `apply:${BLUE}`, + `apply:${ORANGE}`, + `apply:${PINK}`, + ]) + }) + + it('drops invalid hex from the tray', () => { + // Invalid paint is dropped, so verse 1 reads as bare — yellow stays in apply. + expect(summarize({ verses: [1, 2], colors: { 1: 'gg0000', 2: YELLOW } })).toEqual([ `remove:${YELLOW}`, `apply:${YELLOW}`, `apply:${GREEN}`, diff --git a/packages/ui/src/lib/verse-action-swatches.ts b/packages/ui/src/lib/verse-action-swatches.ts index a08568f3..68f48876 100644 --- a/packages/ui/src/lib/verse-action-swatches.ts +++ b/packages/ui/src/lib/verse-action-swatches.ts @@ -1,5 +1,9 @@ import type { HighlightColor, ServerColors } from '@youversion/platform-react-native-expo-core' -import { HIGHLIGHT_COLORS, isHighlightColor } from '@youversion/platform-react-native-expo-core' +import { + HIGHLIGHT_COLORS, + isHighlightColor, + isValidHighlightHex, +} from '@youversion/platform-react-native-expo-core' /** * One circle in the verse action sheet's swatch tray. @@ -8,7 +12,9 @@ import { HIGHLIGHT_COLORS, isHighlightColor } from '@youversion/platform-react-n * renders the checkmark and clears that color. `'apply'` renders the bare circle * and paints it. */ -export type VerseActionSwatch = { color: HighlightColor; state: 'apply' | 'remove' } +export type VerseActionSwatch = + | { color: HighlightColor; state: 'apply' } + | { color: string; state: 'remove' } export type BuildVerseActionSwatchesInput = { /** The verses currently selected in the reader. */ @@ -26,40 +32,53 @@ export type BuildVerseActionSwatchesInput = { * palette order. * * The apply row offers the whole palette when part of the selection is - * unhighlighted, or when the selection already carries more than one color. - * Otherwise it offers only the colors not already present. Colors outside - * `HIGHLIGHT_COLORS` are ignored, because the reader does not paint them either. + * unhighlighted, or when the selection already carries more than one palette + * color. Otherwise it offers only the palette colors not already present. Apply + * is palette-only; remove includes valid non-palette hex at its exact value. + * Invalid hex is dropped from both rows. */ export function buildVerseActionSwatches( input: BuildVerseActionSwatchesInput, ): VerseActionSwatch[] { const { verses, colors } = input - const activeColors = new Set() + const activePaletteColors = new Set() + const activeNonPaletteColors = new Set() let highlightedVerseCount = 0 for (const verse of verses) { const color = colors[verse] - if (color === undefined || !isHighlightColor(color)) { + if (color === undefined || !isValidHighlightHex(color)) { continue } - activeColors.add(color) + const normalized = color.toLowerCase() + if (isHighlightColor(normalized)) { + activePaletteColors.add(normalized) + } else { + activeNonPaletteColors.add(normalized) + } highlightedVerseCount += 1 } const unHighlightedCount = verses.length - highlightedVerseCount - const allColorsActive = activeColors.size === HIGHLIGHT_COLORS.length - // The whole palette is offered when part of the selection is bare, or when the - // selection already carries more than one color. In both cases "apply this - // everywhere" still means something for a color already present somewhere. - const showAllApplyColors = !allColorsActive && (unHighlightedCount > 0 || activeColors.size > 1) + const allPaletteColorsActive = activePaletteColors.size === HIGHLIGHT_COLORS.length + const showAllApplyColors = + !allPaletteColorsActive && + (unHighlightedCount > 0 || activePaletteColors.size > 1) const colorsToApply = showAllApplyColors ? HIGHLIGHT_COLORS - : HIGHLIGHT_COLORS.filter((color) => !activeColors.has(color)) + : HIGHLIGHT_COLORS.filter((color) => !activePaletteColors.has(color)) - return [ - ...HIGHLIGHT_COLORS.filter((color) => activeColors.has(color)).map( + const removeSwatches: VerseActionSwatch[] = [ + ...HIGHLIGHT_COLORS.filter((color) => activePaletteColors.has(color)).map( + (color): VerseActionSwatch => ({ color, state: 'remove' }), + ), + ...[...activeNonPaletteColors].sort().map( (color): VerseActionSwatch => ({ color, state: 'remove' }), ), + ] + + return [ + ...removeSwatches, ...colorsToApply.map((color): VerseActionSwatch => ({ color, state: 'apply' })), ] } From dc856d1edbda105c2458312769d4ff860a67ac23 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 16:02:44 +0000 Subject: [PATCH 22/43] docs(highlights): align YPE-4494 rules and drop unused projectPaintColors Update AGENTS.md and ADR 0017 to document palette-only apply, valid non-palette paint/remove, and invalid hex dropping. Remove the unused projectPaintColors helper; deriveServerColors already filters via isValidHighlightHex. Co-authored-by: Cameron Pak --- AGENTS.md | 2 +- docs/adr/0017-native-verse-action-sheet.md | 2 +- .../__tests__/paint-projection.test.ts | 21 +------------------ packages/core/src/highlights/index.ts | 2 +- .../core/src/highlights/paint-projection.ts | 17 --------------- 5 files changed, 4 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6df277c5..7f0a47fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,7 +209,7 @@ UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider - Paints from the MMKV cache **synchronously** in a `useState` initializer. That only works because `AuthProvider` seeds `userInfo` from its own initializer, so `userInfo.id` exists on the first render — load-bearing coupling, commented at both ends. - `highlights` is always safe to render. `isRefreshing` means "a GET is in flight", never "no data yet"; gating a spinner on it reintroduces the blank first frame the cache exists to prevent. - `error` is **fetch-only**. Writes report through the `HighlightWriteOutcome` they resolve to, and that outcome is point-in-time, not final: `queued` means the paint stands and the drain owes the server the write. Treat it as a success anywhere a write outcome is branched on — the highlight is on screen. Being point-in-time, it also **repeats**: every tap on a verse that is still parked resolves `queued` again, because the outcome reports that write and not the verse's queue state. It carries no first-park-vs-repeat field on purpose — a batch can mix a parked verse with fresh ones, so an honest one would be a per-verse split, and a verse parked yellow then tapped green is a new write rather than a repeat. Deduping a "saved offline" message is the caller's own state. Only an `error` un-paints, and the reason that matters downstream is `auth`: a refusal under a token that should have worked is the permission flow's corrective fallback for a stale grant. `not-signed-in` is not a refusal at all — it is raised locally, before any request goes out, when auth settles with no token or a different user; the flow's own **pre-flight** is what raises the sign-in prompt. The queue changes neither reason. -- The five swatches in `HIGHLIGHT_COLORS` are a company standard enforced in core: both `apply` and `remove` reject anything else as `invalid` before painting or issuing a request. Do not relax this on layering grounds — the open improvement is relocating the palette to `@youversion/platform-core`, not deferring it to the UI layer. +- The five swatches in `HIGHLIGHT_COLORS` are the company-standard **apply** palette in core (YPE-4494): `apply` rejects non-palette colors as `invalid` before painting or issuing a request. Valid non-palette hex from the API paints normally. `remove` clears by exact hex — palette or valid non-palette — and the verse action sheet shows a checkmarked remove circle for each (ANY rule kept). Invalid hex is dropped from paint and swatches. Do not relax this on layering grounds — the open improvement is relocating the palette to `@youversion/platform-core`, not deferring it to the UI layer. - Paint math lives in the pure, React-free `packages/core/src/highlights/optimistic.ts`, ported from the web highlights machine. `state.colors` is what the reader shows — server truth with unconfirmed edits already folded in, not a base plus an overlay. The colour-aware reconcile retirement rule is documented in [ADR 0013](docs/adr/0013-native-highlights-optimistic-layer.md); it reads like a bug in both directions and is defended only by its regression pair, so read the ADR before touching `shouldRetire`. That ADR's ownership tokens were retired by [ADR 0018](docs/adr/0018-highlight-write-queue.md) — the guarantee they gave is now a value comparison against the write queue. - Writes are persisted before they are sent. `packages/core/src/highlights/queue.ts` holds one entry per verse, `{ local, server }`, keyed per user + scope. A write that cannot reach the server, or that comes back 5xx, keeps its paint and resolves `{ status: 'queued' }` — those are the two failures that park. Only a server _refusal_ (401/403 or any other 4xx) reverts, using the entry's `server` side. **Cached Highlights are the paint, not raw server truth** — MMKV is written queue first, cache second, and the mount re-applies the queue over the cache to repair a crash between the two. See [ADR 0018](docs/adr/0018-highlight-write-queue.md). - Parked writes are sent by the **drain** (`highlights/drain.ts`), mounted at core's `YouVersionProvider` by `HighlightQueueDrainHost` inside `AuthProvider` — a parked write outlives the chapter that made it, and after a relaunch the queue is the only record of its scope. It wakes on mount, on a token change, on `AppState` returning to active, on the rising edge of `expo-network` connectivity, and on the two signals the write path raises directly (`drain-signals.ts`: a request reached the server, a write parked). Everything else is a per-verse in-memory backoff that widens on each consecutive failure and resets on success. Connectivity is a trigger, never a gate. diff --git a/docs/adr/0017-native-verse-action-sheet.md b/docs/adr/0017-native-verse-action-sheet.md index 7a422b24..d8550e20 100644 --- a/docs/adr/0017-native-verse-action-sheet.md +++ b/docs/adr/0017-native-verse-action-sheet.md @@ -108,7 +108,7 @@ One difference survives, in the **add** list. Swift and Kotlin gate it on NOT-AL A color covering some but not all of the selected verses appears **twice**: a checkmarked remove circle, and a plain apply circle that paints the whole selection in one tap. That is web's shipped behavior, verified against `verse-action-popover.tsx:270-284` at `ui-2.5.0`. -Colors outside the five swatches are ignored. That matches the projection the WebView paints from, because `deriveHighlightedVerses` drops them. Counting them would size the tray against paint the user cannot see. +Apply stays palette-only: the apply row offers only the five `HIGHLIGHT_COLORS` swatches. Remove follows the ANY rule for palette colors and also for valid non-palette hex at its exact value — each earns a checkmarked remove circle. Invalid hex is dropped from paint and from both swatch rows. YPE-4494 locked this seam: partner apps may share a highlights DB with the main Bible app, which can paint valid custom hex from the API; only apply is restricted to the palette. ### Copy and Share stop crossing the bridge diff --git a/packages/core/src/highlights/__tests__/paint-projection.test.ts b/packages/core/src/highlights/__tests__/paint-projection.test.ts index b8108478..8f5c9895 100644 --- a/packages/core/src/highlights/__tests__/paint-projection.test.ts +++ b/packages/core/src/highlights/__tests__/paint-projection.test.ts @@ -1,7 +1,4 @@ -import { HIGHLIGHT_COLORS, type ServerColors } from '../constants' -import { isValidHighlightHex, projectPaintColors } from '../paint-projection' - -const [YELLOW] = HIGHLIGHT_COLORS +import { isValidHighlightHex } from '../paint-projection' describe('isValidHighlightHex', () => { it('accepts lowercase and uppercase six-digit hex', () => { @@ -17,19 +14,3 @@ describe('isValidHighlightHex', () => { expect(isValidHighlightHex('')).toBe(false) }) }) - -describe('projectPaintColors', () => { - it('keeps valid non-palette hex and normalizes case', () => { - const colors: ServerColors = { 1: '123456', 2: 'AaBbCc' } - expect(projectPaintColors(colors)).toEqual({ 1: '123456', 2: 'aabbcc' }) - }) - - it('keeps palette hex', () => { - expect(projectPaintColors({ 16: YELLOW })).toEqual({ 16: YELLOW }) - }) - - it('drops invalid hex from paint', () => { - const colors: ServerColors = { 1: YELLOW, 2: 'gg0000', 3: '123456' } - expect(projectPaintColors(colors)).toEqual({ 1: YELLOW, 3: '123456' }) - }) -}) diff --git a/packages/core/src/highlights/index.ts b/packages/core/src/highlights/index.ts index 611bab37..2b4b8b80 100644 --- a/packages/core/src/highlights/index.ts +++ b/packages/core/src/highlights/index.ts @@ -25,7 +25,7 @@ export { } from './cache' export { HIGHLIGHT_COLORS, isHighlightColor, type HighlightColor } from './constants' -export { isValidHighlightHex, projectPaintColors } from './paint-projection' +export { isValidHighlightHex } from './paint-projection' // `clearHighlightQueue` is sign-out's, and stays internal. // `hasQueuedHighlightWrites` is public: the reader has to know whether signing diff --git a/packages/core/src/highlights/paint-projection.ts b/packages/core/src/highlights/paint-projection.ts index 1b38142f..42906d7f 100644 --- a/packages/core/src/highlights/paint-projection.ts +++ b/packages/core/src/highlights/paint-projection.ts @@ -1,5 +1,3 @@ -import type { ServerColors } from './constants' - /** Six-digit hex, no `#`. Case-insensitive at the boundary. */ const HIGHLIGHT_HEX_PATTERN = /^[0-9a-f]{6}$/i @@ -7,18 +5,3 @@ const HIGHLIGHT_HEX_PATTERN = /^[0-9a-f]{6}$/i export function isValidHighlightHex(color: string): boolean { return HIGHLIGHT_HEX_PATTERN.test(color) } - -/** - * Drops verses whose color is not a valid highlight hex. Normalizes survivors to - * lowercase. Palette membership is not checked — callers gate apply separately. - */ -export function projectPaintColors(colors: ServerColors): ServerColors { - const projected: ServerColors = {} - for (const [verseKey, color] of Object.entries(colors)) { - const normalized = color.toLowerCase() - if (isValidHighlightHex(normalized)) { - projected[Number(verseKey)] = normalized - } - } - return projected -} From c08f2cc6abc28e733260994ed0fb8db2db1ea2a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 16:03:25 +0000 Subject: [PATCH 23/43] fix(ui): count all active colors for showAllApplyColors (YPE-4494) Match web activeHighlights.size > 1: mixed palette + non-palette selections re-offer the full apply row so users can paint over custom hex. Add locked swatch seam test for the mixed case. Co-authored-by: Cameron Pak --- .../__tests__/verse-action-swatches.test.ts | 19 ++++++++++++++++--- packages/ui/src/lib/verse-action-swatches.ts | 13 ++++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts b/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts index 720f0b9d..861b9bd2 100644 --- a/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts +++ b/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts @@ -67,12 +67,25 @@ describe('buildVerseActionSwatches', () => { }) it('re-offers every color once more than one is present, even with nothing bare', () => { - // `activeHighlights.size > 1` is the other half of `showAllApplyColors`: + // `activeColors.size > 1` is the other half of `showAllApplyColors`: // with two colors in play, "make it all green" is a real action. const swatches = summarize({ verses: [1, 2], colors: { 1: YELLOW, 2: GREEN } }) expect(swatches.filter((s) => s.startsWith('apply:'))).toHaveLength(5) }) + it('re-offers the full apply row when palette and non-palette hex both appear', () => { + // Web counts all valid active colors, not palette-only — paint over custom hex. + expect(summarize({ verses: [1, 2], colors: { 1: '123456', 2: YELLOW } })).toEqual([ + `remove:${YELLOW}`, + 'remove:123456', + `apply:${YELLOW}`, + `apply:${GREEN}`, + `apply:${BLUE}`, + `apply:${ORANGE}`, + `apply:${PINK}`, + ]) + }) + it('shows only remove circles when all five colors are present', () => { expect( summarize({ @@ -103,9 +116,9 @@ describe('buildVerseActionSwatches', () => { }) it('shows a remove circle for valid non-palette hex and drops invalid hex', () => { - expect(summarize({ verses: [1, 2], colors: { 1: '123456', 2: YELLOW } })).toEqual([ - `remove:${YELLOW}`, + expect(summarize({ verses: [1], colors: { 1: '123456' } })).toEqual([ 'remove:123456', + `apply:${YELLOW}`, `apply:${GREEN}`, `apply:${BLUE}`, `apply:${ORANGE}`, diff --git a/packages/ui/src/lib/verse-action-swatches.ts b/packages/ui/src/lib/verse-action-swatches.ts index 68f48876..6125cc64 100644 --- a/packages/ui/src/lib/verse-action-swatches.ts +++ b/packages/ui/src/lib/verse-action-swatches.ts @@ -32,10 +32,11 @@ export type BuildVerseActionSwatchesInput = { * palette order. * * The apply row offers the whole palette when part of the selection is - * unhighlighted, or when the selection already carries more than one palette - * color. Otherwise it offers only the palette colors not already present. Apply - * is palette-only; remove includes valid non-palette hex at its exact value. - * Invalid hex is dropped from both rows. + * unhighlighted, or when the selection already carries more than one active + * color (palette or valid non-palette hex). Otherwise it offers only the + * palette colors not already present. Apply is palette-only; remove includes + * valid non-palette hex at its exact value. Invalid hex is dropped from both + * rows. */ export function buildVerseActionSwatches( input: BuildVerseActionSwatchesInput, @@ -44,6 +45,7 @@ export function buildVerseActionSwatches( const activePaletteColors = new Set() const activeNonPaletteColors = new Set() + const activeColors = new Set() let highlightedVerseCount = 0 for (const verse of verses) { const color = colors[verse] @@ -51,6 +53,7 @@ export function buildVerseActionSwatches( continue } const normalized = color.toLowerCase() + activeColors.add(normalized) if (isHighlightColor(normalized)) { activePaletteColors.add(normalized) } else { @@ -63,7 +66,7 @@ export function buildVerseActionSwatches( const allPaletteColorsActive = activePaletteColors.size === HIGHLIGHT_COLORS.length const showAllApplyColors = !allPaletteColorsActive && - (unHighlightedCount > 0 || activePaletteColors.size > 1) + (unHighlightedCount > 0 || activeColors.size > 1) const colorsToApply = showAllApplyColors ? HIGHLIGHT_COLORS : HIGHLIGHT_COLORS.filter((color) => !activePaletteColors.has(color)) From 734d41b2daced64ac693fd9ef04f18f6153f2455 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 16:03:48 +0000 Subject: [PATCH 24/43] refactor(ui): name activeHighlights set to match web showAllApplyColors gate Co-authored-by: Cameron Pak --- packages/ui/src/lib/__tests__/verse-action-swatches.test.ts | 2 +- packages/ui/src/lib/verse-action-swatches.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts b/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts index 861b9bd2..817ced8b 100644 --- a/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts +++ b/packages/ui/src/lib/__tests__/verse-action-swatches.test.ts @@ -67,7 +67,7 @@ describe('buildVerseActionSwatches', () => { }) it('re-offers every color once more than one is present, even with nothing bare', () => { - // `activeColors.size > 1` is the other half of `showAllApplyColors`: + // Web `activeHighlights.size > 1` — all distinct valid colors, not palette-only. // with two colors in play, "make it all green" is a real action. const swatches = summarize({ verses: [1, 2], colors: { 1: YELLOW, 2: GREEN } }) expect(swatches.filter((s) => s.startsWith('apply:'))).toHaveLength(5) diff --git a/packages/ui/src/lib/verse-action-swatches.ts b/packages/ui/src/lib/verse-action-swatches.ts index 6125cc64..fef09c9d 100644 --- a/packages/ui/src/lib/verse-action-swatches.ts +++ b/packages/ui/src/lib/verse-action-swatches.ts @@ -45,7 +45,7 @@ export function buildVerseActionSwatches( const activePaletteColors = new Set() const activeNonPaletteColors = new Set() - const activeColors = new Set() + const activeHighlights = new Set() let highlightedVerseCount = 0 for (const verse of verses) { const color = colors[verse] @@ -53,7 +53,7 @@ export function buildVerseActionSwatches( continue } const normalized = color.toLowerCase() - activeColors.add(normalized) + activeHighlights.add(normalized) if (isHighlightColor(normalized)) { activePaletteColors.add(normalized) } else { @@ -66,7 +66,7 @@ export function buildVerseActionSwatches( const allPaletteColorsActive = activePaletteColors.size === HIGHLIGHT_COLORS.length const showAllApplyColors = !allPaletteColorsActive && - (unHighlightedCount > 0 || activeColors.size > 1) + (unHighlightedCount > 0 || activeHighlights.size > 1) const colorsToApply = showAllApplyColors ? HIGHLIGHT_COLORS : HIGHLIGHT_COLORS.filter((color) => !activePaletteColors.has(color)) From aa8ab5a4bef931dec382650e1ba6e9de4f31d922 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 16:17:39 +0000 Subject: [PATCH 25/43] docs(agents): list isValidHighlightHex in Core Exports (YPE-4494) Co-authored-by: Cameron Pak --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 7f0a47fc..4a41b8b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,7 +170,7 @@ Keep `apps/example/metro.config.js` minimal — just `getDefaultConfig(__dirname **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` -**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`, `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` / `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`) UI `YouVersionProvider` wraps core and adds theme context + `NativeSheetProvider`. Import Bible components from UI; import `useYVAuth` from core. From 406fff3ecb7f18167b594badc8e99ce7aaa818ed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 18:40:36 +0000 Subject: [PATCH 26/43] docs(highlights): align AGENTS and ADR 0017 with YPE-4494 review Co-authored-by: Cameron Pak --- AGENTS.md | 2 +- docs/adr/0017-native-verse-action-sheet.md | 12 +++++++++++- packages/ui/src/lib/verse-action-swatches.ts | 3 +++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4a41b8b3..f2e87f12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,7 +110,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), ported verbatim from the Web SDK popover. It is an **ANY** rule, and Swift and Kotlin agree on the remove list. A partially-covering color appearing in both rows is intended. +- 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. - `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. diff --git a/docs/adr/0017-native-verse-action-sheet.md b/docs/adr/0017-native-verse-action-sheet.md index d8550e20..a3f4e8c6 100644 --- a/docs/adr/0017-native-verse-action-sheet.md +++ b/docs/adr/0017-native-verse-action-sheet.md @@ -104,7 +104,7 @@ The shipped YouVersion Bible app does more. It has a collapsed tray with a fanne Research settled the question ADR 0015 left open on the reference branch. That ADR said iOS was "believed" to use an ALL rule. It does not. Both public native SDKs filter their remove list with an "is this color on any selected verse" predicate. Kotlin does it at `BibleReaderViewModel.kt:504-520`, Swift at `BibleReaderViewModel+Navigation.swift:147-160`. Web, Swift, and Kotlin agree on the remove list, so this port preserves parity rather than creating a divergence. -One difference survives, in the **add** list. Swift and Kotlin gate it on NOT-ALL. Web, and this port, use `!allColorsActive && (unHighlightedCount > 0 || activeColors.size > 1)`. The two disagree only when all five palette colors are active in one selection. Web then shows five remove circles and an empty apply row. The native SDKs would also show five apply circles. Cam decided on 2026-08-05 to ship the web rule. That edge stays with the separately tracked ANY-vs-ALL question. +One difference survives, in the **add** list. Swift and Kotlin gate it on NOT-ALL. Web, and this port, use `!allPaletteColorsActive && (unHighlightedCount > 0 || activeHighlights.size > 1)` (see the 2026-08-12 amendment for YPE-4494's palette-only apply row). The two disagree only when all five palette colors are active in one selection. Web then shows five remove circles and an empty apply row. The native SDKs would also show five apply circles. Cam decided on 2026-08-05 to ship the web rule. That edge stays with the separately tracked ANY-vs-ALL question. A color covering some but not all of the selected verses appears **twice**: a checkmarked remove circle, and a plain apply circle that paints the whole selection in one tap. That is web's shipped behavior, verified against `verse-action-popover.tsx:270-284` at `ui-2.5.0`. @@ -148,6 +148,16 @@ The action sheet's `isOpen` is `selection !== null && prompt === 'none' && !flow - `expo-clipboard` is a new **peer dependency**. Consumers who take this version must install it and rebuild their dev client. `expo-application` is also now a UI peer, because the sign-in sheet reads the app's display name. Core already depended on it, so no new autolinked module reaches an app that already had core. - The sheet is not exported, so its layout is not public API and can change without a breaking release. +## Amendment (2026-08-12): non-palette paint/clear (YPE-4494) + +YPE-4494 tightened the swatch seam beyond the original 2026-08-05 port: + +- **Remove row:** ANY rule unchanged — every distinct color on any selected verse earns a checkmarked remove circle. Palette colors and valid non-palette hex at their exact value qualify; invalid hex is dropped from paint and from both rows. +- **Apply row:** palette-only. The five `HIGHLIGHT_COLORS` swatches are the only apply targets; non-palette hex never appears as an apply circle. +- **`showAllApplyColors`:** the apply row shows the full palette when `!allPaletteColorsActive && (unHighlightedCount > 0 || activeHighlights.size > 1)`. The first half (`allPaletteColorsActive`) counts only palette colors; the second half (`activeHighlights.size > 1`) counts all valid colors, palette or non-palette. That dual-half rule matches the web SDK YPE-4494 tray (`buildVerseActionSwatches` in platform-sdk-react PR #330), not the published 2.5.0 popover formula alone. + +Core's `apply` rejects non-palette colors as `invalid` before painting or issuing a request. Valid non-palette hex from the API still paints and clears through the remove row. The WebView reader paint path depends on a future `@youversion/platform-react-ui` pin after web #330 publishes; that pin is tracked separately from this native tray work. + ## Verification status The manual passes and the automated ones cover different things. The gap matters when this area is next touched. diff --git a/packages/ui/src/lib/verse-action-swatches.ts b/packages/ui/src/lib/verse-action-swatches.ts index fef09c9d..126274cf 100644 --- a/packages/ui/src/lib/verse-action-swatches.ts +++ b/packages/ui/src/lib/verse-action-swatches.ts @@ -64,6 +64,9 @@ export function buildVerseActionSwatches( const unHighlightedCount = verses.length - highlightedVerseCount const allPaletteColorsActive = activePaletteColors.size === HIGHLIGHT_COLORS.length + // Dual-half rule (YPE-4494 / web highlight-colors Story 14): palette-only for + // "all active"; activeHighlights.size > 1 counts all valid colors (palette or + // non-palette). Matches web buildVerseActionSwatches (platform-sdk-react #330). const showAllApplyColors = !allPaletteColorsActive && (unHighlightedCount > 0 || activeHighlights.size > 1) From ecb080d83708733c9504b98a4ad0b8fed50d6889 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 20:23:19 +0000 Subject: [PATCH 27/43] fix(ui): pin platform-react-ui@2.6.2 for non-palette paint (YPE-4494) Co-authored-by: Cameron Pak --- docs/adr/0017-native-verse-action-sheet.md | 2 +- packages/ui/package.json | 2 +- .../bible-reader-highlights-bridge.test.tsx | 16 ++++++++ pnpm-lock.yaml | 38 ++++++++++++------- 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/docs/adr/0017-native-verse-action-sheet.md b/docs/adr/0017-native-verse-action-sheet.md index a3f4e8c6..97ff37de 100644 --- a/docs/adr/0017-native-verse-action-sheet.md +++ b/docs/adr/0017-native-verse-action-sheet.md @@ -156,7 +156,7 @@ YPE-4494 tightened the swatch seam beyond the original 2026-08-05 port: - **Apply row:** palette-only. The five `HIGHLIGHT_COLORS` swatches are the only apply targets; non-palette hex never appears as an apply circle. - **`showAllApplyColors`:** the apply row shows the full palette when `!allPaletteColorsActive && (unHighlightedCount > 0 || activeHighlights.size > 1)`. The first half (`allPaletteColorsActive`) counts only palette colors; the second half (`activeHighlights.size > 1`) counts all valid colors, palette or non-palette. That dual-half rule matches the web SDK YPE-4494 tray (`buildVerseActionSwatches` in platform-sdk-react PR #330), not the published 2.5.0 popover formula alone. -Core's `apply` rejects non-palette colors as `invalid` before painting or issuing a request. Valid non-palette hex from the API still paints and clears through the remove row. The WebView reader paint path depends on a future `@youversion/platform-react-ui` pin after web #330 publishes; that pin is tracked separately from this native tray work. +Core's `apply` rejects non-palette colors as `invalid` before painting or issuing a request. Valid non-palette hex from the API still paints and clears through the remove row. The WebView reader paint path pins `@youversion/platform-react-ui@2.6.2` (web YPE-4494 / platform-sdk-react PR #330). ## Verification status diff --git a/packages/ui/package.json b/packages/ui/package.json index d034aa1b..86bc9f97 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -56,7 +56,7 @@ "@radix-ui/react-use-controllable-state": "1.2.2", "@rn-primitives/portal": "1.4.0", "@youversion/platform-react-native-expo-core": "workspace:*", - "@youversion/platform-react-ui": "2.5.0", + "@youversion/platform-react-ui": "2.6.2", "expo-localization": "56.0.6", "i18next": "26.3.1", "react-i18next": "17.0.8", diff --git a/packages/ui/src/native/__tests__/bible-reader-highlights-bridge.test.tsx b/packages/ui/src/native/__tests__/bible-reader-highlights-bridge.test.tsx index d0ebc680..fbccebc2 100644 --- a/packages/ui/src/native/__tests__/bible-reader-highlights-bridge.test.tsx +++ b/packages/ui/src/native/__tests__/bible-reader-highlights-bridge.test.tsx @@ -216,6 +216,22 @@ describe('the controlled-mode latch', () => { expect(lastDomProps().highlights).toEqual([]) }) + it('forwards valid non-palette hex in highlights verbatim to the DOM reader', () => { + const NON_PALETTE = 'aabbcc' + const data = [ + { version_id: 111, passage_id: 'JHN.3.16', color: NON_PALETTE }, + highlight('JHN.3.17'), + ] + stubHighlights(data) + + render(, { wrapper }) + + expect(lastDomProps().highlights).toEqual(data) + expect(lastDomProps().highlights).toEqual( + expect.arrayContaining([expect.objectContaining({ color: NON_PALETTE })]), + ) + }) + it('forwards the hook’s highlights verbatim, with no adapter in between', () => { const data = [highlight('JHN.3.16'), highlight('JHN.3.17-18')] stubHighlights(data) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0325d27a..8198a35a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,8 +206,8 @@ importers: specifier: workspace:* version: link:../core '@youversion/platform-react-ui': - specifier: 2.5.0 - version: 2.5.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + specifier: 2.6.2 + version: 2.6.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) expo: specifier: '>=56.0.0 <57.0.0' version: 56.0.12(@babel/core@7.29.0)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.5(react@19.2.5))(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3) @@ -3008,13 +3008,21 @@ packages: linkedom: optional: true - '@youversion/platform-react-hooks@2.5.0': - resolution: {integrity: sha512-wy/q31uQBHwJLdOYYsLCGBZHEGnWOfZOlXGYRP/Ln+RdfUnm1AcY1d35i+XGuxmnuzb9hFCocyU+21sQpGZstQ==} + '@youversion/platform-core@2.6.2': + resolution: {integrity: sha512-es6t2loTEODsaCbY6NA+gE7YrrA49anTh0MtyiurbWzTHALn+ultnT3Ok0FrjJ7fq87d90eDWgINHnUlym1dmA==} + peerDependencies: + jsdom: ^24.0.0 || ^28.0.0 + peerDependenciesMeta: + jsdom: + optional: true + + '@youversion/platform-react-hooks@2.6.2': + resolution: {integrity: sha512-4xmQDo8jaYkBj8+cEhc2OP8XBhjGdBZTE/MVGc911Wq49n42zPSNfWa187gMpoKtRka1PrcL+8zg/BcPIBjL+g==} peerDependencies: react: '>=19.1.0 <20.0.0' - '@youversion/platform-react-ui@2.5.0': - resolution: {integrity: sha512-KrT92Vs4C8X6lIAjmLj2XyjFdyaQ0jX3DLoYAF5NXCYUW1NX+xo/dHn9dXceyMPuaIiBX1c0tBPHUe/ySUrlPQ==} + '@youversion/platform-react-ui@2.6.2': + resolution: {integrity: sha512-/tk+kUV71g8r4wq8C/sr1kxJ5NYivr4jn9PCTDJqyt5teW4tQAUKpdZylAcPYYd83oYaqUHm+Ek1RvHjhq1IUQ==} peerDependencies: react: '>=19.1.0 <20.0.0' react-dom: '>=19.1.0 <20.0.0' @@ -10701,14 +10709,18 @@ snapshots: dependencies: zod: 4.1.12 - '@youversion/platform-react-hooks@2.5.0(react@19.2.5)': + '@youversion/platform-core@2.6.2': + dependencies: + zod: 4.1.12 + + '@youversion/platform-react-hooks@2.6.2(react@19.2.5)': dependencies: - '@youversion/platform-core': 2.5.0 + '@youversion/platform-core': 2.6.2 react: 19.2.5 transitivePeerDependencies: - - linkedom + - jsdom - '@youversion/platform-react-ui@2.5.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3)': + '@youversion/platform-react-ui@2.6.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/jest-preset@0.85.3(@babel/core@7.29.0)(react@19.2.5))(@react-native/metro-config@0.85.3(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)(typescript@6.0.3)': dependencies: '@radix-ui/react-accordion': 1.2.12(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@radix-ui/react-dialog': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -10718,8 +10730,8 @@ snapshots: '@radix-ui/react-tabs': 1.1.13(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) '@xstate/react': 6.1.0(@types/react@19.2.14)(react@19.2.5)(xstate@5.32.4) - '@youversion/platform-core': 2.5.0 - '@youversion/platform-react-hooks': 2.5.0(react@19.2.5) + '@youversion/platform-core': 2.6.2 + '@youversion/platform-react-hooks': 2.6.2(react@19.2.5) better-result: 2.9.2 class-variance-authority: 0.7.1 clsx: 2.1.1 @@ -10734,7 +10746,7 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@types/react-dom' - - linkedom + - jsdom - react-native - typescript From 44cd72969594c426731144de2e6f0716cde2bce8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 21:38:34 +0000 Subject: [PATCH 28/43] test(core): align drain-host test with sync getOrSetInstallationId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After rebasing onto main (YPE-4875), getOrSetInstallationId is synchronous — drop async/waitFor and mockResolvedValue from the highlight queue drain test. Co-authored-by: Cameron Pak --- packages/core/src/__tests__/youversion-provider.test.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/core/src/__tests__/youversion-provider.test.tsx b/packages/core/src/__tests__/youversion-provider.test.tsx index 8d968984..9ba50bec 100644 --- a/packages/core/src/__tests__/youversion-provider.test.tsx +++ b/packages/core/src/__tests__/youversion-provider.test.tsx @@ -81,16 +81,14 @@ describe('YouVersionProvider', () => { expect(MockDrainHost).not.toHaveBeenCalled() }) - it('mounts the highlight queue drain alongside AuthProvider', async () => { - mockGetOrSetInstallationId.mockResolvedValue('inst-1') - + it('mounts the highlight queue drain alongside AuthProvider', () => { render( Content , ) - await waitFor(() => expect(screen.getByTestId('content')).toBeTruthy()) + expect(screen.getByTestId('content')).toBeTruthy() expect(MockDrainHost).toHaveBeenCalled() }) }) From 866e713df72760400dcf94e5f317cd1fb338d3d0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 22:49:12 +0000 Subject: [PATCH 29/43] feat(ui): refreshHighlights, onHighlightError, sign-out guard on highlights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port three consumer-facing UI deltas onto the highlights branch: - BibleReaderHandle.refreshHighlights via React 19 ref + useImperativeHandle - onHighlightError for queued and transient write outcomes only - useSignOutGuard shared by BibleReader toolbar and YouVersionAuthButton Sign-out guard preserves highlights-tip behavior: always Alert (normal or pending copy), confirm calls signOut() only — no discard-first path. Tests cover the hook, auth button parity, ref refresh, and error filtering. Co-authored-by: Cameron Pak --- packages/ui/src/index.ts | 4 + .../src/lib/report-highlight-write-error.ts | 33 ++ .../bible-reader-consumer-api.test.tsx | 335 ++++++++++++++++++ .../__tests__/use-sign-out-guard.test.tsx | 137 +++++++ .../__tests__/youversion-auth-button.test.tsx | 88 ++++- packages/ui/src/native/bible-reader.tsx | 91 +++-- packages/ui/src/native/index.ts | 4 + packages/ui/src/native/use-sign-out-guard.ts | 58 +++ .../ui/src/native/youversion-auth-button.tsx | 15 +- 9 files changed, 715 insertions(+), 50 deletions(-) create mode 100644 packages/ui/src/lib/report-highlight-write-error.ts create mode 100644 packages/ui/src/native/__tests__/bible-reader-consumer-api.test.tsx create mode 100644 packages/ui/src/native/__tests__/use-sign-out-guard.test.tsx create mode 100644 packages/ui/src/native/use-sign-out-guard.ts diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index f0033250..a7559ef1 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/report-highlight-write-error.ts b/packages/ui/src/lib/report-highlight-write-error.ts new file mode 100644 index 00000000..8fe42cd4 --- /dev/null +++ b/packages/ui/src/lib/report-highlight-write-error.ts @@ -0,0 +1,33 @@ +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' | 'error' + reason?: 'transient' + verses: number[] + message?: string +} + +export function reportHighlightWriteError( + outcome: HighlightWriteOutcome, + onHighlightError?: (error: HighlightWriteError) => void, +): void { + if (onHighlightError === undefined) { + return + } + if (outcome.status === 'queued') { + onHighlightError({ status: 'queued', verses: outcome.verses }) + return + } + if (outcome.status === 'error' && outcome.reason === 'transient') { + 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 00000000..300926d0 --- /dev/null +++ b/packages/ui/src/native/__tests__/bible-reader-consumer-api.test.tsx @@ -0,0 +1,335 @@ +/** + * 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 refreshHighlights handle', () => { + it('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', () => { + const reader = createRef() + + render(, { wrapper }) + + expect(Object.keys(reader.current ?? {})).toEqual(['refreshHighlights']) + }) +}) + +describe('BibleReader onHighlightError', () => { + it('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('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', + }) + }) + + 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', async (_label, outcome) => { + highlightPermissionFlowApply.mockResolvedValueOnce(outcome) + const onHighlightError = jest.fn() + + const { getByTestId } = render( + , + { wrapper }, + ) + + await selectVerses(getByTestId) + await act(async () => { + fireEvent.press(getByTestId('trigger-apply-swatch')) + }) + + expect(onHighlightError).not.toHaveBeenCalled() + }) +}) 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 00000000..480f6a12 --- /dev/null +++ b/packages/ui/src/native/__tests__/use-sign-out-guard.test.tsx @@ -0,0 +1,137 @@ +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 } 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 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('shows the normal sign-out alert when nothing is queued', async () => { + const { result } = renderHook( + () => useSignOutGuard({ signOut, userInfo: { id: USER_ID } }), + { 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({ signOut, userInfo: { id: USER_ID } }), + { 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({ signOut, userInfo: { id: USER_ID } }), + { 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({ signOut, userInfo: { id: USER_ID } }), + { 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('asks the queue about the signed-in user id', async () => { + const hasQueued = jest.spyOn(core, 'hasQueuedHighlightWrites').mockReturnValue(false) + const { result } = renderHook( + () => useSignOutGuard({ signOut, userInfo: { id: USER_ID } }), + { wrapper }, + ) + + await act(async () => { + await result.current?.() + }) + + expect(hasQueued).toHaveBeenCalledWith(USER_ID) + }) +}) 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 2e22e300..f8f68686 100644 --- a/packages/ui/src/native/__tests__/youversion-auth-button.test.tsx +++ b/packages/ui/src/native/__tests__/youversion-auth-button.test.tsx @@ -1,6 +1,9 @@ import type { ComponentProps, ReactNode } from 'react' import { render, screen, userEvent } from '@testing-library/react-native' +import * as core from '@youversion/platform-react-native-expo-core' +import { Alert } from 'react-native' +import en from '../../i18n/locales/en.json' import { YouVersionAuthButton } from '../youversion-auth-button' import { YouVersionProvider } from '../youversion-provider' @@ -8,16 +11,6 @@ const mockSignIn = jest.fn() const mockSignOut = jest.fn() 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() @@ -120,17 +150,46 @@ describe('YouVersionAuthButton press behavior', () => { expect(mockSignIn).toHaveBeenCalledTimes(1) expect(mockSignOut).not.toHaveBeenCalled() + expect(Alert.alert).not.toHaveBeenCalled() }) - it('calls signOut when pressed authenticated (mode=auto)', async () => { + 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('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 () => { @@ -160,8 +219,8 @@ describe('YouVersionAuthButton press behavior', () => { await user.press(screen.getByText(/sign out of/i)) - expect(mockSignOut).toHaveBeenCalledTimes(1) - expect(mockSignIn).not.toHaveBeenCalled() + expect(Alert.alert).toHaveBeenCalledTimes(1) + expect(mockSignOut).not.toHaveBeenCalled() }) it('calls signOut when mode="signOut" and authenticated', async () => { @@ -170,6 +229,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 12ac8819..fece96c5 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,9 +197,11 @@ export function BibleReader({ clearSelectionSignal = 0, onCopy: consumerOnCopy, onShare: consumerOnShare, + onHighlightError, backgroundColor, foregroundColor, dom, + ref, }: BibleReaderProps) { const context = useYouVersion() const auth = useYVAuthOptional() @@ -171,8 +209,8 @@ export function BibleReader({ 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 +263,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 +350,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 +368,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 +378,7 @@ export function BibleReader({ removeHighlight, applyHighlight, needsSignIn, + onHighlightError, versionId, book, chapter, @@ -350,8 +396,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 +515,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, { @@ -541,7 +566,7 @@ export function BibleReader({ 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={Platform.OS === 'web' || !signOut ? signOut : guardedSignOut} userInfo={userInfo} theme={resolvedTheme} book={book} diff --git a/packages/ui/src/native/index.ts b/packages/ui/src/native/index.ts index 30283b3b..0e873495 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 00000000..a68f783b --- /dev/null +++ b/packages/ui/src/native/use-sign-out-guard.ts @@ -0,0 +1,58 @@ +import { hasQueuedHighlightWrites } from '@youversion/platform-react-native-expo-core' +import { useCallback } from 'react' +import { Alert } 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 + 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. + * + * Returns `undefined` when there is nothing to sign out of, 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 userId = auth?.userInfo?.id ?? null + + const guardedSignOut = useCallback(async () => { + if (signOut === undefined) { + 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() + }, + }, + ], + ) + }, [signOut, userId, t]) + + return signOut === undefined ? undefined : guardedSignOut +} diff --git a/packages/ui/src/native/youversion-auth-button.tsx b/packages/ui/src/native/youversion-auth-button.tsx index 2e1639a1..c9c3a847 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) From cd3aa25639e8790c99692102f8c0ca9aa5213b9b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 22:55:37 +0000 Subject: [PATCH 30/43] fix(ui): bypass sign-out Alert on web for auth button Co-authored-by: Cameron Pak --- .changeset/ype-104-ui-consumer-api.md | 16 ++++ .../report-highlight-write-error.test.ts | 82 +++++++++++++++++++ .../bible-reader-consumer-api.test.tsx | 67 ++------------- .../__tests__/youversion-auth-button.test.tsx | 28 ++++++- .../ui/src/native/youversion-auth-button.tsx | 10 ++- 5 files changed, 136 insertions(+), 67 deletions(-) create mode 100644 .changeset/ype-104-ui-consumer-api.md create mode 100644 packages/ui/src/lib/__tests__/report-highlight-write-error.test.ts diff --git a/.changeset/ype-104-ui-consumer-api.md b/.changeset/ype-104-ui-consumer-api.md new file mode 100644 index 00000000..232839ce --- /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/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 00000000..719dd626 --- /dev/null +++ b/packages/ui/src/lib/__tests__/report-highlight-write-error.test.ts @@ -0,0 +1,82 @@ +import type { HighlightWriteOutcome } from '@youversion/platform-react-native-expo-core' + +import { reportHighlightWriteError } from '../report-highlight-write-error' + +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.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() + }) +}) 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 index 300926d0..abda88ed 100644 --- a/packages/ui/src/native/__tests__/bible-reader-consumer-api.test.tsx +++ b/packages/ui/src/native/__tests__/bible-reader-consumer-api.test.tsx @@ -197,8 +197,8 @@ async function selectVerses(getByTestId: (id: string) => Parameters { - it('calls through to highlights.refresh', async () => { +describe('BibleReader consumer API', () => { + it('refreshHighlights calls through to highlights.refresh', async () => { const reader = createRef() render(, { wrapper }) @@ -213,17 +213,15 @@ describe('BibleReader refreshHighlights handle', () => { expect(refreshHighlights).toHaveBeenCalledTimes(1) }) - it('exposes nothing beyond refreshHighlights', () => { + it('exposes nothing beyond refreshHighlights on the ref handle', () => { const reader = createRef() render(, { wrapper }) expect(Object.keys(reader.current ?? {})).toEqual(['refreshHighlights']) }) -}) -describe('BibleReader onHighlightError', () => { - it('fires for queued apply outcomes', async () => { + it('onHighlightError fires for queued apply outcomes', async () => { highlightPermissionFlowApply.mockResolvedValueOnce({ status: 'queued', verses: [1, 2] }) const onHighlightError = jest.fn() @@ -245,7 +243,7 @@ describe('BibleReader onHighlightError', () => { expect(onHighlightError).toHaveBeenCalledWith({ status: 'queued', verses: [1, 2] }) }) - it('fires for transient error outcomes on remove', async () => { + it('onHighlightError fires for transient error outcomes on remove', async () => { rawRemove.mockResolvedValueOnce({ status: 'error', reason: 'transient', @@ -277,59 +275,4 @@ describe('BibleReader onHighlightError', () => { message: 'Network request failed', }) }) - - 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', async (_label, outcome) => { - highlightPermissionFlowApply.mockResolvedValueOnce(outcome) - const onHighlightError = jest.fn() - - const { getByTestId } = render( - , - { wrapper }, - ) - - await selectVerses(getByTestId) - await act(async () => { - fireEvent.press(getByTestId('trigger-apply-swatch')) - }) - - expect(onHighlightError).not.toHaveBeenCalled() - }) }) 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 f8f68686..01935c7b 100644 --- a/packages/ui/src/native/__tests__/youversion-auth-button.test.tsx +++ b/packages/ui/src/native/__tests__/youversion-auth-button.test.tsx @@ -1,7 +1,7 @@ import type { ComponentProps, ReactNode } from 'react' import { render, screen, userEvent } from '@testing-library/react-native' import * as core from '@youversion/platform-react-native-expo-core' -import { Alert } from 'react-native' +import { Alert, Platform } from 'react-native' import en from '../../i18n/locales/en.json' import { YouVersionAuthButton } from '../youversion-auth-button' @@ -142,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() @@ -223,6 +233,22 @@ describe('YouVersionAuthButton press behavior', () => { expect(mockSignOut).not.toHaveBeenCalled() }) + 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) + }) + it('calls signOut when mode="signOut" and authenticated', async () => { mockIsAuthenticated = true const user = userEvent.setup() diff --git a/packages/ui/src/native/youversion-auth-button.tsx b/packages/ui/src/native/youversion-auth-button.tsx index c9c3a847..d5228ebb 100644 --- a/packages/ui/src/native/youversion-auth-button.tsx +++ b/packages/ui/src/native/youversion-auth-button.tsx @@ -1,5 +1,5 @@ import { useYVAuth } from '@youversion/platform-react-native-expo-core' -import { Pressable, StyleSheet, Text } from 'react-native' +import { Platform, Pressable, StyleSheet, Text } from 'react-native' import { Trans } from 'react-i18next' import { useSdkTranslation } from '../i18n/use-sdk-translation' import { BibleAppLogo } from './bible-app-logo' @@ -24,22 +24,24 @@ export function YouVersionAuthButton({ text, }: YouVersionAuthButtonProps) { const auth = useYVAuth() - const { isAuthenticated, signIn } = auth + const { isAuthenticated, signIn, signOut } = auth const guardedSignOut = useSignOutGuard(auth) + // `Alert.alert` is a no-op on react-native-web, so web signs out unprompted. + const signOutAction = Platform.OS === 'web' || !signOut ? signOut : guardedSignOut const { t, i18n } = useSdkTranslation() const authFunction = async () => { try { if (mode === 'auto') { if (isAuthenticated) { - await guardedSignOut?.() + await signOutAction?.() } else { await signIn() } } else if (mode === 'signIn') { await signIn() } else { - await guardedSignOut?.() + await signOutAction?.() } } catch (error) { console.error(error) From 23b384761686464c53df7d8a4d31233ce7919ff6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 23:01:28 +0000 Subject: [PATCH 31/43] chore(ui): drop unused ReactNode import in auth button test Co-authored-by: Cameron Pak --- .../ui/src/native/__tests__/youversion-auth-button.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 01935c7b..ed5adced 100644 --- a/packages/ui/src/native/__tests__/youversion-auth-button.test.tsx +++ b/packages/ui/src/native/__tests__/youversion-auth-button.test.tsx @@ -1,4 +1,4 @@ -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' From 74c1d1a7d11192d9ec5919523f5f3c890ff4411e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 15:08:24 +0000 Subject: [PATCH 32/43] fix(ui): web sign-out in hook and isolate onHighlightError throws Co-authored-by: Cameron Pak --- .../report-highlight-write-error.test.ts | 15 +++++++++ .../src/lib/report-highlight-write-error.ts | 15 +++++++-- .../__tests__/use-sign-out-guard.test.tsx | 33 ++++++++++++++++++- packages/ui/src/native/bible-reader.tsx | 4 +-- packages/ui/src/native/use-sign-out-guard.ts | 9 ++++- .../ui/src/native/youversion-auth-button.tsx | 10 +++--- 6 files changed, 73 insertions(+), 13 deletions(-) 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 index 719dd626..5cefd46d 100644 --- a/packages/ui/src/lib/__tests__/report-highlight-write-error.test.ts +++ b/packages/ui/src/lib/__tests__/report-highlight-write-error.test.ts @@ -39,6 +39,21 @@ describe('reportHighlightWriteError', () => { ).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.each([ ['ok', { status: 'ok', verses: [1, 2] } satisfies HighlightWriteOutcome], ['noop', { status: 'noop' } satisfies HighlightWriteOutcome], diff --git a/packages/ui/src/lib/report-highlight-write-error.ts b/packages/ui/src/lib/report-highlight-write-error.ts index 8fe42cd4..06a130ba 100644 --- a/packages/ui/src/lib/report-highlight-write-error.ts +++ b/packages/ui/src/lib/report-highlight-write-error.ts @@ -11,6 +11,17 @@ export type HighlightWriteError = { message?: string } +function invokeHighlightErrorHandler( + onHighlightError: (error: HighlightWriteError) => void, + error: HighlightWriteError, +): void { + try { + onHighlightError(error) + } catch (err) { + console.error('onHighlightError failed:', err) + } +} + export function reportHighlightWriteError( outcome: HighlightWriteOutcome, onHighlightError?: (error: HighlightWriteError) => void, @@ -19,11 +30,11 @@ export function reportHighlightWriteError( return } if (outcome.status === 'queued') { - onHighlightError({ status: 'queued', verses: outcome.verses }) + invokeHighlightErrorHandler(onHighlightError, { status: 'queued', verses: outcome.verses }) return } if (outcome.status === 'error' && outcome.reason === 'transient') { - onHighlightError({ + invokeHighlightErrorHandler(onHighlightError, { status: 'error', reason: 'transient', verses: outcome.failedVerses, 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 index 480f6a12..ef6fce1d 100644 --- a/packages/ui/src/native/__tests__/use-sign-out-guard.test.tsx +++ b/packages/ui/src/native/__tests__/use-sign-out-guard.test.tsx @@ -1,7 +1,7 @@ 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 } from 'react-native' +import { Alert, Platform } from 'react-native' import en from '../../i18n/locales/en.json' import { useSignOutGuard } from '../use-sign-out-guard' @@ -134,4 +134,35 @@ describe('useSignOutGuard', () => { 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({ signOut, userInfo: { id: USER_ID } }), + { wrapper }, + ) + + await act(async () => { + await result.current?.() + }) + + expect(Alert.alert).not.toHaveBeenCalled() + expect(signOut).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/packages/ui/src/native/bible-reader.tsx b/packages/ui/src/native/bible-reader.tsx index fece96c5..e70dff1d 100644 --- a/packages/ui/src/native/bible-reader.tsx +++ b/packages/ui/src/native/bible-reader.tsx @@ -208,7 +208,6 @@ export function BibleReader({ 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) @@ -565,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 : guardedSignOut} + onSignOutPress={guardedSignOut} userInfo={userInfo} theme={resolvedTheme} book={book} diff --git a/packages/ui/src/native/use-sign-out-guard.ts b/packages/ui/src/native/use-sign-out-guard.ts index a68f783b..a756dfe4 100644 --- a/packages/ui/src/native/use-sign-out-guard.ts +++ b/packages/ui/src/native/use-sign-out-guard.ts @@ -1,6 +1,6 @@ import { hasQueuedHighlightWrites } from '@youversion/platform-react-native-expo-core' import { useCallback } from 'react' -import { Alert } from 'react-native' +import { Alert, Platform } from 'react-native' import { useSdkTranslation } from '../i18n/use-sdk-translation' @@ -23,6 +23,8 @@ export type SignOutGuardAuth = { * 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. + * * Returns `undefined` when there is nothing to sign out of, so callers can pass * the result straight through to an optional handler prop. */ @@ -36,6 +38,11 @@ export function useSignOutGuard(auth: SignOutGuardAuth): (() => Promise) | return } + if (Platform.OS === 'web') { + await signOut() + return + } + const hasUnsentHighlights = hasQueuedHighlightWrites(userId) Alert.alert( diff --git a/packages/ui/src/native/youversion-auth-button.tsx b/packages/ui/src/native/youversion-auth-button.tsx index d5228ebb..c9c3a847 100644 --- a/packages/ui/src/native/youversion-auth-button.tsx +++ b/packages/ui/src/native/youversion-auth-button.tsx @@ -1,5 +1,5 @@ import { useYVAuth } from '@youversion/platform-react-native-expo-core' -import { Platform, Pressable, StyleSheet, Text } from 'react-native' +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' @@ -24,24 +24,22 @@ export function YouVersionAuthButton({ text, }: YouVersionAuthButtonProps) { const auth = useYVAuth() - const { isAuthenticated, signIn, signOut } = auth + const { isAuthenticated, signIn } = auth const guardedSignOut = useSignOutGuard(auth) - // `Alert.alert` is a no-op on react-native-web, so web signs out unprompted. - const signOutAction = Platform.OS === 'web' || !signOut ? signOut : guardedSignOut const { t, i18n } = useSdkTranslation() const authFunction = async () => { try { if (mode === 'auto') { if (isAuthenticated) { - await signOutAction?.() + await guardedSignOut?.() } else { await signIn() } } else if (mode === 'signIn') { await signIn() } else { - await signOutAction?.() + await guardedSignOut?.() } } catch (error) { console.error(error) From 916b636e6d5810ede8d0ce9c039d5ffb8c43b02c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 15:26:34 +0000 Subject: [PATCH 33/43] fix(ui): swallow rejected async onHighlightError Co-authored-by: Cameron Pak --- .../report-highlight-write-error.test.ts | 17 +++++++++++++++++ .../ui/src/lib/report-highlight-write-error.ts | 4 +++- 2 files changed, 20 insertions(+), 1 deletion(-) 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 index 5cefd46d..eb33055e 100644 --- a/packages/ui/src/lib/__tests__/report-highlight-write-error.test.ts +++ b/packages/ui/src/lib/__tests__/report-highlight-write-error.test.ts @@ -54,6 +54,23 @@ describe('reportHighlightWriteError', () => { 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], diff --git a/packages/ui/src/lib/report-highlight-write-error.ts b/packages/ui/src/lib/report-highlight-write-error.ts index 06a130ba..caae3857 100644 --- a/packages/ui/src/lib/report-highlight-write-error.ts +++ b/packages/ui/src/lib/report-highlight-write-error.ts @@ -16,7 +16,9 @@ function invokeHighlightErrorHandler( error: HighlightWriteError, ): void { try { - onHighlightError(error) + void Promise.resolve(onHighlightError(error)).catch((err) => { + console.error('onHighlightError failed:', err) + }) } catch (err) { console.error('onHighlightError failed:', err) } From 05aa01325dc331fd09f73080155009e40854d6bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 16:12:10 +0000 Subject: [PATCH 34/43] fix(ui): contain signOut rejections and gate signed-out guard Co-authored-by: Cameron Pak --- AGENTS.md | 4 +- .../__tests__/use-sign-out-guard.test.tsx | 72 +++++++++++++------ .../__tests__/youversion-auth-button.test.tsx | 8 +-- packages/ui/src/native/use-sign-out-guard.ts | 10 ++- 4 files changed, 65 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f2e87f12..52d6866c 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/native/__tests__/use-sign-out-guard.test.tsx b/packages/ui/src/native/__tests__/use-sign-out-guard.test.tsx index ef6fce1d..6efabc6d 100644 --- a/packages/ui/src/native/__tests__/use-sign-out-guard.test.tsx +++ b/packages/ui/src/native/__tests__/use-sign-out-guard.test.tsx @@ -10,6 +10,8 @@ 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} @@ -50,12 +52,25 @@ describe('useSignOutGuard', () => { expect(result.current).toBeUndefined() }) - it('shows the normal sign-out alert when nothing is queued', async () => { + it('returns undefined when the user is signed out', async () => { const { result } = renderHook( - () => useSignOutGuard({ signOut, userInfo: { id: USER_ID } }), + () => useSignOutGuard({ signOut, isAuthenticated: false, userInfo: null }), { wrapper }, ) + expect(result.current).toBeUndefined() + + await act(async () => { + await result.current?.() + }) + + expect(Alert.alert).not.toHaveBeenCalled() + expect(signOut).not.toHaveBeenCalled() + }) + + it('shows the normal sign-out alert when nothing is queued', async () => { + const { result } = renderHook(() => useSignOutGuard(signedInAuth), { wrapper }) + await act(async () => { await result.current?.() }) @@ -68,10 +83,7 @@ describe('useSignOutGuard', () => { }) it('signs out once the user confirms the normal variant', async () => { - const { result } = renderHook( - () => useSignOutGuard({ signOut, userInfo: { id: USER_ID } }), - { wrapper }, - ) + const { result } = renderHook(() => useSignOutGuard(signedInAuth), { wrapper }) await act(async () => { await result.current?.() @@ -82,10 +94,7 @@ describe('useSignOutGuard', () => { }) it('keeps the user signed in when they cancel', async () => { - const { result } = renderHook( - () => useSignOutGuard({ signOut, userInfo: { id: USER_ID } }), - { wrapper }, - ) + const { result } = renderHook(() => useSignOutGuard(signedInAuth), { wrapper }) await act(async () => { await result.current?.() @@ -97,10 +106,7 @@ describe('useSignOutGuard', () => { it('escalates when queued writes exist and signs out on confirm without discarding', async () => { jest.spyOn(core, 'hasQueuedHighlightWrites').mockReturnValue(true) - const { result } = renderHook( - () => useSignOutGuard({ signOut, userInfo: { id: USER_ID } }), - { wrapper }, - ) + const { result } = renderHook(() => useSignOutGuard(signedInAuth), { wrapper }) await act(async () => { await result.current?.() @@ -121,13 +127,40 @@ describe('useSignOutGuard', () => { expect(core.hasQueuedHighlightWrites).toHaveBeenCalledWith(USER_ID) }) - it('asks the queue about the signed-in user id', async () => { - const hasQueued = jest.spyOn(core, 'hasQueuedHighlightWrites').mockReturnValue(false) + 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, userInfo: { id: USER_ID } }), + () => + 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?.() }) @@ -152,10 +185,7 @@ describe('useSignOutGuard', () => { enumerable: true, value: 'web', }) - const { result } = renderHook( - () => useSignOutGuard({ signOut, userInfo: { id: USER_ID } }), - { wrapper }, - ) + const { result } = renderHook(() => useSignOutGuard(signedInAuth), { wrapper }) await act(async () => { await result.current?.() 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 ed5adced..efb8813b 100644 --- a/packages/ui/src/native/__tests__/youversion-auth-button.test.tsx +++ b/packages/ui/src/native/__tests__/youversion-auth-button.test.tsx @@ -7,8 +7,8 @@ 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('../bible-app-logo', () => { @@ -223,13 +223,13 @@ describe('YouVersionAuthButton press behavior', () => { expect(mockSignOut).not.toHaveBeenCalled() }) - it('calls signOut when mode="signOut" and unauthenticated', async () => { + it('does nothing 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).toHaveBeenCalledTimes(1) + expect(Alert.alert).not.toHaveBeenCalled() expect(mockSignOut).not.toHaveBeenCalled() }) diff --git a/packages/ui/src/native/use-sign-out-guard.ts b/packages/ui/src/native/use-sign-out-guard.ts index a756dfe4..67fb6055 100644 --- a/packages/ui/src/native/use-sign-out-guard.ts +++ b/packages/ui/src/native/use-sign-out-guard.ts @@ -11,6 +11,7 @@ import { useSdkTranslation } from '../i18n/use-sdk-translation' */ export type SignOutGuardAuth = { signOut: () => Promise + isAuthenticated?: boolean userInfo?: { id?: string | null } | null } | null @@ -31,6 +32,7 @@ export type SignOutGuardAuth = { 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 () => { @@ -54,12 +56,16 @@ export function useSignOutGuard(auth: SignOutGuardAuth): (() => Promise) | text: hasUnsentHighlights ? t('signOutPendingHighlightsConfirm') : t('signOut'), style: 'destructive', onPress: () => { - void signOut() + void signOut().catch((err) => console.error(err)) }, }, ], ) }, [signOut, userId, t]) - return signOut === undefined ? undefined : guardedSignOut + if (signOut === undefined || !isAuthenticated) { + return undefined + } + + return guardedSignOut } From 95aa61861cd6775bcd2ded4333d05e10d34e63dd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 18:10:50 +0000 Subject: [PATCH 35/43] fix(ui): contain rejecting web signOut in useSignOutGuard Co-authored-by: Cameron Pak --- .../__tests__/use-sign-out-guard.test.tsx | 30 +++++++++++++++++++ packages/ui/src/native/use-sign-out-guard.ts | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) 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 index 6efabc6d..fb426853 100644 --- a/packages/ui/src/native/__tests__/use-sign-out-guard.test.tsx +++ b/packages/ui/src/native/__tests__/use-sign-out-guard.test.tsx @@ -194,5 +194,35 @@ describe('useSignOutGuard', () => { 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/use-sign-out-guard.ts b/packages/ui/src/native/use-sign-out-guard.ts index 67fb6055..8daf967f 100644 --- a/packages/ui/src/native/use-sign-out-guard.ts +++ b/packages/ui/src/native/use-sign-out-guard.ts @@ -41,7 +41,7 @@ export function useSignOutGuard(auth: SignOutGuardAuth): (() => Promise) | } if (Platform.OS === 'web') { - await signOut() + await signOut().catch((err) => console.error(err)) return } From 05c0d559b14842277ae555fafcfbd317e4fec033 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Thu, 13 Aug 2026 13:35:56 -0500 Subject: [PATCH 36/43] fix(ui): skip per-frame re-renders on the swatch tray Scroll offset stays in a ref; React only stores the fade-gate booleans so a drag does not rebuild the verse action sheet every frame. Co-authored-by: Cursor --- .../bible-reader-verse-actions.test.tsx | 17 +++++++ .../src/native/bible-verse-action-sheet.tsx | 50 +++++++++++++++---- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/native/__tests__/bible-reader-verse-actions.test.tsx b/packages/ui/src/native/__tests__/bible-reader-verse-actions.test.tsx index 652f1389..0a5cd58f 100644 --- a/packages/ui/src/native/__tests__/bible-reader-verse-actions.test.tsx +++ b/packages/ui/src/native/__tests__/bible-reader-verse-actions.test.tsx @@ -552,6 +552,23 @@ describe('BibleReader verse action sheet — swatch tray overflow', () => { await user.press(screen.getByTestId(`bible-verse-action-swatch-apply-${PINK}`)) expect(highlightPermissionFlowApply).toHaveBeenCalledWith(PINK, [1, 2]) }) + + /** + * Offset lives in a ref, not React state. A later layout pass must still see + * it, or both fades would drop the moment the tray remeasured. + */ + it('keeps both fades after a layout pass mid-strip', async () => { + stubHighlightPermissionFlow([highlight(1, YELLOW), highlight(2, BLUE)]) + render(, { wrapper }) + + await selectVerses() + measureTray(200, 320) + scrollTray(60) + measureTray(200, 320) + + expect(screen.getByTestId('bible-verse-action-swatch-fade-leading')).toBeTruthy() + expect(screen.getByTestId('bible-verse-action-swatch-fade-trailing')).toBeTruthy() + }) }) /** diff --git a/packages/ui/src/native/bible-verse-action-sheet.tsx b/packages/ui/src/native/bible-verse-action-sheet.tsx index b7f051de..1f2541a9 100644 --- a/packages/ui/src/native/bible-verse-action-sheet.tsx +++ b/packages/ui/src/native/bible-verse-action-sheet.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg' @@ -64,12 +64,22 @@ const SWATCH_GAP = 8 */ const PAN_ACTIVE_OFFSET_Y: [number, number] = [-10, 10] +/** Remaining pixels before an edge's fade retires. */ +const FADE_GATE_PX = 1 + /** `fffe00` → `rgba(255, 254, 0, 0.3)`. Input is always 6-char hex, no `#`. */ function hexToRgba(hex: string, alpha: number): string { const value = Number.parseInt(hex, 16) return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})` } +function swatchTrayFadeGates(trayWidth: number, contentWidth: number, scrollX: number) { + return { + hasScrolledPast: scrollX > FADE_GATE_PX, + hasMoreToScroll: contentWidth - trayWidth - scrollX > FADE_GATE_PX, + } +} + export type BibleVerseActionSheetProps = { isOpen: boolean /** Localized display reference for the selection, e.g. `Hebrews 11:4`. */ @@ -107,11 +117,24 @@ export function BibleVerseActionSheet({ // is *remaining* scroll distance, not raw overflow, so a fade retires at the // end it guards. Gating on overflow leaves the outermost swatch permanently // dimmed, which reads as disabled. - const [trayWidth, setTrayWidth] = useState(0) - const [contentWidth, setContentWidth] = useState(0) - const [scrollX, setScrollX] = useState(0) - const hasMoreToScroll = contentWidth - trayWidth - scrollX > 1 - const hasScrolledPast = scrollX > 1 + // + // Layout and offset stay in refs. React state holds only the two booleans the + // fades mount on, so a drag does not re-render the sheet every frame. + const trayWidthRef = useRef(0) + const contentWidthRef = useRef(0) + const scrollXRef = useRef(0) + const [hasScrolledPast, setHasScrolledPast] = useState(false) + const [hasMoreToScroll, setHasMoreToScroll] = useState(false) + + const commitFadeGates = () => { + const next = swatchTrayFadeGates( + trayWidthRef.current, + contentWidthRef.current, + scrollXRef.current, + ) + setHasScrolledPast(next.hasScrolledPast) + setHasMoreToScroll(next.hasMoreToScroll) + } return ( // Non-modal: the user is still building the selection, so the passage behind @@ -146,9 +169,18 @@ export function BibleVerseActionSheet({ testID="bible-verse-action-swatch-scroll" horizontal showsHorizontalScrollIndicator={false} - onLayout={(event) => setTrayWidth(event.nativeEvent.layout.width)} - onContentSizeChange={(width) => setContentWidth(width)} - onScroll={(event) => setScrollX(event.nativeEvent.contentOffset.x)} + onLayout={(event) => { + trayWidthRef.current = event.nativeEvent.layout.width + commitFadeGates() + }} + onContentSizeChange={(width) => { + contentWidthRef.current = width + commitFadeGates() + }} + onScroll={(event) => { + scrollXRef.current = event.nativeEvent.contentOffset.x + commitFadeGates() + }} scrollEventThrottle={16} style={styles.swatchScroll} contentContainerStyle={styles.swatchTrayContent} From d4ef5fbf026878ac8840231de16df4db7ef97e3d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 18:38:10 +0000 Subject: [PATCH 37/43] fix(ui): make HighlightWriteError a two-member union Co-authored-by: Cameron Pak --- .../report-highlight-write-error.test.ts | 21 ++++++++++++++++++- .../src/lib/report-highlight-write-error.ts | 9 +++----- 2 files changed, 23 insertions(+), 7 deletions(-) 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 index eb33055e..7f769641 100644 --- a/packages/ui/src/lib/__tests__/report-highlight-write-error.test.ts +++ b/packages/ui/src/lib/__tests__/report-highlight-write-error.test.ts @@ -1,6 +1,19 @@ import type { HighlightWriteOutcome } from '@youversion/platform-react-native-expo-core' -import { reportHighlightWriteError } from '../report-highlight-write-error' +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', () => { @@ -111,4 +124,10 @@ describe('reportHighlightWriteError', () => { 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 index caae3857..e2a4537f 100644 --- a/packages/ui/src/lib/report-highlight-write-error.ts +++ b/packages/ui/src/lib/report-highlight-write-error.ts @@ -4,12 +4,9 @@ import type { HighlightWriteOutcome } from '@youversion/platform-react-native-ex * 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' | 'error' - reason?: 'transient' - verses: number[] - message?: string -} +export type HighlightWriteError = + | { status: 'queued'; verses: number[] } + | { status: 'error'; reason: 'transient'; verses: number[]; message?: string } function invokeHighlightErrorHandler( onHighlightError: (error: HighlightWriteError) => void, From 75a3811dba8fbc35c7fb794084d1a6ca43d56c3e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 18:43:37 +0000 Subject: [PATCH 38/43] fix(ui): explicit sign-out still clears session when unauthenticated Co-authored-by: Cameron Pak --- .../__tests__/use-sign-out-guard.test.tsx | 6 +++--- .../__tests__/youversion-auth-button.test.tsx | 4 ++-- packages/ui/src/native/use-sign-out-guard.ts | 17 +++++++++++++---- 3 files changed, 18 insertions(+), 9 deletions(-) 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 index fb426853..68109feb 100644 --- a/packages/ui/src/native/__tests__/use-sign-out-guard.test.tsx +++ b/packages/ui/src/native/__tests__/use-sign-out-guard.test.tsx @@ -52,20 +52,20 @@ describe('useSignOutGuard', () => { expect(result.current).toBeUndefined() }) - it('returns undefined when the user is signed out', async () => { + 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).toBeUndefined() + expect(result.current).toBeDefined() await act(async () => { await result.current?.() }) expect(Alert.alert).not.toHaveBeenCalled() - expect(signOut).not.toHaveBeenCalled() + expect(signOut).toHaveBeenCalledTimes(1) }) it('shows the normal sign-out alert when nothing is queued', async () => { 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 efb8813b..0acdfa36 100644 --- a/packages/ui/src/native/__tests__/youversion-auth-button.test.tsx +++ b/packages/ui/src/native/__tests__/youversion-auth-button.test.tsx @@ -223,14 +223,14 @@ describe('YouVersionAuthButton press behavior', () => { expect(mockSignOut).not.toHaveBeenCalled() }) - it('does nothing 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).not.toHaveBeenCalled() + expect(mockSignOut).toHaveBeenCalledTimes(1) }) it('signs out immediately on web without raising Alert', async () => { diff --git a/packages/ui/src/native/use-sign-out-guard.ts b/packages/ui/src/native/use-sign-out-guard.ts index 8daf967f..9c9bc332 100644 --- a/packages/ui/src/native/use-sign-out-guard.ts +++ b/packages/ui/src/native/use-sign-out-guard.ts @@ -26,8 +26,12 @@ export type SignOutGuardAuth = { * * On web, `Alert.alert` is a no-op, so the guard calls `signOut()` directly. * - * Returns `undefined` when there is nothing to sign out of, so callers can pass - * the result straight through to an optional handler prop. + * 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() @@ -40,6 +44,11 @@ export function useSignOutGuard(auth: SignOutGuardAuth): (() => Promise) | return } + if (!isAuthenticated) { + await signOut().catch((err) => console.error(err)) + return + } + if (Platform.OS === 'web') { await signOut().catch((err) => console.error(err)) return @@ -61,9 +70,9 @@ export function useSignOutGuard(auth: SignOutGuardAuth): (() => Promise) | }, ], ) - }, [signOut, userId, t]) + }, [signOut, isAuthenticated, userId, t]) - if (signOut === undefined || !isAuthenticated) { + if (signOut === undefined) { return undefined } From 4a20d6e1abad5fc177c35472b1b5fa2f4e57eb32 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 19:16:09 +0000 Subject: [PATCH 39/43] refactor(ui): extract swatch tray fade gates to lib Move the remaining-distance predicate to layer 1 so the arithmetic can be tested without booting BibleReader, and store tray metrics as one ref. Co-authored-by: Cameron Pak --- .../__tests__/verse-action-fade-gates.test.ts | 67 +++++++++++++++++++ .../ui/src/lib/verse-action-fade-gates.ts | 33 +++++++++ .../bible-reader-verse-actions.test.tsx | 4 +- .../src/native/bible-verse-action-sheet.tsx | 52 ++++++-------- 4 files changed, 123 insertions(+), 33 deletions(-) create mode 100644 packages/ui/src/lib/__tests__/verse-action-fade-gates.test.ts create mode 100644 packages/ui/src/lib/verse-action-fade-gates.ts diff --git a/packages/ui/src/lib/__tests__/verse-action-fade-gates.test.ts b/packages/ui/src/lib/__tests__/verse-action-fade-gates.test.ts new file mode 100644 index 00000000..b6a71be1 --- /dev/null +++ b/packages/ui/src/lib/__tests__/verse-action-fade-gates.test.ts @@ -0,0 +1,67 @@ +/** + * Layer 1 — remaining-distance fade gates for the verse action swatch tray. + * + * Layer 3 can only see the overlays after a full `BibleReader` boot. These + * cases pin the arithmetic, including the 1px slack at each edge. + */ +import { FADE_GATE_PX, swatchTrayFadeGates } from '../verse-action-fade-gates' + +const OVERFLOWING = { trayWidth: 200, contentWidth: 320 } + +describe('swatchTrayFadeGates', () => { + it('shows only the trailing fade at the head of an overflowing strip', () => { + expect(swatchTrayFadeGates({ ...OVERFLOWING, scrollX: 0 })).toEqual({ + hasScrolledPast: false, + hasMoreToScroll: true, + }) + }) + + it('shows both fades mid-strip', () => { + expect(swatchTrayFadeGates({ ...OVERFLOWING, scrollX: 60 })).toEqual({ + hasScrolledPast: true, + hasMoreToScroll: true, + }) + }) + + it('shows only the leading fade at the tail', () => { + expect(swatchTrayFadeGates({ ...OVERFLOWING, scrollX: 120 })).toEqual({ + hasScrolledPast: true, + hasMoreToScroll: false, + }) + }) + + it('shows neither fade when the strip fits', () => { + expect(swatchTrayFadeGates({ trayWidth: 200, contentWidth: 200, scrollX: 0 })).toEqual({ + hasScrolledPast: false, + hasMoreToScroll: false, + }) + }) + + it('keeps the leading fade retired until scroll passes the slack', () => { + expect(swatchTrayFadeGates({ ...OVERFLOWING, scrollX: FADE_GATE_PX })).toEqual({ + hasScrolledPast: false, + hasMoreToScroll: true, + }) + expect(swatchTrayFadeGates({ ...OVERFLOWING, scrollX: FADE_GATE_PX + 0.01 })).toEqual({ + hasScrolledPast: true, + hasMoreToScroll: true, + }) + }) + + it('retires the trailing fade once remaining distance is at the slack', () => { + const remaining = OVERFLOWING.contentWidth - OVERFLOWING.trayWidth + expect(swatchTrayFadeGates({ ...OVERFLOWING, scrollX: remaining - FADE_GATE_PX })).toEqual({ + hasScrolledPast: true, + hasMoreToScroll: false, + }) + expect( + swatchTrayFadeGates({ + ...OVERFLOWING, + scrollX: remaining - FADE_GATE_PX - 0.01, + }), + ).toEqual({ + hasScrolledPast: true, + hasMoreToScroll: true, + }) + }) +}) diff --git a/packages/ui/src/lib/verse-action-fade-gates.ts b/packages/ui/src/lib/verse-action-fade-gates.ts new file mode 100644 index 00000000..3575c48c --- /dev/null +++ b/packages/ui/src/lib/verse-action-fade-gates.ts @@ -0,0 +1,33 @@ +/** Slack, in px, before a fade retires at the edge it guards. */ +export const FADE_GATE_PX = 1 + +/** + * Live swatch-tray measurements. Widths and offset always travel together; + * fade visibility is a function of the triple, not of any one number. + */ +export type SwatchTrayMetrics = { + trayWidth: number + contentWidth: number + scrollX: number +} + +export type SwatchTrayFadeGates = { + hasScrolledPast: boolean + hasMoreToScroll: boolean +} + +/** + * Whether each end of the swatch tray still has hidden content under it. + * + * Leading (`hasScrolledPast`) gates on distance already scrolled. Trailing + * (`hasMoreToScroll`) gates on remaining scroll distance. Both use + * `FADE_GATE_PX` of slack so a fade retires at the edge it guards rather than + * staying up for raw overflow. + */ +export function swatchTrayFadeGates(metrics: SwatchTrayMetrics): SwatchTrayFadeGates { + const { trayWidth, contentWidth, scrollX } = metrics + return { + hasScrolledPast: scrollX > FADE_GATE_PX, + hasMoreToScroll: contentWidth - trayWidth - scrollX > FADE_GATE_PX, + } +} diff --git a/packages/ui/src/native/__tests__/bible-reader-verse-actions.test.tsx b/packages/ui/src/native/__tests__/bible-reader-verse-actions.test.tsx index 0a5cd58f..bd466d6a 100644 --- a/packages/ui/src/native/__tests__/bible-reader-verse-actions.test.tsx +++ b/packages/ui/src/native/__tests__/bible-reader-verse-actions.test.tsx @@ -554,8 +554,8 @@ describe('BibleReader verse action sheet — swatch tray overflow', () => { }) /** - * Offset lives in a ref, not React state. A later layout pass must still see - * it, or both fades would drop the moment the tray remeasured. + * Guards a handler that zeros stored offset on remeasure. The fade arithmetic + * itself is pinned at layer 1 in `lib/__tests__/verse-action-fade-gates.test.ts`. */ it('keeps both fades after a layout pass mid-strip', async () => { stubHighlightPermissionFlow([highlight(1, YELLOW), highlight(2, BLUE)]) diff --git a/packages/ui/src/native/bible-verse-action-sheet.tsx b/packages/ui/src/native/bible-verse-action-sheet.tsx index 1f2541a9..de61d366 100644 --- a/packages/ui/src/native/bible-verse-action-sheet.tsx +++ b/packages/ui/src/native/bible-verse-action-sheet.tsx @@ -5,6 +5,7 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg' import { useSdkTranslation } from '../i18n/use-sdk-translation' import { SHEET_FOREGROUND, SHEET_MUTED_BACKGROUND, SHEET_STROKE } from '../lib/native-sheet-theme' import type { Theme } from '../lib/resolve-theme' +import { swatchTrayFadeGates, type SwatchTrayMetrics } from '../lib/verse-action-fade-gates' import type { VerseActionSwatch } from '../lib/verse-action-swatches' import { CheckIcon, CopyIcon, ShareIcon } from './icons' import { NativeSheet } from './native-sheet' @@ -64,22 +65,12 @@ const SWATCH_GAP = 8 */ const PAN_ACTIVE_OFFSET_Y: [number, number] = [-10, 10] -/** Remaining pixels before an edge's fade retires. */ -const FADE_GATE_PX = 1 - /** `fffe00` → `rgba(255, 254, 0, 0.3)`. Input is always 6-char hex, no `#`. */ function hexToRgba(hex: string, alpha: number): string { const value = Number.parseInt(hex, 16) return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})` } -function swatchTrayFadeGates(trayWidth: number, contentWidth: number, scrollX: number) { - return { - hasScrolledPast: scrollX > FADE_GATE_PX, - hasMoreToScroll: contentWidth - trayWidth - scrollX > FADE_GATE_PX, - } -} - export type BibleVerseActionSheetProps = { isOpen: boolean /** Localized display reference for the selection, e.g. `Hebrews 11:4`. */ @@ -113,25 +104,24 @@ export function BibleVerseActionSheet({ }: BibleVerseActionSheetProps) { const { t } = useSdkTranslation() - // Each edge shows its fade only while swatches are hidden under it. The gate - // is *remaining* scroll distance, not raw overflow, so a fade retires at the - // end it guards. Gating on overflow leaves the outermost swatch permanently - // dimmed, which reads as disabled. + // Each edge shows its fade only while swatches are hidden under it. The + // gates (`lib/verse-action-fade-gates.ts`) use remaining distance on the + // trailing edge and distance already scrolled on the leading, not raw + // overflow, so a fade retires at the end it guards. Gating on overflow + // leaves the outermost swatch permanently dimmed, which reads as disabled. // - // Layout and offset stay in refs. React state holds only the two booleans the - // fades mount on, so a drag does not re-render the sheet every frame. - const trayWidthRef = useRef(0) - const contentWidthRef = useRef(0) - const scrollXRef = useRef(0) + // Layout and offset stay in a ref. React state holds only the two booleans + // the fades mount on, so a drag does not re-render the sheet every frame. + const metricsRef = useRef({ + trayWidth: 0, + contentWidth: 0, + scrollX: 0, + }) const [hasScrolledPast, setHasScrolledPast] = useState(false) const [hasMoreToScroll, setHasMoreToScroll] = useState(false) - const commitFadeGates = () => { - const next = swatchTrayFadeGates( - trayWidthRef.current, - contentWidthRef.current, - scrollXRef.current, - ) + const syncFadeGates = () => { + const next = swatchTrayFadeGates(metricsRef.current) setHasScrolledPast(next.hasScrolledPast) setHasMoreToScroll(next.hasMoreToScroll) } @@ -170,16 +160,16 @@ export function BibleVerseActionSheet({ horizontal showsHorizontalScrollIndicator={false} onLayout={(event) => { - trayWidthRef.current = event.nativeEvent.layout.width - commitFadeGates() + metricsRef.current.trayWidth = event.nativeEvent.layout.width + syncFadeGates() }} onContentSizeChange={(width) => { - contentWidthRef.current = width - commitFadeGates() + metricsRef.current.contentWidth = width + syncFadeGates() }} onScroll={(event) => { - scrollXRef.current = event.nativeEvent.contentOffset.x - commitFadeGates() + metricsRef.current.scrollX = event.nativeEvent.contentOffset.x + syncFadeGates() }} scrollEventThrottle={16} style={styles.swatchScroll} From 99a30e480b392164d635ef55d6f0c7c27617f1a6 Mon Sep 17 00:00:00 2001 From: Brenden Manquen Date: Thu, 13 Aug 2026 22:11:32 -0500 Subject: [PATCH 40/43] refactor(api): narrow the published surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `ensureFreshToken` from the auth context. `getAccessToken` runs the identical leeway-gated single-flight refresh and reports whether it worked, so the side-effect-only variant was strictly less useful. `HighlightQueueDrainHost` derives the drain's internal `DrainAuth.ensureFreshToken` from it; `drain.ts` is unchanged. - Stop exporting `SignOutGuardAuth`. It only named `useSignOutGuard`'s parameter, which structural inference already covers. - Stop exporting `isValidHighlightHex` and `KnownAuthPermission`. - Update AGENTS.md, ADR 0016, the READMEs, and the changeset. None of these shipped — all are new on this branch inside unreleased minors, so no changeset entry is needed for the removals. --- .changeset/native-highlights-release.md | 17 +++-- AGENTS.md | 13 ++-- README.md | 74 ++++++++++++++++++- docs/adr/0016-highlight-permission-flow.md | 2 +- packages/core/README.md | 55 +++++++++++++- .../src/auth/__tests__/auth-provider.test.tsx | 2 +- .../src/auth/__tests__/use-yv-auth.test.tsx | 1 - packages/core/src/auth/auth-context.tsx | 33 +++------ packages/core/src/auth/auth-provider.tsx | 19 ++--- .../src/highlights/__tests__/exports.test.ts | 1 + .../highlight-queue-drain-host.test.tsx | 4 +- .../__tests__/highlight-write-queue.test.tsx | 5 -- .../offline-permission-flow.test.tsx | 1 - .../use-highlight-permission-flow.test.tsx | 8 -- .../__tests__/use-highlights.test.tsx | 14 +--- .../highlights/highlight-queue-drain-host.tsx | 14 +++- packages/core/src/index.ts | 2 - packages/ui/README.md | 2 + packages/ui/src/index.ts | 1 - packages/ui/src/lib/verse-action-swatches.ts | 13 ++-- .../bible-reader-consumer-api.test.tsx | 1 - .../bible-reader-highlights-prompts.test.tsx | 1 - .../__tests__/bible-reader-sign-out.test.tsx | 1 - .../__tests__/youversion-auth-button.test.tsx | 1 - packages/ui/src/native/index.ts | 1 - packages/ui/src/native/use-sign-out-guard.ts | 2 +- 26 files changed, 191 insertions(+), 97 deletions(-) diff --git a/.changeset/native-highlights-release.md b/.changeset/native-highlights-release.md index 82063cee..ae00a6a9 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 52d6866c..ef52221c 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 b9036262..22f7d470 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