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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ype-5745-reader-navigation.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <BibleReader navigation={navigation} defaultVersionId={3034} />
}
```

`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.
Expand Down
19 changes: 15 additions & 4 deletions packages/ui/src/__tests__/exports.test.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -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))
Expand All @@ -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))
Expand All @@ -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')
Expand All @@ -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(),
)
})
})
3 changes: 3 additions & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>
}

let latestReaderDomProps: LatestReaderDomProps = {}

function MockDOM(props: LatestReaderDomProps) {
latestReaderDomProps = props
return (
<View testID="mock-dom">
<Text testID="book">{props.book ?? 'none'}</Text>
<Text testID="chapter">{props.chapter ?? 'none'}</Text>
<Text testID="version-id">{String(props.versionId ?? 'none')}</Text>
<Pressable testID="trigger-chapter-change" onPress={() => props.onChapterChange?.('5')}>
<Text>Chapter</Text>
</Pressable>
</View>
)
}

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(<BibleReader navigation={navigation} />, { 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(<BibleReader navigation={navigation} />, { 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(<BibleReader navigation={navigation} />, { wrapper })

expect(getByTestId('book').props.children).toBe('PSA')
expect(getByTestId('chapter').props.children).toBe('23')

rerender(<BibleReader navigation={navigation} />)

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(
<BibleReader
navigation={navigation}
defaultBook="JHN"
defaultChapter="1"
defaultVersionId={111}
/>,
{ 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(
<BibleReader
navigation={navigation}
defaultBook="JHN"
defaultChapter="1"
defaultVersionId={111}
/>,
{ 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(
<BibleReader
navigation={navigation}
defaultBook="JHN"
defaultChapter="1"
defaultVersionId={111}
onBookChange={onBookChange}
onChapterChange={onChapterChange}
onVersionChange={onVersionChange}
/>,
{ 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(<BibleReader navigation={navigation} />, { 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')
})
})
Loading
Loading