diff --git a/.changeset/ype-5745-reader-navigation.md b/.changeset/ype-5745-reader-navigation.md new file mode 100644 index 00000000..a050b5bc --- /dev/null +++ b/.changeset/ype-5745-reader-navigation.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-native-expo-ui': minor +--- + +feat: add BibleReaderNavigation for chapter jumps (YPE-5745). Host can request a version/book/chapter before the Reader mounts. Scroll-to-verse and focus are not in this release. diff --git a/CONTEXT.md b/CONTEXT.md index 2683a2ed..09016edb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -48,7 +48,11 @@ _Avoid_: Passage id, USFM ref **Reader Location**: The last committed Bible location (`book`, `chapter`, `versionId`) a **Native Wrapper** restores on launch for uncontrolled readers. Same shape as **Picker Selection**, but names the persisted snapshot rather than the commit event. Controlled `book` / `chapter` / `versionId` win and are not overwritten by the snapshot. Uncontrolled **BibleCard** persists committed `versionId` in MMKV, separate from this snapshot. -_Avoid_: Reader navigation, passage state +_Avoid_: **Reader Navigation** (the pending-request object); passage state + +**Reader Navigation**: +The public pending-request object (`BibleReaderNavigation`) a host creates and passes into `BibleReader`. One object per mounted Reader. `request` and `focusReference` queue one jump to a version / book / chapter; a newer call replaces an older one; the Reader consumes it once, including when submitted before mount. Verse scroll and focus fields are stored for a later release and do not move or dim the chapter today. Goes through the existing `book` / `chapter` / `versionId` setters, so controlled props still notify the host and uncontrolled readers still persist **Reader Location**. +_Avoid_: Adding methods to **BibleReaderHandle**; new DOM / WebView props; treating this as **Reader Location** (that is the MMKV snapshot) **Picker Press**: The user action that requests opening chapter picker presentation from the current Bible location. Defaults to opening the built-in **Chapter Picker Sheet**; overridable via `onChapterPickerPress`. diff --git a/README.md b/README.md index e3b4d97f..2c996978 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,27 @@ On iOS and Android, avatar, chapter (with prev/next), version, and settings live `BibleTextView`, `BibleCard`, and `VerseOfTheDay` paint those same highlights on the passage they show, from the same cache. They do not create or remove highlights — tapping a verse on those surfaces still does nothing. +#### Jumping to a passage + +Create a `BibleReaderNavigation` object and pass it in. Use one object per Reader. You can call it before the reader mounts. A newer call replaces an older one; the reader consumes each request once. + +```tsx +import { useMemo } from 'react' +import { BibleReader, createBibleReaderNavigation } from '@youversion/platform-react-native-expo-ui' + +function ReaderScreen() { + const navigation = useMemo(() => { + const readerNavigation = createBibleReaderNavigation() + readerNavigation.request({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 }) + return readerNavigation + }, []) + + return +} +``` + +`request` and `focusReference` load that version / book / chapter. They do not scroll to the verse or dim the rest of the chapter in this release. + #### Verse actions Tapping a verse opens a native bottom sheet with the reference, Copy, and Share. When `auth` is configured on `YouVersionProvider`, the sheet also shows the highlight colors. 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. diff --git a/packages/ui/src/__tests__/exports.test.ts b/packages/ui/src/__tests__/exports.test.ts index 5d4f7e53..4953923f 100644 --- a/packages/ui/src/__tests__/exports.test.ts +++ b/packages/ui/src/__tests__/exports.test.ts @@ -1,8 +1,9 @@ /** * Guards the public API surface of the UI package, the way * `packages/core/src/highlights/__tests__/exports.test.ts` guards core's. The - * nine components plus `useSignOutGuard`, `getTokens`, and `useTokens` are the supported - * surface; the sheets the SDK wires for itself stay off the package namespace, + * nine components plus `BibleReaderNavigation`, `createBibleReaderNavigation`, + * `useSignOutGuard`, `getTokens`, and `useTokens` are the supported surface; + * the sheets the SDK wires for itself stay off the package namespace, * so a consumer cannot couple to them and an accidental re-export from * `index.ts` reds the suite. * @@ -28,7 +29,7 @@ describe('package exports', () => { // Named one by one rather than looped over `PUBLIC_COMPONENTS`: a computed // read off the namespace is something neither ESLint nor tsc can check, so // the loop would pass on a name no longer exported. - it('exposes the public components, the sign-out guard, getTokens, and useTokens', () => { + it('exposes the public components, reader navigation, the sign-out guard, getTokens, and useTokens', () => { expect(ui.BibleCard).toEqual(expect.any(Function)) expect(ui.BibleChapterPickerSheet).toEqual(expect.any(Function)) expect(ui.BibleReader).toEqual(expect.any(Function)) @@ -38,6 +39,8 @@ describe('package exports', () => { expect(ui.VerseOfTheDay).toEqual(expect.any(Function)) expect(ui.YouVersionAuthButton).toEqual(expect.any(Function)) expect(ui.YouVersionProvider).toEqual(expect.any(Function)) + expect(ui.BibleReaderNavigation).toEqual(expect.any(Function)) + expect(ui.createBibleReaderNavigation).toEqual(expect.any(Function)) expect(ui.useSignOutGuard).toEqual(expect.any(Function)) expect(ui.getTokens).toEqual(expect.any(Function)) expect(ui.useTokens).toEqual(expect.any(Function)) @@ -55,6 +58,7 @@ describe('package exports', () => { expect(names).not.toContain('useVerseOfTheDayPassageId') expect(names).not.toContain('getVerseOfTheDayPassageId') expect(names).not.toContain('getDayOfYear') + expect(names).not.toContain('useConsumedNavigationRequest') expect(names).not.toContain('useVerseOfTheDayShareSource') expect(names).not.toContain('getVerseOfTheDayShareSource') expect(names).not.toContain('Tabs') @@ -72,7 +76,14 @@ describe('package exports', () => { it('exports nothing beyond the pinned list', () => { expect(Object.keys(ui).sort()).toEqual( - [...PUBLIC_COMPONENTS, 'getTokens', 'useSignOutGuard', 'useTokens'].sort(), + [ + ...PUBLIC_COMPONENTS, + 'BibleReaderNavigation', + 'createBibleReaderNavigation', + 'getTokens', + 'useSignOutGuard', + 'useTokens', + ].sort(), ) }) }) diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 591891ee..323f56c2 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -6,17 +6,20 @@ export { BibleCard, BibleChapterPickerSheet, BibleReader, + BibleReaderNavigation, BibleReaderSettingsSheet, BibleTextView, BibleVersionPickerSheet, VerseOfTheDay, YouVersionAuthButton, YouVersionProvider, + createBibleReaderNavigation, } from './native' export type { BibleCardProps, BibleChapterPickerSheetProps, BibleReaderHandle, + BibleReaderNavigationRequest, BibleReaderProps, BibleReaderSettingsSheetProps, BibleReaderShareData, diff --git a/packages/ui/src/native/__tests__/bible-reader-navigation-reader.test.tsx b/packages/ui/src/native/__tests__/bible-reader-navigation-reader.test.tsx new file mode 100644 index 00000000..14db8c65 --- /dev/null +++ b/packages/ui/src/native/__tests__/bible-reader-navigation-reader.test.tsx @@ -0,0 +1,231 @@ +import { act, fireEvent, render } from '@testing-library/react-native' +import { mmkvStorage } from '@youversion/platform-react-native-expo-core' +import { Pressable, Text, View } from 'react-native' + +import { + readerLocationStoreInitialState, + useReaderLocationStore, +} from '../../stores/reader-location-store' +import { + installBibleReaderTestImpls, + resetImpls, + setImpl, +} from '../../test-utils/install-test-impls' +import { youVersionProviderWrapper } from '../../test-utils/youversion-provider-wrapper' +import { BibleReader } from '../bible-reader' +import { createBibleReaderNavigation } from '../bible-reader-navigation' + +type LatestReaderDomProps = { + book?: string + chapter?: string + versionId?: number + onChapterChange?: (chapter: string) => Promise +} + +let latestReaderDomProps: LatestReaderDomProps = {} + +function MockDOM(props: LatestReaderDomProps) { + latestReaderDomProps = props + return ( + + {props.book ?? 'none'} + {props.chapter ?? 'none'} + {String(props.versionId ?? 'none')} + props.onChapterChange?.('5')}> + Chapter + + + ) +} + +const wrapper = youVersionProviderWrapper() + +async function resetReaderLocationStore() { + mmkvStorage.clearAll() + useReaderLocationStore.setState(readerLocationStoreInitialState) + await useReaderLocationStore.persist.rehydrate() +} + +describe('BibleReader navigation', () => { + beforeEach(async () => { + latestReaderDomProps = {} + installBibleReaderTestImpls() + setImpl('BibleReaderDom', MockDOM) + await resetReaderLocationStore() + }) + + afterEach(() => { + resetImpls() + jest.restoreAllMocks() + }) + + it('applies a request made before mount on the first render', () => { + const navigation = createBibleReaderNavigation() + navigation.request({ versionId: 111, bookId: 'PSA', chapter: 23, verse: 1 }) + + const { getByTestId } = render(, { wrapper }) + + expect(getByTestId('book').props.children).toBe('PSA') + expect(getByTestId('chapter').props.children).toBe('23') + expect(getByTestId('version-id').props.children).toBe('111') + }) + + it('lets a newer request replace an older one before mount', () => { + const navigation = createBibleReaderNavigation() + navigation.request({ versionId: 59, bookId: 'GEN', chapter: 1, verse: 1 }) + navigation.request({ versionId: 111, bookId: 'PSA', chapter: 23, verse: 1 }) + + const { getByTestId } = render(, { wrapper }) + + expect(getByTestId('book').props.children).toBe('PSA') + expect(getByTestId('chapter').props.children).toBe('23') + expect(getByTestId('version-id').props.children).toBe('111') + }) + + it('consumes a request once so a later render does not re-apply it', async () => { + const navigation = createBibleReaderNavigation() + navigation.request({ versionId: 111, bookId: 'PSA', chapter: 23, verse: 1 }) + + const { getByTestId, rerender } = render(, { wrapper }) + + expect(getByTestId('book').props.children).toBe('PSA') + expect(getByTestId('chapter').props.children).toBe('23') + + rerender() + + expect(getByTestId('chapter').props.children).toBe('23') + + await act(async () => { + fireEvent.press(getByTestId('trigger-chapter-change')) + }) + + expect(getByTestId('book').props.children).toBe('PSA') + expect(getByTestId('chapter').props.children).toBe('5') + expect(getByTestId('version-id').props.children).toBe('111') + }) + + it('request and focusReference both change chapter and version when the reference differs', async () => { + const navigation = createBibleReaderNavigation() + + const { getByTestId } = render( + , + { wrapper }, + ) + + expect(getByTestId('book').props.children).toBe('JHN') + expect(getByTestId('chapter').props.children).toBe('1') + expect(getByTestId('version-id').props.children).toBe('111') + + await act(async () => { + navigation.request({ versionId: 111, bookId: 'MAT', chapter: 5, verse: 1 }) + }) + + expect(getByTestId('book').props.children).toBe('MAT') + expect(getByTestId('chapter').props.children).toBe('5') + expect(getByTestId('version-id').props.children).toBe('111') + + await act(async () => { + navigation.focusReference({ versionId: 59, bookId: 'ROM', chapter: 8, verse: 1 }) + }) + + expect(getByTestId('book').props.children).toBe('ROM') + expect(getByTestId('chapter').props.children).toBe('8') + expect(getByTestId('version-id').props.children).toBe('59') + }) + + it('persists a combined jump as one Reader Location', async () => { + const originalSetLocation = useReaderLocationStore.getState().setLocation + const patches: { book?: string; chapter?: string; versionId?: number }[] = [] + + try { + await act(async () => { + useReaderLocationStore.setState({ + setLocation: (patch) => { + patches.push(patch) + originalSetLocation(patch) + }, + }) + }) + + const navigation = createBibleReaderNavigation() + render( + , + { wrapper }, + ) + patches.length = 0 + + await act(async () => { + navigation.request({ versionId: 59, bookId: 'ROM', chapter: 8, verse: 1 }) + }) + + expect(patches).toEqual([{ book: 'ROM', chapter: '8', versionId: 59 }]) + expect(useReaderLocationStore.getState()).toMatchObject({ + book: 'ROM', + chapter: '8', + versionId: 59, + }) + } finally { + await act(async () => { + useReaderLocationStore.setState({ setLocation: originalSetLocation }) + }) + } + }) + + it('consumes a same-location request without rewriting the DOM location', async () => { + const navigation = createBibleReaderNavigation() + const onBookChange = jest.fn() + const onChapterChange = jest.fn() + const onVersionChange = jest.fn() + + const { getByTestId } = render( + , + { wrapper }, + ) + + await act(async () => { + navigation.request({ versionId: 111, bookId: 'JHN', chapter: 1, verse: 16 }) + }) + + expect(getByTestId('book').props.children).toBe('JHN') + expect(getByTestId('chapter').props.children).toBe('1') + expect(getByTestId('version-id').props.children).toBe('111') + expect(onBookChange).not.toHaveBeenCalled() + expect(onChapterChange).not.toHaveBeenCalled() + expect(onVersionChange).not.toHaveBeenCalled() + }) + + it('does not add scroll or focus props on the DOM mock', () => { + const navigation = createBibleReaderNavigation() + navigation.request({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 }) + navigation.focusReference({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 }, true) + + render(, { wrapper }) + + expect(latestReaderDomProps.book).toBe('JHN') + expect(latestReaderDomProps.chapter).toBe('3') + expect(latestReaderDomProps.versionId).toBe(111) + expect(latestReaderDomProps).not.toHaveProperty('scrollsToVerse') + expect(latestReaderDomProps).not.toHaveProperty('shouldFocus') + expect(latestReaderDomProps).not.toHaveProperty('scrollTarget') + expect(latestReaderDomProps).not.toHaveProperty('focusedVerse') + expect(latestReaderDomProps).not.toHaveProperty('showsFullChapter') + }) +}) diff --git a/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts b/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts new file mode 100644 index 00000000..9d32576a --- /dev/null +++ b/packages/ui/src/native/__tests__/bible-reader-navigation.test.ts @@ -0,0 +1,138 @@ +import type { BibleReference } from '@youversion/platform-react-native-expo-core' +import { act, renderHook } from '@testing-library/react-native' + +import { + BibleReaderNavigation, + createBibleReaderNavigation, + useConsumedNavigationRequest, +} from '../bible-reader-navigation' + +const JOHN_3_16: BibleReference = { + versionId: 111, + bookId: 'JHN', + chapter: 3, + verse: 16, +} + +const ROMANS_8_1: BibleReference = { + versionId: 59, + bookId: 'ROM', + chapter: 8, + verse: 1, +} + +describe('BibleReaderNavigation', () => { + it('request stores a full-chapter pending request including the verse', () => { + const navigation = createBibleReaderNavigation() + + navigation.request(JOHN_3_16) + + const { result } = renderHook(() => useConsumedNavigationRequest(navigation)) + + expect(result.current).toEqual({ + reference: JOHN_3_16, + showsFullChapter: true, + scrollsToVerse: false, + shouldFocus: false, + }) + }) + + it('focusReference stores shouldFocus and scrollsToVerse without scrolling itself', () => { + const navigation = createBibleReaderNavigation() + + navigation.focusReference(JOHN_3_16) + + const { result } = renderHook(() => useConsumedNavigationRequest(navigation)) + + expect(result.current).toEqual({ + reference: JOHN_3_16, + showsFullChapter: false, + scrollsToVerse: true, + shouldFocus: true, + }) + + act(() => { + navigation.focusReference(ROMANS_8_1, false) + }) + + expect(result.current).toEqual({ + reference: ROMANS_8_1, + showsFullChapter: false, + scrollsToVerse: false, + shouldFocus: true, + }) + }) + + it('a newer call replaces the older pending request', () => { + const navigation = createBibleReaderNavigation() + + navigation.request(JOHN_3_16) + navigation.focusReference(ROMANS_8_1, true) + + const { result } = renderHook(() => useConsumedNavigationRequest(navigation)) + + expect(result.current?.reference).toEqual(ROMANS_8_1) + expect(result.current?.shouldFocus).toBe(true) + }) + + it('keeps verse fields on an uncommitted pending request', () => { + const navigation = createBibleReaderNavigation() + navigation.request(JOHN_3_16) + + const { result } = renderHook(() => useConsumedNavigationRequest(navigation)) + + expect(result.current?.reference.verse).toBe(16) + expect(result.current?.scrollsToVerse).toBe(false) + expect(result.current?.shouldFocus).toBe(false) + }) + + it('createBibleReaderNavigation and new BibleReaderNavigation share the host API', () => { + const created = createBibleReaderNavigation() + const constructed = new BibleReaderNavigation() + + expect(created).toBeInstanceOf(BibleReaderNavigation) + expect(constructed).toBeInstanceOf(BibleReaderNavigation) + expect(created.request).toEqual(expect.any(Function)) + expect(created.focusReference).toEqual(expect.any(Function)) + expect(created).not.toHaveProperty('pendingRequest') + expect(created).not.toHaveProperty('subscribe') + expect(created).not.toHaveProperty('getSnapshot') + expect(created).not.toHaveProperty('consumePending') + expect(created).not.toHaveProperty('consumeCommitted') + }) +}) + +describe('useConsumedNavigationRequest', () => { + it('applies a pre-mount request once and keeps verse fields on that request', () => { + const navigation = createBibleReaderNavigation() + navigation.request(JOHN_3_16) + + const { result, rerender } = renderHook( + ({ nav }: { nav: BibleReaderNavigation }) => useConsumedNavigationRequest(nav), + { initialProps: { nav: navigation } }, + ) + + expect(result.current?.reference).toEqual(JOHN_3_16) + expect(result.current?.reference.verse).toBe(16) + + rerender({ nav: navigation }) + + expect(result.current).toBeNull() + }) + + it('does not drop a newer request when an older render has already committed', () => { + const navigation = createBibleReaderNavigation() + navigation.request(JOHN_3_16) + + const { result } = renderHook(() => useConsumedNavigationRequest(navigation)) + + expect(result.current?.reference).toEqual(JOHN_3_16) + + act(() => { + navigation.focusReference(ROMANS_8_1) + }) + + expect(result.current?.reference).toEqual(ROMANS_8_1) + expect(result.current?.shouldFocus).toBe(true) + }) +}) diff --git a/packages/ui/src/native/bible-reader-navigation.ts b/packages/ui/src/native/bible-reader-navigation.ts new file mode 100644 index 00000000..18a1d4ca --- /dev/null +++ b/packages/ui/src/native/bible-reader-navigation.ts @@ -0,0 +1,166 @@ +import type { BibleReference } from '@youversion/platform-react-native-expo-core' +import { useLayoutEffect, useRef, useSyncExternalStore } from 'react' + +/** + * One jump the host asked the reader to make. Verse scroll and focus are + * stored so a later release can honor them; this release only loads the + * chapter. + */ +export type BibleReaderNavigationRequest = { + reference: BibleReference + showsFullChapter: boolean + scrollsToVerse: boolean + shouldFocus: boolean +} + +type Subscribe = (onStoreChange: () => void) => () => void + +const subscribeNoop: Subscribe = () => () => {} +const snapshotZero = (): number => 0 + +function copyReference(reference: BibleReference): BibleReference { + return { + versionId: reference.versionId, + bookId: reference.bookId, + chapter: reference.chapter, + verse: reference.verse, + } +} + +type ReaderNavigationAccess = { + subscribe: Subscribe + getSnapshot: () => number + peekPending: () => BibleReaderNavigationRequest | null + consumeCommitted: (version: number) => void +} + +const readerAccess = new WeakMap() + +function accessFor(navigation: BibleReaderNavigation): ReaderNavigationAccess { + const access = readerAccess.get(navigation) + if (!access) { + throw new Error('BibleReaderNavigation is not initialized') + } + return access +} + +/** + * Pending-request object a host can create and call before `` + * mounts. Pass one object to one Reader. One pending request; a newer call + * replaces an older one; the reader consumes it once. + * + * ```tsx + * const navigation = useMemo(() => { + * const readerNavigation = createBibleReaderNavigation() + * readerNavigation.request({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 }) + * return readerNavigation + * }, []) + * + * ``` + */ +export class BibleReaderNavigation { + #pending: BibleReaderNavigationRequest | null = null + #version = 0 + #pendingVersion = 0 + #listeners = new Set<() => void>() + + constructor() { + readerAccess.set(this, { + subscribe: (onStoreChange) => { + this.#listeners.add(onStoreChange) + return () => { + this.#listeners.delete(onStoreChange) + } + }, + getSnapshot: () => this.#version, + peekPending: () => this.#pending, + consumeCommitted: (version) => { + if (this.#pendingVersion !== version) { + return + } + this.#pending = null + }, + }) + } + + /** + * Load this version / book / chapter as a full chapter. Verse fields stay on + * the pending request for a later scroll/focus pass. + */ + request(reference: BibleReference): void { + this.#setPending({ + reference: copyReference(reference), + showsFullChapter: true, + scrollsToVerse: false, + shouldFocus: false, + }) + } + + /** + * Same chapter change as {@link BibleReaderNavigation.request} today. Does + * not dim or scroll. `scrollsToVerse` and `shouldFocus` are stored for a + * later release. + */ + focusReference(reference: BibleReference, scrollsToVerse = true): void { + this.#setPending({ + reference: copyReference(reference), + showsFullChapter: false, + scrollsToVerse, + shouldFocus: true, + }) + } + + #setPending(request: BibleReaderNavigationRequest): void { + this.#pending = request + this.#version += 1 + this.#pendingVersion = this.#version + for (const listener of this.#listeners) { + listener() + } + } +} + +export function createBibleReaderNavigation(): BibleReaderNavigation { + return new BibleReaderNavigation() +} + +/** + * Subscribe to a navigation object and consume at most one request per + * version bump. Peek during render so a discarded render cannot drop the + * jump; clear only after commit. A call before mount still lands on the + * first reader render. + */ +export function useConsumedNavigationRequest( + navigation: BibleReaderNavigation | undefined, +): BibleReaderNavigationRequest | null { + let subscribe: Subscribe = subscribeNoop + let getSnapshot = snapshotZero + if (navigation) { + const access = accessFor(navigation) + subscribe = access.subscribe + getSnapshot = access.getSnapshot + } + const version = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + const committedRef = useRef<{ + navigation: BibleReaderNavigation | undefined + version: number + }>({ navigation: undefined, version: 0 }) + + useLayoutEffect(() => { + committedRef.current = { navigation, version } + if (!navigation) { + return + } + accessFor(navigation).consumeCommitted(version) + }, [navigation, version]) + + if (!navigation) { + return null + } + const committedVersion = + committedRef.current.navigation === navigation ? committedRef.current.version : 0 + if (version === committedVersion) { + return null + } + return accessFor(navigation).peekPending() +} diff --git a/packages/ui/src/native/bible-reader.tsx b/packages/ui/src/native/bible-reader.tsx index c5d34332..fab6e0e4 100644 --- a/packages/ui/src/native/bible-reader.tsx +++ b/packages/ui/src/native/bible-reader.tsx @@ -21,6 +21,7 @@ import { useCallback, useEffect, useImperativeHandle, + useLayoutEffect, useMemo, useRef, useState, @@ -48,6 +49,7 @@ 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 { useConsumedNavigationRequest, type BibleReaderNavigation } from './bible-reader-navigation' import { BibleChapterPickerSheet } from './bible-chapter-picker-sheet' import { BibleReaderSettingsSheet } from './bible-reader-settings-sheet' import { BibleReaderToolbar } from './bible-reader-toolbar' @@ -195,6 +197,16 @@ export type BibleReaderProps = Omit< * as an ordinary prop, so there is no `forwardRef` here. */ ref?: Ref + /** + * Pending-request object from {@link BibleReaderNavigation}. Pass one object + * to one Reader. Call `request` or `focusReference` before or after mount. + * One pending request; a newer call replaces an older one; this reader + * consumes it once. + * + * Loads book / chapter / versionId only. Verse scroll and focus stay stored + * on the request for a later release. + */ + navigation?: BibleReaderNavigation } export function BibleReader({ @@ -227,6 +239,7 @@ export function BibleReader({ foregroundColor, dom, ref, + navigation, }: BibleReaderProps): ReactNode { const context = useYouVersion() const { lng } = useLocale() @@ -266,7 +279,11 @@ export function BibleReader({ prop: controlledBook, defaultProp: controlledBook !== undefined ? defaultBook : (storedBook ?? defaultBook), onChange: (newBook) => { - if (controlledBook === undefined && lastAppliedRef.current?.book !== newBook) { + if ( + controlledBook === undefined && + lastAppliedRef.current?.book !== newBook && + useReaderLocationStore.getState().book !== newBook + ) { setLocation({ book: newBook }) } void onBookChange?.(newBook) @@ -278,7 +295,11 @@ export function BibleReader({ defaultProp: controlledChapter !== undefined ? defaultChapter : (storedChapter ?? defaultChapter), onChange: (newChapter) => { - if (controlledChapter === undefined && lastAppliedRef.current?.chapter !== newChapter) { + if ( + controlledChapter === undefined && + lastAppliedRef.current?.chapter !== newChapter && + useReaderLocationStore.getState().chapter !== newChapter + ) { setLocation({ chapter: newChapter }) } void onChapterChange?.(newChapter) @@ -290,7 +311,11 @@ export function BibleReader({ defaultProp: controlledVersionId !== undefined ? defaultVersionId : (storedVersionId ?? defaultVersionId), onChange: (newVersionId) => { - if (controlledVersionId === undefined && lastAppliedRef.current?.versionId !== newVersionId) { + if ( + controlledVersionId === undefined && + lastAppliedRef.current?.versionId !== newVersionId && + useReaderLocationStore.getState().versionId !== newVersionId + ) { setLocation({ versionId: newVersionId }) } void onVersionChange?.(newVersionId) @@ -305,8 +330,88 @@ export function BibleReader({ lastAppliedRef.current = null }) - const resolvedVersionId = versionId ?? DEFAULT_BIBLE_VERSION_ID - const resolvedBook = book ?? DEFAULT_BOOK + const commitReaderLocationPatch = (next: LocationPatch) => { + lastAppliedRef.current = next + const storePatch: LocationPatch = {} + if (controlledBook === undefined && next.book !== undefined) { + storePatch.book = next.book + } + if (controlledChapter === undefined && next.chapter !== undefined) { + storePatch.chapter = next.chapter + } + if (controlledVersionId === undefined && next.versionId !== undefined) { + storePatch.versionId = next.versionId + } + if ( + storePatch.book !== undefined || + storePatch.chapter !== undefined || + storePatch.versionId !== undefined + ) { + setLocation(storePatch) + } + } + + const pendingNavigation = useConsumedNavigationRequest(navigation) + let appliedBook = book + let appliedChapter = chapter + let appliedVersionId = versionId + if (pendingNavigation) { + const { + bookId, + chapter: requestedChapterNumber, + versionId: requestedVersionId, + } = pendingNavigation.reference + const requestedBook = bookId + const requestedChapter = String(requestedChapterNumber) + if (requestedBook !== book && controlledBook === undefined) { + appliedBook = requestedBook + } + if (requestedChapter !== chapter && controlledChapter === undefined) { + appliedChapter = requestedChapter + } + if (requestedVersionId !== versionId && controlledVersionId === undefined) { + appliedVersionId = requestedVersionId + } + } + + // Overlay paints the jump on this render. Persist one location after commit + // so a discarded render cannot notify the host or save a mixed book/chapter. + useLayoutEffect(() => { + if (!pendingNavigation) { + return + } + const { + bookId, + chapter: requestedChapterNumber, + versionId: requestedVersionId, + } = pendingNavigation.reference + const requestedBook = bookId + const requestedChapter = String(requestedChapterNumber) + if ( + requestedBook === book && + requestedChapter === chapter && + requestedVersionId === versionId + ) { + return + } + commitReaderLocationPatch({ + book: requestedBook, + chapter: requestedChapter, + versionId: requestedVersionId, + }) + if (requestedBook !== book) { + setBook(requestedBook) + } + if (requestedChapter !== chapter) { + setChapter(requestedChapter) + } + if (requestedVersionId !== versionId) { + setVersionId(requestedVersionId) + } + }, [pendingNavigation, book, chapter, versionId, setBook, setChapter, setVersionId]) + + const resolvedVersionId = appliedVersionId ?? DEFAULT_BIBLE_VERSION_ID + const resolvedBook = appliedBook ?? DEFAULT_BOOK const showNativeToolbar = Platform.OS !== 'web' && showToolbar const { abbreviation: versionAbbreviation, @@ -325,7 +430,7 @@ export function BibleReader({ enabled: showNativeToolbar, }) const bookLabel = bookTitle ?? '' - const resolvedChapter = chapter ?? DEFAULT_CHAPTER + const resolvedChapter = appliedChapter ?? DEFAULT_CHAPTER // Intro chapters carry a non-numeric id ("INTRO"); show the catalog's title, not the id. const chapterLabel = chapterLabelForBook(bookEntry, resolvedChapter) const previousChapter = adjacentBookChapter( @@ -337,24 +442,7 @@ export function BibleReader({ const nextChapter = adjacentBookChapter(bookCatalog, resolvedBook, resolvedChapter, 'next') const applyReaderLocation = (next: LocationPatch) => { - lastAppliedRef.current = next - const storePatch: LocationPatch = {} - if (controlledBook === undefined && next.book !== undefined) { - storePatch.book = next.book - } - if (controlledChapter === undefined && next.chapter !== undefined) { - storePatch.chapter = next.chapter - } - if (controlledVersionId === undefined && next.versionId !== undefined) { - storePatch.versionId = next.versionId - } - if ( - storePatch.book !== undefined || - storePatch.chapter !== undefined || - storePatch.versionId !== undefined - ) { - setLocation(storePatch) - } + commitReaderLocationPatch(next) if (next.book !== undefined && next.book !== resolvedBook) { setBook(next.book) } @@ -366,7 +454,11 @@ export function BibleReader({ } } - const highlightPermissionFlow = useHighlightPermissionFlow({ versionId, book, chapter }) + const highlightPermissionFlow = useHighlightPermissionFlow({ + versionId: appliedVersionId, + book: appliedBook, + chapter: appliedChapter, + }) const { highlights, scope: highlightScope, @@ -421,7 +513,14 @@ export function BibleReader({ // 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 } + const currentScope: HighlightScope = useMemo( + () => ({ + versionId: appliedVersionId, + book: appliedBook, + chapter: appliedChapter, + }), + [appliedVersionId, appliedBook, appliedChapter], + ) let renderedPrompt = prompt if (prompt.kind === 'sign-in' && !sameScope(prompt.scope, currentScope)) { renderedPrompt = NO_PROMPT @@ -468,11 +567,11 @@ export function BibleReader({ // 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 + if (!sameScope(pending.scope, currentScope)) return void applyHighlight(pending.color, pending.verses).then((outcome) => reportHighlightWriteError(outcome, onHighlightError), ) - }, [applyHighlight, onHighlightError, versionId, book, chapter]) + }, [applyHighlight, onHighlightError, currentScope]) const handleSwatchPress = (swatch: VerseActionSwatch) => { const verses = verseSelection?.verses ?? [] @@ -498,10 +597,10 @@ export function BibleReader({ pendingIntentRef.current = { color: swatch.color, verses, - scope: { versionId, book, chapter }, + scope: currentScope, } if (authGate === 'signed-out') { - setPrompt({ kind: 'sign-in', scope: { versionId, book, chapter } }) + setPrompt({ kind: 'sign-in', scope: currentScope }) } return } @@ -539,7 +638,7 @@ export function BibleReader({ if (prompt.kind !== 'none') return const pending = pendingIntentRef.current if (pending === null) return - if (!sameScope(pending.scope, { versionId, book, chapter })) { + if (!sameScope(pending.scope, currentScope)) { pendingIntentRef.current = null return } @@ -555,7 +654,7 @@ export function BibleReader({ return _exhaustive } } - }, [authGate, prompt.kind, versionId, book, chapter, replayPendingIntent]) + }, [authGate, prompt.kind, currentScope, replayPendingIntent]) const handleOpenBibleThemeSettings = () => { setIsSettingsSheetOpen(true) @@ -759,9 +858,9 @@ export function BibleReader({ onSignOutPress={guardedSignOut} userInfo={userInfo} theme={resolvedTheme} - book={book} - chapter={chapter} - versionId={versionId} + book={appliedBook} + chapter={appliedChapter} + versionId={appliedVersionId} fontSize={fontSize} fontFamily={encodeFontFamilyForDom(fontFamily)} lineSpacing={lineSpacing} @@ -850,9 +949,9 @@ export function BibleReader({ setIsPickerOpen(false)} - book={book} - chapter={chapter} - versionId={versionId} + book={appliedBook} + chapter={appliedChapter} + versionId={appliedVersionId} theme={resolvedTheme} onSelect={async (data) => { applyReaderLocation({ @@ -867,7 +966,7 @@ export function BibleReader({ setIsVersionPickerOpen(false)} - versionId={versionId} + versionId={appliedVersionId} theme={resolvedTheme} onSelect={async (newVersionId) => { setVersionId(newVersionId) diff --git a/packages/ui/src/native/index.ts b/packages/ui/src/native/index.ts index 5e0dd402..dc37b3b1 100644 --- a/packages/ui/src/native/index.ts +++ b/packages/ui/src/native/index.ts @@ -10,6 +10,8 @@ export type { BibleReaderVerseSelection, HighlightWriteError, } from './bible-reader' +export { BibleReaderNavigation, createBibleReaderNavigation } from './bible-reader-navigation' +export type { BibleReaderNavigationRequest } from './bible-reader-navigation' export { BibleReaderSettingsSheet } from './bible-reader-settings-sheet' export type { BibleReaderSettingsSheetProps } from './bible-reader-settings-sheet' export { BibleTextView } from './bible-text-view'