diff --git a/.changeset/ype-5766-search-ui.md b/.changeset/ype-5766-search-ui.md new file mode 100644 index 00000000..1709dcbe --- /dev/null +++ b/.changeset/ype-5766-search-ui.md @@ -0,0 +1,23 @@ +--- +"@youversion/platform-core": minor +"@youversion/platform-react-hooks": minor +"@youversion/platform-react-ui": minor +--- + +Add Bible Reader search: one USFM grammar, an exclusive-lane `useBibleSearch` session, and a Toolbar-mounted `BibleReaderSearch` that navigates with transient verse focus. + +Expose `BibleReaderNavigation` for host requests before reader mount, passage or full-chapter display, and optional in-place focus. Search supports grapheme-safe input, deduplicated results, verse previews, automatic pagination, and accessible dismissal and errors. + +Align search with the reader designs in a toolbar-anchored popover. Show three trending queries and the latest three browser-local recent searches, and preserve query entry and recents when suggestions fail. Load verse previews behind one spinner, keeping earlier results visible during pagination. Result selection moves keyboard focus to the verse; dismissing search with Close or Escape restores the trigger. + +Add an async-compatible `BibleReader.Root.onSearchPress` override for host-owned search. Match toolbar popover motion and native scrollbar themes. Correct verse mapping across poetry lines so focus dims the complete surrounding verses without adding a highlight, and retain focus until user interaction. + +Add Old Testament, New Testament, and Both search filters using the selected Bible's book metadata. Continue through nonmatching result pages before reporting no results. Display sentence-case references above three-line serif previews with inline verse numbers. Back clears the search and filters without closing the popover; show only one X control at a time. + +Give search rows inset hover surfaces without shifting content, align initial search and preview spinners, and reveal loaded previews with a short reduced-motion-aware transition. Reuse existing localized empty-state copy and clear stale failures when changing testament filters. + +Start testament controls collapsed behind an accessible Filters disclosure. Preserve and indicate active filtering while collapsed, and reset to Both and collapsed on Back or clear. Expand and collapse the controls with an interruptible accordion transition, immediate keyboard toggles, and fade-only reduced motion. + +Keep core imports usable without Intl.Segmenter, falling back to Unicode code-point limits for search on those runtimes. Preserve the generic useDebounce delay for null values while suppressing suggestions in submitted searches. Allow Enter to resubmit failed or empty searches without replaying cached pages or duplicating an in-flight request. + +Apply queued reader destinations before fetching passage content, and wait for controlled props to accept pending navigation. Clear activated navigation when the host leaves its destination so returning does not restore stale passage selection or focus. Omit chapter-only search hits while preserving verse ranges and continuation loading. diff --git a/.github/scripts/major-release-signoff.test.sh b/.github/scripts/major-release-signoff.test.sh index b4afb442..7004c7d3 100644 --- a/.github/scripts/major-release-signoff.test.sh +++ b/.github/scripts/major-release-signoff.test.sh @@ -268,6 +268,49 @@ run_decision_case "ordinary major previews still require signoff" \ run_decision_case "failed ordinary previews remain blocked, not major" \ 'blocked=release preview did not succeed (failure)' false false skipped '' failure '' +extract_step "Restore release tooling from the base branch" > "$TMP/restore.sh" +PREVIEW_REPO="$TMP/preview-repo" +git init --quiet --initial-branch=main "$PREVIEW_REPO" +git -C "$PREVIEW_REPO" config commit.gpgsign false +git -C "$PREVIEW_REPO" config user.name test +git -C "$PREVIEW_REPO" config user.email test@example.com +mkdir -p "$PREVIEW_REPO/packages/ui" "$PREVIEW_REPO/packages/core" \ + "$PREVIEW_REPO/scripts" "$PREVIEW_REPO/.changeset" +printf '{"name":"fixture","private":true}\n' > "$PREVIEW_REPO/package.json" +printf '{"name":"@fixture/ui","version":"1.0.0"}\n' > "$PREVIEW_REPO/packages/ui/package.json" +printf '{"name":"@fixture/core","version":"1.0.0"}\n' > "$PREVIEW_REPO/packages/core/package.json" +printf 'packages:\n - "packages/*"\n' > "$PREVIEW_REPO/pnpm-workspace.yaml" +printf '// base-owned detector\n' > "$PREVIEW_REPO/scripts/preview-release.mjs" +printf '{}\n' > "$PREVIEW_REPO/.changeset/config.json" +pnpm --dir "$PREVIEW_REPO" install --lockfile-only --ignore-scripts --offline > "$TMP/preview-install.log" 2>&1 +git -C "$PREVIEW_REPO" add -A +git -C "$PREVIEW_REPO" commit --quiet -m base +git -C "$PREVIEW_REPO" switch --quiet -c feature +git -C "$PREVIEW_REPO" remote add origin "$PREVIEW_REPO" +# A changed dependency, a new workspace, and a deleted workspace must not influence +# the base-owned install. The PR's major changeset must still reach the detector. +printf '{"name":"@fixture/ui","version":"1.0.0","dependencies":{"zod":"4.1.12"}}\n' \ + > "$PREVIEW_REPO/packages/ui/package.json" +mkdir "$PREVIEW_REPO/packages/new" +printf '{"name":"@fixture/new","version":"1.0.0"}\n' > "$PREVIEW_REPO/packages/new/package.json" +rm "$PREVIEW_REPO/packages/core/package.json" +printf '%s\n' '---' '"@fixture/ui": major' '---' 'PR release data' \ + > "$PREVIEW_REPO/.changeset/pr-change.md" +git -C "$PREVIEW_REPO" add -A +git -C "$PREVIEW_REPO" commit --quiet -m feature + +if (cd "$PREVIEW_REPO" && bash "$TMP/restore.sh" && \ + pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile --offline && \ + git diff --exit-code main -- package.json pnpm-lock.yaml ':(glob)**/package.json' && \ + test ! -e packages/new/package.json && \ + git diff --exit-code HEAD -- .changeset/pr-change.md && \ + grep -Fq '"@fixture/ui": major' .changeset/pr-change.md) > "$TMP/restore.log" 2>&1; then + pass "base tooling installs despite PR workspace dependency changes and preserves release data" +else + fail "base tooling installs despite PR workspace dependency changes and preserves release data" \ + "$(cat "$TMP/restore.log")" +fi + if pnpm exec prettier --check "$WORKFLOW" >/dev/null; then pass "workflow YAML parses and is formatted" else diff --git a/.github/workflows/major-release-signoff.yml b/.github/workflows/major-release-signoff.yml index 9a451ff9..3594c7cb 100644 --- a/.github/workflows/major-release-signoff.yml +++ b/.github/workflows/major-release-signoff.yml @@ -217,6 +217,10 @@ jobs: pnpm-workspace.yaml .changeset/config.json; do git cat-file -e "main:$f" 2>/dev/null && git checkout main -- "$f" done + # Frozen installs validate every workspace manifest against the base lockfile. + # Remove PR-only manifests and restore base manifests, including PR deletions. + # Keep PR changesets as data; never resolve PR-owned dependencies for tooling. + git restore --source=main --staged --worktree -- ':(glob)**/package.json' # Install hooks a PR could add to rewrite what we just restored. Take main's, or # remove it outright: leaving a PR-authored one hands back the control. for f in .npmrc .pnpmfile.cjs; do diff --git a/.size-limit.js b/.size-limit.js index ac831e5f..bc82651c 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -3,7 +3,7 @@ export default [ { name: 'core / full barrel (esm)', path: 'packages/core/dist/index.js', - limit: '21 KB', + limit: '21.5 KB', ignore: ['jsdom'], }, { @@ -28,7 +28,7 @@ export default [ { name: 'hooks / full barrel', path: 'packages/hooks/dist/index.js', - limit: '26 KB', + limit: '27 KB', ignore: ['react', 'react-dom', '@tanstack/react-query'], }, { @@ -61,6 +61,6 @@ export default [ { name: 'ui / tailwind.css', path: 'packages/ui/dist/tailwind.css', - limit: '9 KB', + limit: '9.5 KB', }, ]; diff --git a/packages/core/src/__tests__/search-verses.test.ts b/packages/core/src/__tests__/search-verses.test.ts index 50135062..c78d805a 100644 --- a/packages/core/src/__tests__/search-verses.test.ts +++ b/packages/core/src/__tests__/search-verses.test.ts @@ -2,7 +2,13 @@ import { describe, it, expect, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { ApiClient } from '../client'; import { SearchClient } from '../search'; -import { isValidStructuralUsfmReference, parseSearchLanguageRange } from '../schemas/search'; +import { + MAX_SEARCH_QUERY_GRAPHEMES, + SearchTextQuerySchema, + clampSearchText, + isValidStructuralUsfmReference, + parseSearchLanguageRange, +} from '../schemas/search'; import { server } from './setup'; const apiHost = process.env.YVP_API_HOST; @@ -33,9 +39,15 @@ describe('SearchClient.searchVerses', () => { expect(isValidStructuralUsfmReference('JHN.0.1')).toBe(false); expect(isValidStructuralUsfmReference('JHN.1.0')).toBe(false); expect(isValidStructuralUsfmReference('JHN.1.1-0')).toBe(false); + expect(isValidStructuralUsfmReference('JHN.1.5-3')).toBe(false); expect(isValidStructuralUsfmReference('')).toBe(false); }); + it('accepts chapter-only and inclusive verse ranges', () => { + expect(isValidStructuralUsfmReference('JHN.6')).toBe(true); + expect(isValidStructuralUsfmReference('JHN.6.9-11')).toBe(true); + }); + it('accepts valid language ranges and normalizes case and underscores', () => { expect(parseSearchLanguageRange('EN')).toBe('en'); expect(parseSearchLanguageRange('en')).toBe('en'); @@ -49,6 +61,38 @@ describe('SearchClient.searchVerses', () => { expect(() => parseSearchLanguageRange('en-abcdefghi')).toThrow(/Language range must/); }); + it('validates and clamps search text by grapheme clusters', () => { + const combiningCluster = 'e\u0301'; + const combiningBoundary = `${'a'.repeat(MAX_SEARCH_QUERY_GRAPHEMES - 1)}${combiningCluster}`; + const surrogateBoundary = `${'a'.repeat(MAX_SEARCH_QUERY_GRAPHEMES - 1)}😀`; + + expect(SearchTextQuerySchema.parse(combiningBoundary)).toBe(combiningBoundary); + expect(SearchTextQuerySchema.parse(surrogateBoundary)).toBe(surrogateBoundary); + expect(() => SearchTextQuerySchema.parse(`${combiningBoundary}z`)).toThrow(); + expect(() => SearchTextQuerySchema.parse(`${surrogateBoundary}z`)).toThrow(); + expect(clampSearchText(`${combiningBoundary}z`)).toBe(combiningBoundary); + expect(clampSearchText(`${surrogateBoundary}z`)).toBe(surrogateBoundary); + }); + + it('imports core without Intl.Segmenter and clamps fallback input without splitting surrogate pairs', async () => { + const nativeSegmenter = Intl.Segmenter; + vi.resetModules(); + Object.defineProperty(Intl, 'Segmenter', { configurable: true, value: undefined }); + try { + const core = await import('../index'); + expect(core.ApiClient).toBeTypeOf('function'); + const boundary = `${'a'.repeat(99)}😀`; + expect(core.clampSearchText(`${boundary}z`)).toBe(boundary); + expect(core.clampSearchText('short')).toBe('short'); + const { SearchTextQuerySchema: fallbackSchema } = await import('../schemas/search'); + expect(fallbackSchema.parse(boundary)).toBe(boundary); + expect(() => fallbackSchema.parse(`${boundary}z`)).toThrow(/Query must/); + } finally { + Object.defineProperty(Intl, 'Segmenter', { configurable: true, value: nativeSegmenter }); + vi.resetModules(); + } + }); + it('maps wire reference to SDK id and metadata fields', async () => { const searchClient = createSearchClient(); diff --git a/packages/core/src/__tests__/usfm-reference.test.ts b/packages/core/src/__tests__/usfm-reference.test.ts new file mode 100644 index 00000000..565913e5 --- /dev/null +++ b/packages/core/src/__tests__/usfm-reference.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { parseUsfmReference } from '../usfm-reference'; + +describe('parseUsfmReference', () => { + it.each([ + ['JHN.6', { book: 'JHN', chapter: '6', verses: [] }], + ['JHN.6.9', { book: 'JHN', chapter: '6', verses: [9] }], + ['JHN.6.9-11', { book: 'JHN', chapter: '6', verses: [9, 10, 11] }], + ['JHN.6.9-9', { book: 'JHN', chapter: '6', verses: [9] }], + ['1JN.1.1', { book: '1JN', chapter: '1', verses: [1] }], + ['ZZZ.1.1', { book: 'ZZZ', chapter: '1', verses: [1] }], + ['REV.22.20-21', { book: 'REV', chapter: '22', verses: [20, 21] }], + ] as const)('parses %s', (usfm, expected) => { + expect(parseUsfmReference(usfm)).toEqual(expected); + }); + + it.each([ + '', + 'JHN', + 'jhn.6.9', + 'JOHN.6.9', + 'JHN.0.1', + 'JHN.1.0', + 'JHN.1.1-0', + 'JHN.1.5-3', + 'JHN.6.9-8', + 'JHN.6.9-', + 'JHN..9', + 'MAT.1.1-2-3', + 'JHN.6.9007199254740992', + 'JHN.6.1-100000', + ])('rejects %s', (usfm) => { + expect(parseUsfmReference(usfm)).toBeNull(); + }); + + it('rejects unsafe integer verse ranges without hanging', () => { + expect(parseUsfmReference('JHN.6.9007199254740992-9007199254740993')).toBeNull(); + expect(parseUsfmReference('JHN.6.9007199254740991-9007199254740992')).toBeNull(); + }, 1000); + + it('parses a full Psalm 119 range', () => { + const parsed = parseUsfmReference('PSA.119.1-176'); + expect(parsed?.book).toBe('PSA'); + expect(parsed?.chapter).toBe('119'); + expect(parsed?.verses[0]).toBe(1); + expect(parsed?.verses[175]).toBe(176); + expect(parsed?.verses).toHaveLength(176); + }); +}); diff --git a/packages/core/src/bible-html-transformer.test.ts b/packages/core/src/bible-html-transformer.test.ts index 2505c7e0..74e44431 100644 --- a/packages/core/src/bible-html-transformer.test.ts +++ b/packages/core/src/bible-html-transformer.test.ts @@ -84,6 +84,36 @@ describe('transformBibleHtml - intro chapter footnotes', () => { }); describe('transformBibleHtml - verse wrapping', () => { + it('assigns poetry continuation lines to their verse without swallowing headings or the next verse', () => { + const html = `
+
1A Psalm of David.
+
The Lord is my shepherd;
+
I shall not want.
+
2He makes me lie down
+
beside quiet waters.
+
A heading
+
3He restores my soul.
+
He guides me.
+
`; + const result = transformBibleHtml(html, createAdapters()); + const doc = new DOMParser().parseFromString(result.html, 'text/html'); + expect( + [...doc.querySelectorAll('.yv-v[v]')].map((node) => [ + node.getAttribute('v'), + node.textContent?.trim(), + ]), + ).toEqual([ + ['1', '1\u00a0A Psalm of David.'], + ['1', 'The Lord is my shepherd;'], + ['1', 'I shall not want.'], + ['2', '2\u00a0He makes me lie down'], + ['2', 'beside quiet waters.'], + ['3', '3\u00a0He restores my soul.'], + ['3', 'He guides me.'], + ]); + expect(doc.querySelector('.s1')?.closest('.yv-v')).toBeNull(); + }); + it('should wrap verse content in .yv-v[v] elements', () => { const html = `
diff --git a/packages/core/src/bible-html-transformer.ts b/packages/core/src/bible-html-transformer.ts index d8bc50ab..97cb1b78 100644 --- a/packages/core/src/bible-html-transformer.ts +++ b/packages/core/src/bible-html-transformer.ts @@ -122,6 +122,8 @@ export type TransformedBibleHtml = { }; function wrapVerseContent(doc: Document): void { + const verseBlockSelector = + '.p, p, .d, .q, .q1, .q2, .q3, .q4, .qm, .qm1, .qm2, .qm3, .qm4, .qr, .qc, .m, .mi, .pi, .pi1, .pi2, .pi3, .pi4, .pc, .pr, .pm, .pmo, .pmc, .pmr, .li, .li1, .li2, .li3, .li4'; function wrapParagraphContent(doc: Document, paragraph: Element, verseNum: string): void { const children = Array.from(paragraph.childNodes); if (children.length === 0) return; @@ -162,7 +164,7 @@ function wrapVerseContent(doc: Document): void { if (currentParagraph.querySelector('.yv-v[v]')) break; - if (currentParagraph.classList.contains('p') || currentParagraph.tagName === 'P') { + if (currentParagraph.matches(verseBlockSelector)) { wrapParagraphContent(doc, currentParagraph, verseNum); } @@ -197,8 +199,8 @@ function wrapVerseContent(doc: Document): void { const nodesToWrap = collectNodesBetweenMarkers(marker, nextMarker); if (nodesToWrap.length === 0) return; - const currentParagraph = marker.closest('.p, p, div.p'); - const nextParagraph = nextMarker?.closest('.p, p, div.p') || null; + const currentParagraph = marker.closest(verseBlockSelector); + const nextParagraph = nextMarker?.closest(verseBlockSelector) || null; const doc = marker.ownerDocument; wrapNodesInVerse(marker, verseNum, nodesToWrap); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0d82dbf5..9f1ffa90 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,13 +26,16 @@ export { LanguagesClient } from './languages'; export { getLanguage } from './languages-language'; export { getLanguages, type GetLanguagesOptions } from './languages-list'; export { SearchClient, type SearchVersesOptions } from './search'; -export { - getSuggestedQueries, - getTrendingQueries, -} from './search-queries'; +export { getSuggestedQueries, getTrendingQueries } from './search-queries'; export { searchVerses } from './search-verses'; export { searchTopics } from './search-topics'; -export { isValidStructuralUsfmReference } from './schemas/search'; +export { + MAX_SEARCH_QUERY_GRAPHEMES, + clampSearchText, + isValidStructuralUsfmReference, + parseSearchLanguageRange, +} from './schemas/search'; +export { parseUsfmReference, type UsfmReference } from './usfm-reference'; export { OrganizationsClient } from './organizations'; export { HighlightsClient, diff --git a/packages/core/src/schemas/search.ts b/packages/core/src/schemas/search.ts index e8b8b767..8edcca4e 100644 --- a/packages/core/src/schemas/search.ts +++ b/packages/core/src/schemas/search.ts @@ -1,16 +1,33 @@ import * as z from 'zod/mini'; +import { parseUsfmReference } from '../usfm-reference'; + +export const MAX_SEARCH_QUERY_GRAPHEMES = 100; + +let searchTextSegmenter: Intl.Segmenter | undefined; + +/** Truncates to 100 graphemes, or Unicode code points when Intl.Segmenter is unavailable. */ +export function clampSearchText(raw: string): string { + if (Intl.Segmenter === undefined) { + return Array.from(raw).slice(0, MAX_SEARCH_QUERY_GRAPHEMES).join(''); + } + searchTextSegmenter ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + const segments = searchTextSegmenter.segment(raw)[Symbol.iterator](); + let end = 0; + for (let count = 0; count < MAX_SEARCH_QUERY_GRAPHEMES; count += 1) { + const next = segments.next(); + if (next.done) { + return raw; + } + end = next.value.index + next.value.segment.length; + } + return raw.slice(0, end); +} /** Known Platform Search user-intent values. Unknown wire strings remain valid. */ export const KNOWN_SEARCH_USER_INTENTS = ['unknown', 'topical', 'text', 'reference'] as const; export type KnownSearchUserIntent = (typeof KNOWN_SEARCH_USER_INTENTS)[number]; -/** - * Dot-separated USFM passage id for search hits (`BOOK.CHAPTER[.VERSE[-VERSE]]`). - * Structural only: book codes are not checked against {@link BOOK_IDS}. - */ -const USFM_REFERENCE_PATTERN = /^([A-Z0-9]{1,3})\.(\d+)(?:\.(\d+)(?:-(\d+))?)?$/; - /** * Structural USFM reference check for search verse hits. * Rejects malformed references and non-positive chapter/verse numbers. @@ -22,31 +39,7 @@ const USFM_REFERENCE_PATTERN = /^([A-Z0-9]{1,3})\.(\d+)(?:\.(\d+)(?:-(\d+))?)?$/ * (for example `ZZZ.1.1`) so bad hits drop without throwing while good hits stay. */ export function isValidStructuralUsfmReference(usfm: string): boolean { - const match = USFM_REFERENCE_PATTERN.exec(usfm); - if (!match) { - return false; - } - - const chapter = Number(match[2]); - if (!Number.isInteger(chapter) || chapter <= 0) { - return false; - } - - if (match[3] !== undefined) { - const verse = Number(match[3]); - if (!Number.isInteger(verse) || verse <= 0) { - return false; - } - } - - if (match[4] !== undefined) { - const verseEnd = Number(match[4]); - if (!Number.isInteger(verseEnd) || verseEnd <= 0) { - return false; - } - } - - return true; + return parseUsfmReference(usfm) !== null; } /** Normalizes caller language ranges for search endpoints (`en_US` → `en-us`). */ @@ -246,14 +239,15 @@ export function toSearchTopicsResponse(wire: SearchTopicsWire): SearchTopicsResp }; } -/** Input validation for verse/topic search queries (1–100 characters). */ -export const SearchTextQuerySchema = z - .string() - .check( - z.trim(), - z.minLength(1, 'Query must be between 1 and 100 characters'), - z.maxLength(100, 'Query must be between 1 and 100 characters'), - ); +/** Input validation for verse/topic search queries (1–100 grapheme clusters). */ +export const SearchTextQuerySchema = z.string().check( + z.trim(), + z.minLength(1, 'Query must be between 1 and 100 characters'), + z.refine( + (value) => clampSearchText(value) === value, + 'Query must be between 1 and 100 characters', + ), +); /** Input validation for suggested-query partial text (non-empty). */ export const SuggestedSearchQuerySchema = z diff --git a/packages/core/src/styles/bible-reader.css b/packages/core/src/styles/bible-reader.css index a2ad5092..549dbad7 100644 --- a/packages/core/src/styles/bible-reader.css +++ b/packages/core/src/styles/bible-reader.css @@ -174,6 +174,12 @@ text-underline-offset: 0.33em; } + /* Search-driven transient focus. Dim the rest of the chapter. */ + &[data-yv-verse-focus] .yv-v:not(.yv-v-focused), + &[data-yv-verse-focus] .yv-h { + opacity: 0.35; + } + /* Demo highlighted verse styling 20% opacity of expressive yellow 30 */ & .yv-v.yv-v-highlighted { background-color: rgba(241, 156, 51, 0.2); diff --git a/packages/core/src/styles/theme.css b/packages/core/src/styles/theme.css index 8fd31212..d3a668d3 100644 --- a/packages/core/src/styles/theme.css +++ b/packages/core/src/styles/theme.css @@ -3,6 +3,7 @@ @import './preflight.css'; [data-yv-sdk] { + color-scheme: light; /* Brand colors */ --yv-red: oklch(from #ff3d4d l c h); --yv-red-dark-mode: oklch(from #f04c59 l c h); @@ -123,6 +124,7 @@ --yv-highlight-mix-p: 1; &[data-yv-theme='dark'] { + color-scheme: dark; /* Brand colors - Dark theme */ --yv-background: var(--yv-gray-50); --yv-foreground: var(--yv-white); diff --git a/packages/core/src/usfm-reference.ts b/packages/core/src/usfm-reference.ts new file mode 100644 index 00000000..2bab8779 --- /dev/null +++ b/packages/core/src/usfm-reference.ts @@ -0,0 +1,72 @@ +export type UsfmReference = Readonly<{ + book: string; + chapter: string; + verses: readonly number[]; +}>; + +const USFM_REFERENCE_PATTERN = /^([A-Z0-9]{1,3})\.(\d+)(?:\.(\d+)(?:-(\d+))?)?$/; + +/** + * Longest Bible chapter is Psalm 119 (176 verses). Anything longer is + * malformed and is rejected rather than expanded. + */ +const MAX_RANGE_LENGTH = 250; + +function isPositiveSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} + +/** + * Parses `JHN.6`, `JHN.6.9`, and `JHN.6.9-11`. Returns `null` for anything + * malformed, non-positive, not a safe integer, with `verseEnd < verseStart`, + * or a verse span longer than 250. Does not check book codes against + * `BOOK_IDS`. + * + * Sole owner of this grammar. `isValidStructuralUsfmReference` is + * `parseUsfmReference(usfm) !== null`. + */ +export function parseUsfmReference(usfm: string): UsfmReference | null { + const match = USFM_REFERENCE_PATTERN.exec(usfm); + if (!match) { + return null; + } + + const book = match[1]; + const chapter = match[2]; + if (book === undefined || chapter === undefined) { + return null; + } + + const chapterNumber = Number(chapter); + if (!isPositiveSafeInteger(chapterNumber)) { + return null; + } + + if (match[3] === undefined) { + return { book, chapter, verses: [] }; + } + + const verseStart = Number(match[3]); + if (!isPositiveSafeInteger(verseStart)) { + return null; + } + + if (match[4] === undefined) { + return { book, chapter, verses: [verseStart] }; + } + + const verseEnd = Number(match[4]); + if (!isPositiveSafeInteger(verseEnd) || verseEnd < verseStart) { + return null; + } + + if (verseEnd - verseStart + 1 > MAX_RANGE_LENGTH) { + return null; + } + + const verses: number[] = []; + for (let verse = verseStart; verse <= verseEnd; verse += 1) { + verses.push(verse); + } + return { book, chapter, verses }; +} diff --git a/packages/hooks/src/context/YouVersionContext.tsx b/packages/hooks/src/context/YouVersionContext.tsx index 477543cd..e9e5f667 100644 --- a/packages/hooks/src/context/YouVersionContext.tsx +++ b/packages/hooks/src/context/YouVersionContext.tsx @@ -1,7 +1,12 @@ 'use client'; import { createContext } from 'react'; -import type { BibleClient, LanguagesClient, OrganizationsClient } from '@youversion/platform-core'; +import type { + BibleClient, + LanguagesClient, + OrganizationsClient, + SearchClient, +} from '@youversion/platform-core'; import type { HookOverrides } from '../hook-overrides'; export type { HookOverrides }; @@ -20,6 +25,8 @@ export type YouVersionContextData = { languagesClient?: LanguagesClient; /** Test seam: skip constructing a live OrganizationsClient. */ organizationsClient?: OrganizationsClient; + /** Test seam: skip constructing a live SearchClient. */ + searchClient?: SearchClient; /** Test seam: return stub hook results without fetching. */ hookOverrides?: HookOverrides; }; diff --git a/packages/hooks/src/hook-overrides.ts b/packages/hooks/src/hook-overrides.ts index bbd6faa5..9323bea0 100644 --- a/packages/hooks/src/hook-overrides.ts +++ b/packages/hooks/src/hook-overrides.ts @@ -10,6 +10,7 @@ import type { UseLanguageResult } from './useLanguage'; import type { UseLanguagesResult } from './useLanguages'; import type { UseOrganizationsResult } from './useOrganizations'; import type { UsePassageProps, UsePassageResult } from './usePassage'; +import type { UseBibleSearchProps, UseBibleSearchResult } from './useBibleSearch'; import type { UseVersionResult } from './useVersion'; import type { UseVersionsOptions, UseVersionsResult } from './useVersions'; import type { UseVerseOfTheDayResult } from './useVOTD'; @@ -39,6 +40,7 @@ export type HookOverrides = { ) => UseLanguagesResult; useOrganizations?: (organizationIds: (string | null | undefined)[]) => UseOrganizationsResult; usePassage?: (props: UsePassageProps) => UsePassageResult; + useBibleSearch?: (props: UseBibleSearchProps) => UseBibleSearchResult; useTheme?: () => 'light' | 'dark'; useVersion?: (versionId: number, options?: UseApiDataOptions) => UseVersionResult; useVersions?: ( diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 0d44d571..8e623aad 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -14,6 +14,8 @@ export * from './useOrganizationsClient'; export * from './context'; export * from './utility'; export * from './useBibleClient'; +export * from './useSearchClient'; +export * from './useBibleSearch'; export * from './usePassage'; export * from './useVOTD'; export * from './useHighlights'; diff --git a/packages/hooks/src/internal/bible-search-session.test.ts b/packages/hooks/src/internal/bible-search-session.test.ts new file mode 100644 index 00000000..46fe4e03 --- /dev/null +++ b/packages/hooks/src/internal/bible-search-session.test.ts @@ -0,0 +1,486 @@ +import { describe, expect, it } from 'vitest'; +import type { SearchQuery, SearchVersesResponse } from '@youversion/platform-core'; +import { + EMPTY_QUERY, + MAX_SEARCH_QUERY_LENGTH, + clampSearchInput, + createSearchSession, + deriveSearchPhase, + normalizeQuery, + projectVerses, + queriesRequest, + searchSessionReducer, + versesRequest, + type NormalizedQuery, + type SearchSession, + type VersesRequest, +} from './bible-search-session'; + +const LOVE = normalizeQuery('love'); +const GRACE = normalizeQuery('grace'); + +const trendingQueries: readonly SearchQuery[] = [{ text: 'love' }, { text: 'hope' }]; +const suggestQueries: readonly SearchQuery[] = [{ text: 'love of God' }]; + +function versesResponse( + ids: readonly string[], + nextPageToken: string | null = null, +): SearchVersesResponse { + return { + verses: ids.map((id) => ({ id })), + didYouMean: [], + searchInsteadFor: null, + nextPageToken, + }; +} + +function firstPageRequest(query: NormalizedQuery, versionId = 111): VersesRequest { + return { query, versionId, pageToken: null }; +} + +function browsing(raw: string, versionId = 111): SearchSession { + return searchSessionReducer(createSearchSession(versionId), { type: 'setQuery', raw }); +} + +function submitted(raw: string, versionId = 111): SearchSession { + return searchSessionReducer(browsing(raw, versionId), { type: 'submit' }); +} + +function withPages( + session: SearchSession, + ids: readonly string[], + nextPageToken: string | null = 'page-2', +): SearchSession { + const request = versesRequest(session); + if (request === null) { + throw new Error('expected a verses request'); + } + return searchSessionReducer(session, { + type: 'commitPage', + request, + response: versesResponse(ids, nextPageToken), + }); +} + +describe('clampSearchInput', () => { + it('leaves short input unchanged, including trailing spaces', () => { + expect(clampSearchInput('love ')).toBe('love '); + }); + + it('clamps to 100 graphemes without trimming or splitting clusters', () => { + const raw = `${'a'.repeat(MAX_SEARCH_QUERY_LENGTH)}bcd`; + expect(clampSearchInput(raw)).toBe('a'.repeat(MAX_SEARCH_QUERY_LENGTH)); + + const combiningCluster = 'e\u0301'; + expect(clampSearchInput(`${'a'.repeat(99)}${combiningCluster}z`)).toBe( + `${'a'.repeat(99)}${combiningCluster}`, + ); + expect(clampSearchInput(`${'a'.repeat(99)}😀z`)).toBe(`${'a'.repeat(99)}😀`); + }); +}); + +describe('normalizeQuery', () => { + it('trims and brands a non-empty query', () => { + expect(normalizeQuery(' love ')).toBe('love'); + }); + + it('maps whitespace-only input to the empty query', () => { + expect(normalizeQuery(' ')).toBe(EMPTY_QUERY); + }); +}); + +describe('searchSessionReducer', () => { + it('starts browsing with an empty query', () => { + expect(createSearchSession(111)).toEqual({ + versionId: 111, + raw: '', + normalized: EMPTY_QUERY, + lane: { kind: 'browsing' }, + }); + }); + + it('setQuery keeps the submitted lane when only trailing space changes', () => { + const withResults = withPages(submitted('love'), ['JHN.3.16']); + const next = searchSessionReducer(withResults, { type: 'setQuery', raw: 'love ' }); + expect(next.raw).toBe('love '); + expect(next.normalized).toBe(LOVE); + expect(next.lane).toEqual(withResults.lane); + }); + + it('setQuery drops to browsing when the normalized query changes', () => { + const withResults = withPages(submitted('love'), ['JHN.3.16']); + expect(searchSessionReducer(withResults, { type: 'setQuery', raw: 'grace' })).toEqual({ + versionId: 111, + raw: 'grace', + normalized: GRACE, + lane: { kind: 'browsing' }, + }); + }); + + it('setQuery returns the same object when raw is unchanged', () => { + const state = browsing('love'); + expect(searchSessionReducer(state, { type: 'setQuery', raw: 'love' })).toBe(state); + }); + + it('submit on empty input lands in browsing with a cleared raw field', () => { + const spaces = browsing(' '); + expect(searchSessionReducer(spaces, { type: 'submit' })).toEqual({ + versionId: 111, + raw: '', + normalized: EMPTY_QUERY, + lane: { kind: 'browsing' }, + }); + }); + + it('submit starts a submitted lane with no pages', () => { + expect(submitted('love')).toEqual({ + versionId: 111, + raw: 'love', + normalized: LOVE, + lane: { kind: 'submitted', query: LOVE, pages: [], wantsMore: false }, + }); + }); + + it('resubmission clears pages and refreshes whitespace-only suggestion text', () => { + const state = withPages(submitted('love'), ['JHN.3.16']); + expect(searchSessionReducer(state, { type: 'submit' })).toEqual({ + versionId: 111, + raw: 'love', + normalized: 'love', + lane: { kind: 'submitted', query: 'love', pages: [], wantsMore: false }, + }); + expect(searchSessionReducer(state, { type: 'selectSuggestion', text: ' love ' })).toMatchObject( + { raw: ' love ', normalized: 'love', lane: { pages: [] } }, + ); + }); + + it('selectSuggestion fills the input and submits in one transition', () => { + const state = browsing('lo'); + expect(searchSessionReducer(state, { type: 'selectSuggestion', text: 'love of God' })).toEqual({ + versionId: 111, + raw: 'love of God', + normalized: 'love of God', + lane: { + kind: 'submitted', + query: 'love of God', + pages: [], + wantsMore: false, + }, + }); + }); + + it('loadMore is a no-op until a page with a next token exists', () => { + const empty = submitted('love'); + expect(searchSessionReducer(empty, { type: 'loadMore' })).toBe(empty); + const lastPage = withPages(submitted('love'), ['JHN.3.16'], null); + expect(searchSessionReducer(lastPage, { type: 'loadMore' })).toBe(lastPage); + }); + + it('loadMore sets wantsMore when the last page has a next token', () => { + const paged = withPages(submitted('love'), ['JHN.3.16'], 'page-2'); + const next = searchSessionReducer(paged, { type: 'loadMore' }); + expect(next.lane).toEqual({ + kind: 'submitted', + query: LOVE, + pages: [ + { + token: null, + hits: [{ id: 'JHN.3.16' }], + nextToken: 'page-2', + }, + ], + wantsMore: true, + }); + expect(searchSessionReducer(next, { type: 'loadMore' })).toBe(next); + }); + + it('setVersion returns identity for the same id', () => { + const state = submitted('love'); + expect(searchSessionReducer(state, { type: 'setVersion', versionId: 111 })).toBe(state); + }); + + it('setVersion re-runs a submitted search under the new version', () => { + const withResults = withPages(submitted('love'), ['JHN.3.16']); + expect(searchSessionReducer(withResults, { type: 'setVersion', versionId: 222 })).toEqual({ + versionId: 222, + raw: 'love', + normalized: LOVE, + lane: { kind: 'submitted', query: LOVE, pages: [], wantsMore: false }, + }); + }); + + it('commitPage ignores a stale query, version, or duplicate token', () => { + const state = submitted('love'); + const request = firstPageRequest(LOVE); + const response = versesResponse(['JHN.3.16'], 'page-2'); + const committed = searchSessionReducer(state, { type: 'commitPage', request, response }); + + expect( + searchSessionReducer(state, { + type: 'commitPage', + request: { ...request, query: GRACE }, + response, + }), + ).toBe(state); + expect( + searchSessionReducer(state, { + type: 'commitPage', + request: { ...request, versionId: 222 }, + response, + }), + ).toBe(state); + expect( + searchSessionReducer(committed, { + type: 'commitPage', + request, + response: versesResponse(['ROM.8.28'], 'page-3'), + }), + ).toBe(committed); + expect( + searchSessionReducer(browsing('love'), { type: 'commitPage', request, response }), + ).toEqual(browsing('love')); + }); + + it('commitPage appends the matching page and clears wantsMore', () => { + const first = withPages(submitted('love'), ['JHN.3.16'], 'page-2'); + const loadingMore = searchSessionReducer(first, { type: 'loadMore' }); + const second = searchSessionReducer(loadingMore, { + type: 'commitPage', + request: { query: LOVE, versionId: 111, pageToken: 'page-2' }, + response: versesResponse(['ROM.8.28'], null), + }); + expect(second.lane).toEqual({ + kind: 'submitted', + query: LOVE, + pages: [ + { token: null, hits: [{ id: 'JHN.3.16' }], nextToken: 'page-2' }, + { token: 'page-2', hits: [{ id: 'ROM.8.28' }], nextToken: null }, + ], + wantsMore: false, + }); + }); +}); + +describe('queriesRequest and versesRequest', () => { + it('returns trending for an empty settled query and suggest otherwise', () => { + expect(queriesRequest(createSearchSession(111), EMPTY_QUERY)).toEqual({ kind: 'trending' }); + expect(queriesRequest(browsing('love'), LOVE)).toEqual({ kind: 'suggest', query: LOVE }); + expect(queriesRequest(submitted('love'), LOVE)).toBeNull(); + expect(queriesRequest(browsing('loved'), null)).toBeNull(); + expect(queriesRequest(browsing('loved'), LOVE)).toBeNull(); + }); + + it('requests the first verse page, then the next token only while wantsMore', () => { + const first = submitted('love'); + expect(versesRequest(first)).toEqual(firstPageRequest(LOVE)); + expect(versesRequest(browsing('love'))).toBeNull(); + + const paged = withPages(first, ['JHN.3.16'], 'page-2'); + expect(versesRequest(paged)).toBeNull(); + expect(versesRequest(searchSessionReducer(paged, { type: 'loadMore' }))).toEqual({ + query: LOVE, + versionId: 111, + pageToken: 'page-2', + }); + }); +}); + +describe('projectVerses', () => { + it('parses verse hits, drops malformed and chapter-only ids, and keeps only the first duplicate', () => { + expect( + projectVerses([{ id: 'JHN.3.16-18' }, { id: 'BAD' }, { id: 'JHN.3.16-18' }, { id: 'JHN.3' }]), + ).toEqual([{ id: 'JHN.3.16-18', book: 'JHN', chapter: '3', verses: [16, 17, 18] }]); + }); +}); + +describe('deriveSearchPhase', () => { + const idle = { + queries: null, + queriesLoading: false, + versesLoading: false, + versesError: null, + } satisfies { + queries: readonly SearchQuery[] | null; + queriesLoading: boolean; + versesLoading: boolean; + versesError: Error | null; + }; + + it('maps empty browsing to trending, including a loading list', () => { + expect( + deriveSearchPhase({ + session: createSearchSession(111), + settled: EMPTY_QUERY, + ...idle, + queries: trendingQueries, + }), + ).toEqual({ kind: 'trending', queries: trendingQueries, loading: false }); + expect( + deriveSearchPhase({ + session: createSearchSession(111), + settled: EMPTY_QUERY, + ...idle, + queriesLoading: true, + }), + ).toEqual({ kind: 'trending', queries: [], loading: true }); + }); + + it('maps typing to suggesting, with debounce distinct from loading', () => { + const typing = browsing('love'); + expect( + deriveSearchPhase({ + session: typing, + settled: EMPTY_QUERY, + ...idle, + queries: trendingQueries, + }), + ).toEqual({ + kind: 'suggesting', + queries: trendingQueries, + loading: false, + debouncing: true, + }); + expect( + deriveSearchPhase({ + session: typing, + settled: LOVE, + ...idle, + queries: suggestQueries, + queriesLoading: true, + }), + ).toEqual({ + kind: 'suggesting', + queries: suggestQueries, + loading: true, + debouncing: false, + }); + }); + + it('treats a suggestion failure as an empty list, never failed', () => { + expect( + deriveSearchPhase({ + session: browsing('love'), + settled: LOVE, + ...idle, + queries: null, + }), + ).toEqual({ + kind: 'suggesting', + queries: [], + loading: false, + debouncing: false, + }); + }); + + it('maps a submitted first page in flight to searching', () => { + expect( + deriveSearchPhase({ + session: submitted('love'), + settled: LOVE, + ...idle, + versesLoading: true, + }), + ).toEqual({ kind: 'searching' }); + }); + + it('keeps searching in the landed-before-commit frame', () => { + expect( + deriveSearchPhase({ + session: submitted('love'), + settled: LOVE, + ...idle, + }), + ).toEqual({ kind: 'searching' }); + }); + + it('maps a first-page error to failed, then searching while retrying', () => { + const error = new Error('search failed'); + expect( + deriveSearchPhase({ + session: submitted('love'), + settled: LOVE, + ...idle, + versesError: error, + }), + ).toEqual({ kind: 'failed', error }); + expect( + deriveSearchPhase({ + session: submitted('love'), + settled: LOVE, + ...idle, + versesLoading: true, + versesError: error, + }), + ).toEqual({ kind: 'searching' }); + }); + + it('maps zero projected hits to empty only when pagination is exhausted', () => { + expect( + deriveSearchPhase({ + session: withPages(submitted('love'), ['BAD', 'JHN.3']), + settled: LOVE, + ...idle, + }), + ).toEqual({ kind: 'searching' }); + expect( + deriveSearchPhase({ + session: withPages(submitted('love'), ['BAD', 'JHN.3'], null), + settled: LOVE, + ...idle, + }), + ).toEqual({ kind: 'empty' }); + }); + + it('maps non-empty hits to results and encodes nextPage as one enum', () => { + const john = { + id: 'JHN.3.16', + book: 'JHN', + chapter: '3', + verses: [16], + }; + const available = withPages(submitted('love'), ['JHN.3.16'], 'page-2'); + expect(deriveSearchPhase({ session: available, settled: LOVE, ...idle })).toEqual({ + kind: 'results', + verses: [john], + nextPage: 'available', + }); + + const loadingMore = searchSessionReducer(available, { type: 'loadMore' }); + expect( + deriveSearchPhase({ + session: loadingMore, + settled: LOVE, + ...idle, + versesLoading: true, + }), + ).toEqual({ kind: 'results', verses: [john], nextPage: 'loading' }); + expect( + deriveSearchPhase({ + session: loadingMore, + settled: LOVE, + ...idle, + versesError: new Error('page 2 failed'), + }), + ).toEqual({ kind: 'results', verses: [john], nextPage: 'failed' }); + + const last = withPages(submitted('love'), ['JHN.3.16'], null); + expect(deriveSearchPhase({ session: last, settled: LOVE, ...idle })).toEqual({ + kind: 'results', + verses: [john], + nextPage: 'none', + }); + + const first = withPages(submitted('love'), ['JHN.3.16', 'JHN.3.16'], 'page-2'); + const loadingSecond = searchSessionReducer(first, { type: 'loadMore' }); + const duplicateAcrossPages = searchSessionReducer(loadingSecond, { + type: 'commitPage', + request: { query: LOVE, versionId: 111, pageToken: 'page-2' }, + response: versesResponse(['JHN.3.16', 'ROM.8.28']), + }); + expect( + deriveSearchPhase({ session: duplicateAcrossPages, settled: LOVE, ...idle }), + ).toMatchObject({ + verses: [john, { id: 'ROM.8.28', book: 'ROM', chapter: '8', verses: [28] }], + }); + }); +}); diff --git a/packages/hooks/src/internal/bible-search-session.ts b/packages/hooks/src/internal/bible-search-session.ts new file mode 100644 index 00000000..10a8746c --- /dev/null +++ b/packages/hooks/src/internal/bible-search-session.ts @@ -0,0 +1,390 @@ +import { + MAX_SEARCH_QUERY_GRAPHEMES, + clampSearchText, + parseUsfmReference, +} from '@youversion/platform-core'; +import type { SearchQuery, SearchVerseHit, SearchVersesResponse } from '@youversion/platform-core'; + +export type NormalizedQuery = string & { readonly __normalizedQuery: unique symbol }; + +export type VersePage = Readonly<{ + /** Continuation token this page answered. `null` for the first page. */ + token: string | null; + hits: readonly SearchVerseHit[]; + nextToken: string | null; +}>; + +/** + * Which lane is live. The PRD forbids suggesting for a query that is already + * submitted, so the lanes are one union rather than two slices that can both + * be on at once. + */ +export type SearchLane = + | { readonly kind: 'browsing' } + | { + readonly kind: 'submitted'; + readonly query: NormalizedQuery; + /** Ascending. No two pages share a `token`. Empty while the first page is wanted. */ + readonly pages: readonly VersePage[]; + /** The user asked for the page after `pages.at(-1)` and it has not landed. */ + readonly wantsMore: boolean; + }; + +export type SearchSession = Readonly<{ + versionId: number; + /** Raw controlled input. Clamped to 100 characters. Not trimmed while typing. */ + raw: string; + /** Always `normalizeQuery(raw)`. Never set on its own. */ + normalized: NormalizedQuery; + lane: SearchLane; +}>; + +export type QueriesRequest = + | { readonly kind: 'trending' } + | { readonly kind: 'suggest'; readonly query: NormalizedQuery }; + +export type VersesRequest = Readonly<{ + query: NormalizedQuery; + versionId: number; + pageToken: string | null; +}>; + +export type BibleSearchResult = Readonly<{ + /** USFM id. Same semantics as `BiblePassage.id` (D1). */ + id: string; + book: string; + /** Chapter id as a string, matching `BibleReaderContext.setChapter`. */ + chapter: string; + /** Expanded, ascending. `JHN.3.16-18` becomes `[16, 17, 18]`. Chapter-only hits are omitted. */ + verses: readonly number[]; +}>; + +export type BibleSearchPhase = + | { + readonly kind: 'trending'; + readonly queries: readonly SearchQuery[]; + readonly loading: boolean; + readonly error?: Error; + } + | { + readonly kind: 'suggesting'; + readonly queries: readonly SearchQuery[]; + readonly loading: boolean; + readonly error?: Error; + /** A keystroke is still settling. Never drive a spinner from this. */ + readonly debouncing: boolean; + } + | { readonly kind: 'searching' } + | { + readonly kind: 'results'; + readonly verses: readonly [BibleSearchResult, ...BibleSearchResult[]]; + readonly nextPage: 'none' | 'available' | 'loading' | 'failed'; + } + | { readonly kind: 'empty' } + | { readonly kind: 'failed'; readonly error: Error }; + +export const MAX_SEARCH_QUERY_LENGTH = MAX_SEARCH_QUERY_GRAPHEMES; +export const SEARCH_SUGGESTION_DEBOUNCE_MS = 300; +export const SEARCH_VERSES_PAGE_SIZE = 20; +export const EMPTY_QUERY = + // SAFETY: the empty string is the branded empty query. normalizeQuery is the + // only other mint, and it returns this constant after trim. + '' as NormalizedQuery; + +export function clampSearchInput(raw: string): string { + return clampSearchText(raw); +} + +export function normalizeQuery(raw: string): NormalizedQuery { + const trimmed = raw.trim(); + if (trimmed === '') { + return EMPTY_QUERY; + } + // SAFETY: branding after trim. Empty input already returned EMPTY_QUERY. + return trimmed as NormalizedQuery; +} + +export type SearchSessionAction = + | { readonly type: 'setQuery'; readonly raw: string } + | { readonly type: 'submit' } + | { readonly type: 'selectSuggestion'; readonly text: string } + | { readonly type: 'loadMore' } + | { readonly type: 'setVersion'; readonly versionId: number } + | { + readonly type: 'commitPage'; + readonly request: VersesRequest; + readonly response: SearchVersesResponse; + }; + +export function createSearchSession(versionId: number): SearchSession { + return { + versionId, + raw: '', + normalized: EMPTY_QUERY, + lane: { kind: 'browsing' }, + }; +} + +function submitNormalized( + state: SearchSession, + raw: string, + normalized: NormalizedQuery, +): SearchSession { + if (normalized === EMPTY_QUERY) { + if (state.raw === '' && state.normalized === EMPTY_QUERY && state.lane.kind === 'browsing') { + return state; + } + return { ...state, raw: '', normalized: EMPTY_QUERY, lane: { kind: 'browsing' } }; + } + return { + ...state, + raw, + normalized, + lane: { kind: 'submitted', query: normalized, pages: [], wantsMore: false }, + }; +} + +/** + * Pure. Returns the same object for a no-op so React bails out. + * + * Invariants: + * - `normalized` is always `normalizeQuery(raw)`. + * - A submitted lane never contains two pages with the same token. + * - `wantsMore` is true only when the last page has a `nextToken`. + */ +export function searchSessionReducer( + state: SearchSession, + action: SearchSessionAction, +): SearchSession { + switch (action.type) { + case 'setQuery': { + const raw = clampSearchInput(action.raw); + const normalized = normalizeQuery(raw); + if (normalized === state.normalized) { + if (raw === state.raw) { + return state; + } + return { ...state, raw }; + } + return { ...state, raw, normalized, lane: { kind: 'browsing' } }; + } + case 'submit': { + return submitNormalized(state, state.raw, state.normalized); + } + case 'selectSuggestion': { + const raw = clampSearchInput(action.text); + const normalized = normalizeQuery(raw); + return submitNormalized(state, raw, normalized); + } + case 'loadMore': { + if (state.lane.kind !== 'submitted' || state.lane.wantsMore) { + return state; + } + const last = state.lane.pages.at(-1); + if (last?.nextToken == null) { + return state; + } + return { ...state, lane: { ...state.lane, wantsMore: true } }; + } + case 'setVersion': { + if (action.versionId === state.versionId) { + return state; + } + if (state.lane.kind === 'submitted') { + return { + ...state, + versionId: action.versionId, + lane: { + kind: 'submitted', + query: state.lane.query, + pages: [], + wantsMore: false, + }, + }; + } + return { ...state, versionId: action.versionId }; + } + case 'commitPage': { + if (state.lane.kind !== 'submitted') { + return state; + } + if ( + action.request.query !== state.lane.query || + action.request.versionId !== state.versionId + ) { + return state; + } + if (state.lane.pages.some((page) => page.token === action.request.pageToken)) { + return state; + } + return { + ...state, + lane: { + ...state.lane, + pages: [ + ...state.lane.pages, + { + token: action.request.pageToken, + hits: action.response.verses, + nextToken: action.response.nextPageToken ?? null, + }, + ], + wantsMore: false, + }, + }; + } + default: { + const _exhaustive: never = action; + return _exhaustive; + } + } +} + +export function queriesRequest( + session: SearchSession, + settled: NormalizedQuery | null, +): QueriesRequest | null { + if (session.lane.kind === 'submitted' || settled === null) { + return null; + } + if (settled !== session.normalized) { + return null; + } + if (settled === EMPTY_QUERY) { + return { kind: 'trending' }; + } + return { kind: 'suggest', query: settled }; +} + +export function versesRequest(session: SearchSession): VersesRequest | null { + if (session.lane.kind !== 'submitted') { + return null; + } + if (session.lane.pages.length === 0) { + return { + query: session.lane.query, + versionId: session.versionId, + pageToken: null, + }; + } + if (!session.lane.wantsMore) { + return null; + } + const last = session.lane.pages.at(-1); + if (last?.nextToken == null) { + return null; + } + return { + query: session.lane.query, + versionId: session.versionId, + pageToken: last.nextToken, + }; +} + +export function projectVerses(hits: readonly SearchVerseHit[]): readonly BibleSearchResult[] { + const verses: BibleSearchResult[] = []; + const seen = new Set(); + for (const hit of hits) { + if (seen.has(hit.id)) { + continue; + } + seen.add(hit.id); + const parsed = parseUsfmReference(hit.id); + if (parsed === null || parsed.verses.length === 0) { + continue; + } + verses.push({ + id: hit.id, + book: parsed.book, + chapter: parsed.chapter, + verses: parsed.verses, + }); + } + return verses; +} + +function flattenHits(pages: readonly VersePage[]): readonly SearchVerseHit[] { + const hits: SearchVerseHit[] = []; + for (const page of pages) { + hits.push(...page.hits); + } + return hits; +} + +function nextPageStatus(input: { + readonly session: SearchSession; + readonly failed: boolean; + readonly inFlight: boolean; +}): 'none' | 'available' | 'loading' | 'failed' { + if (input.failed) { + return 'failed'; + } + if (input.inFlight) { + return 'loading'; + } + if (input.session.lane.kind !== 'submitted') { + return 'none'; + } + const last = input.session.lane.pages.at(-1); + return last?.nextToken ? 'available' : 'none'; +} + +export function deriveSearchPhase(input: { + readonly session: SearchSession; + readonly bookIds?: readonly string[]; + readonly settled: NormalizedQuery | null; + readonly queries: readonly SearchQuery[] | null; + readonly queriesLoading: boolean; + readonly queriesError?: Error | null; + readonly versesLoading: boolean; + readonly versesError: Error | null; +}): BibleSearchPhase { + const queries = input.queries ?? []; + if (input.session.lane.kind === 'browsing') { + const error = input.queriesError && !input.queriesLoading ? { error: input.queriesError } : {}; + if (input.session.normalized === EMPTY_QUERY) { + return { kind: 'trending', queries, loading: input.queriesLoading, ...error }; + } + return { + kind: 'suggesting', + queries, + loading: input.queriesLoading, + debouncing: input.settled !== input.session.normalized, + ...error, + }; + } + + const wanted = versesRequest(input.session); + const failed = wanted !== null && input.versesError !== null && !input.versesLoading; + const inFlight = wanted !== null && !failed; + const verses = projectVerses(flattenHits(input.session.lane.pages)).filter( + (verse) => input.bookIds === undefined || input.bookIds.includes(verse.book), + ); + + if (input.session.lane.pages.length === 0) { + if (failed && input.versesError !== null) { + return { kind: 'failed', error: input.versesError }; + } + if (inFlight) { + return { kind: 'searching' }; + } + } + + const first = verses[0]; + if (first === undefined) { + if (failed && input.versesError !== null) return { kind: 'failed', error: input.versesError }; + if ( + input.bookIds?.length !== 0 && + nextPageStatus({ session: input.session, failed, inFlight }) !== 'none' + ) { + return { kind: 'searching' }; + } + return { kind: 'empty' }; + } + + return { + kind: 'results', + verses: [first, ...verses.slice(1)], + nextPage: nextPageStatus({ session: input.session, failed, inFlight }), + }; +} diff --git a/packages/hooks/src/test/utils.tsx b/packages/hooks/src/test/utils.tsx index dcb6e177..54cc80ff 100644 --- a/packages/hooks/src/test/utils.tsx +++ b/packages/hooks/src/test/utils.tsx @@ -1,6 +1,12 @@ import { type ReactNode, type ComponentType } from 'react'; -import type { BibleClient, LanguagesClient, OrganizationsClient } from '@youversion/platform-core'; +import type { + BibleClient, + LanguagesClient, + OrganizationsClient, + SearchClient, +} from '@youversion/platform-core'; import { YouVersionContext } from '../context'; +import type { HookOverrides } from '../hook-overrides'; import { TestQueryClientProvider } from '../test-utils'; export { TestQueryClientProvider }; @@ -10,6 +16,8 @@ export type YVWrapperOptions = { bibleClient?: BibleClient; languagesClient?: LanguagesClient; organizationsClient?: OrganizationsClient; + searchClient?: SearchClient; + hookOverrides?: HookOverrides; }; /** Builds a `BibleClient`-typed stub with only the methods the test calls. */ @@ -32,14 +40,29 @@ export function createOrganizationsClientStub( return methods as OrganizationsClient; } +/** Builds a `SearchClient`-typed stub with only the methods the test calls. */ +export function createSearchClientStub(methods: Partial): SearchClient { + // SAFETY: stub implements the methods under test + return methods as SearchClient; +} + export const createYVWrapper = ( appKey = 'test-app-key', options: YVWrapperOptions = {}, ): ComponentType<{ children: ReactNode }> => { - const { theme, bibleClient, languagesClient, organizationsClient } = options; + const { theme, bibleClient, languagesClient, organizationsClient, searchClient, hookOverrides } = + options; const Wrapper = ({ children }: { children: ReactNode }) => ( {children} diff --git a/packages/hooks/src/useBibleSearch.test.tsx b/packages/hooks/src/useBibleSearch.test.tsx new file mode 100644 index 00000000..96a9841c --- /dev/null +++ b/packages/hooks/src/useBibleSearch.test.tsx @@ -0,0 +1,564 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { BibleVersion, SearchQueries, SearchVersesResponse } from '@youversion/platform-core'; +import { useBibleSearch, type UseBibleSearchResult } from './useBibleSearch'; +import { + SEARCH_SUGGESTION_DEBOUNCE_MS, + SEARCH_VERSES_PAGE_SIZE, +} from './internal/bible-search-session'; +import { createBibleClientStub, createSearchClientStub, createYVWrapper } from './test/utils'; + +const mockVersion: BibleVersion = { + id: 111, + title: 'New International Version', + abbreviation: 'NIV', + localized_title: 'New International Version', + localized_abbreviation: 'NIV', + language_tag: 'en', + books: ['GEN', 'EXO', 'LEV'], + youversion_deep_link: 'https://bible.com/versions/111', +}; + +const trending: SearchQueries = { queries: [{ text: 'love' }, { text: 'hope' }] }; +const suggestions: SearchQueries = { queries: [{ text: 'love of God' }] }; + +function versesPage(ids: readonly string[], nextPageToken: string | null): SearchVersesResponse { + return { + verses: ids.map((id) => ({ id })), + didYouMean: [], + searchInsteadFor: null, + nextPageToken, + }; +} + +async function settleDebounce(): Promise { + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, SEARCH_SUGGESTION_DEBOUNCE_MS + 20); + }); + }); +} + +function createSearchFixture() { + const mockGetVersion = vi.fn().mockResolvedValue(mockVersion); + const mockGetTrendingQueries = vi.fn().mockResolvedValue(trending); + const mockGetSuggestedQueries = vi.fn().mockResolvedValue(suggestions); + const mockSearchVerses = vi.fn().mockResolvedValue(versesPage(['JHN.3.16'], 'page-2')); + + const bibleClient = createBibleClientStub({ getVersion: mockGetVersion }); + const searchClient = createSearchClientStub({ + getTrendingQueries: mockGetTrendingQueries, + getSuggestedQueries: mockGetSuggestedQueries, + searchVerses: mockSearchVerses, + }); + const wrapper = createYVWrapper('test-app-key', { bibleClient, searchClient }); + + return { + wrapper, + bibleClient, + searchClient, + mockGetVersion, + mockGetTrendingQueries, + mockGetSuggestedQueries, + mockSearchVerses, + }; +} + +describe('useBibleSearch', () => { + it('resubmits failed and empty searches without replaying cached pages or duplicating an in-flight request', async () => { + const fixture = createSearchFixture(); + let finishRetry!: (page: SearchVersesResponse) => void; + fixture.mockSearchVerses + .mockRejectedValueOnce(new Error('unavailable')) + .mockResolvedValueOnce(versesPage([], null)) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishRetry = resolve; + }), + ); + const { result } = renderHook(() => useBibleSearch({ versionId: 111 }), { + wrapper: fixture.wrapper, + }); + act(() => result.current.selectSuggestion('love')); + await waitFor(() => expect(result.current.phase.kind).toBe('failed')); + act(() => result.current.submit()); + await waitFor(() => expect(result.current.phase.kind).toBe('empty')); + expect(fixture.mockSearchVerses).toHaveBeenCalledTimes(2); + act(() => result.current.submit()); + expect(result.current.phase.kind).toBe('searching'); + await waitFor(() => expect(fixture.mockSearchVerses).toHaveBeenCalledTimes(3)); + act(() => result.current.submit()); + act(() => result.current.selectSuggestion(' love ')); + expect(result.current.query).toBe(' love '); + expect(result.current.phase.kind).toBe('searching'); + expect(fixture.mockSearchVerses).toHaveBeenCalledTimes(3); + await act(async () => finishRetry(versesPage(['JHN.3.16'], null))); + await waitFor(() => + expect(result.current.phase).toMatchObject({ + kind: 'results', + verses: [{ id: 'JHN.3.16' }], + nextPage: 'none', + }), + ); + expect(fixture.mockGetSuggestedQueries).not.toHaveBeenCalled(); + }); + + it('replaces a failed continuation with loading immediately when the book filter changes', async () => { + const fixture = createSearchFixture(); + let finishPage!: (page: SearchVersesResponse) => void; + fixture.mockSearchVerses + .mockResolvedValueOnce(versesPage(['PSA.91.1'], 'second')) + .mockRejectedValueOnce(new Error('page unavailable')) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishPage = resolve; + }), + ); + const { result, rerender } = renderHook( + ({ bookIds }) => useBibleSearch({ versionId: 111, bookIds }), + { wrapper: fixture.wrapper, initialProps: { bookIds: ['JHN'] } }, + ); + act(() => result.current.selectSuggestion('Psalm 91')); + await waitFor(() => expect(result.current.phase.kind).toBe('failed')); + rerender({ bookIds: ['PSA'] }); + expect(result.current.phase).toMatchObject({ kind: 'results', nextPage: 'loading' }); + await waitFor(() => expect(fixture.mockSearchVerses).toHaveBeenCalledTimes(3)); + await act(async () => finishPage(versesPage([], null))); + await waitFor(() => + expect(result.current.phase).toMatchObject({ kind: 'results', nextPage: 'none' }), + ); + rerender({ bookIds: ['JHN'] }); + expect(result.current.phase).toEqual({ kind: 'empty' }); + }); + + it('skips nonmatching pages, retries failures, and reuses loaded results when the book filter changes', async () => { + const fixture = createSearchFixture(); + const unavailable = new Error('page unavailable'); + fixture.mockSearchVerses + .mockResolvedValueOnce(versesPage(['GEN.1.1'], 'second')) + .mockRejectedValueOnce(unavailable) + .mockResolvedValueOnce(versesPage(['PSA.23.1'], 'third')) + .mockResolvedValueOnce(versesPage(['JHN.3.16', 'GEN.1.2'], null)); + const phases: string[] = []; + const { result, rerender } = renderHook< + UseBibleSearchResult, + { bookIds: readonly string[] | undefined } + >( + ({ bookIds }) => { + const search = useBibleSearch({ versionId: 111, bookIds }); + phases.push(search.phase.kind); + return search; + }, + { wrapper: fixture.wrapper, initialProps: { bookIds: ['JHN'] } }, + ); + act(() => result.current.selectSuggestion('love')); + await waitFor(() => + expect(result.current.phase).toEqual({ kind: 'failed', error: unavailable }), + ); + expect(phases).not.toContain('empty'); + act(() => result.current.retry()); + await waitFor(() => + expect(result.current.phase).toMatchObject({ + kind: 'results', + verses: [{ id: 'JHN.3.16' }], + nextPage: 'none', + }), + ); + expect(fixture.mockSearchVerses.mock.calls.map((call) => call[2].pageToken)).toEqual([ + undefined, + 'second', + 'second', + 'third', + ]); + rerender({ bookIds: undefined }); + expect(result.current.phase).toMatchObject({ + kind: 'results', + verses: [{ id: 'GEN.1.1' }, { id: 'PSA.23.1' }, { id: 'JHN.3.16' }, { id: 'GEN.1.2' }], + }); + rerender({ bookIds: ['EXO'] }); + expect(result.current.phase).toEqual({ kind: 'empty' }); + expect(fixture.mockSearchVerses).toHaveBeenCalledTimes(4); + }); + + it('abandons filtered pagination when the query is cleared, ignoring its late response', async () => { + const fixture = createSearchFixture(); + let finishPage!: (page: SearchVersesResponse) => void; + fixture.mockSearchVerses + .mockResolvedValueOnce(versesPage(['GEN.1.1'], 'second')) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishPage = resolve; + }), + ); + const { result } = renderHook(() => useBibleSearch({ versionId: 111, bookIds: ['JHN'] }), { + wrapper: fixture.wrapper, + }); + act(() => result.current.selectSuggestion('love')); + await waitFor(() => expect(fixture.mockSearchVerses).toHaveBeenCalledTimes(2)); + expect(result.current.phase.kind).toBe('searching'); + act(() => result.current.setQuery('')); + await act(async () => finishPage(versesPage(['JHN.3.16'], 'third'))); + expect(result.current.phase.kind).toBe('trending'); + expect(fixture.mockSearchVerses).toHaveBeenCalledTimes(2); + }); + + it('surfaces discovery failures, retries trending, and still submits after suggestions fail', async () => { + const { wrapper, mockGetTrendingQueries, mockGetSuggestedQueries, mockSearchVerses } = + createSearchFixture(); + const unavailable = new Error('discovery unavailable'); + mockGetTrendingQueries.mockRejectedValueOnce(unavailable); + mockGetSuggestedQueries.mockRejectedValueOnce(unavailable); + const { result } = renderHook(() => useBibleSearch({ versionId: 111 }), { wrapper }); + await waitFor(() => + expect(result.current.phase).toMatchObject({ + kind: 'trending', + loading: false, + error: unavailable, + }), + ); + act(() => result.current.retry()); + await waitFor(() => + expect(result.current.phase).toEqual({ + kind: 'trending', + queries: trending.queries, + loading: false, + }), + ); + act(() => result.current.setQuery('mercy')); + await settleDebounce(); + await waitFor(() => + expect(result.current.phase).toMatchObject({ + kind: 'suggesting', + loading: false, + error: unavailable, + }), + ); + act(() => result.current.submit()); + await waitFor(() => expect(result.current.phase.kind).toBe('results')); + expect(result.current.query).toBe('mercy'); + expect(mockSearchVerses).toHaveBeenCalledWith('mercy', 111, { + pageSize: SEARCH_VERSES_PAGE_SIZE, + }); + }); + + it('waits for version.language_tag before requesting trending', async () => { + const { wrapper, mockGetVersion, mockGetTrendingQueries } = createSearchFixture(); + let resolveVersion: (version: BibleVersion) => void = () => {}; + mockGetVersion.mockReturnValue( + new Promise((resolve) => { + resolveVersion = resolve; + }), + ); + + const { result } = renderHook(() => useBibleSearch({ versionId: 111 }), { wrapper }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockGetTrendingQueries).not.toHaveBeenCalled(); + expect(result.current.phase).toEqual({ kind: 'trending', queries: [], loading: false }); + + await act(async () => { + resolveVersion(mockVersion); + }); + + await waitFor(() => { + expect(mockGetTrendingQueries).toHaveBeenCalledWith('en'); + }); + await waitFor(() => { + expect(result.current.phase).toEqual({ + kind: 'trending', + queries: trending.queries, + loading: false, + }); + }); + }); + + it('falls back to wildcard discovery when version metadata fails', async () => { + const { wrapper, mockGetVersion, mockGetTrendingQueries } = createSearchFixture(); + mockGetVersion.mockRejectedValueOnce(new Error('metadata unavailable')); + + const { result } = renderHook(() => useBibleSearch({ versionId: 111 }), { wrapper }); + + await waitFor(() => { + expect(mockGetTrendingQueries).toHaveBeenCalledWith('*'); + }); + await waitFor(() => { + expect(result.current.phase).toEqual({ + kind: 'trending', + queries: trending.queries, + loading: false, + }); + }); + }); + + it.each(['', 'not a language tag'])( + 'falls back to wildcard discovery for unusable language tag %j', + async (languageTag) => { + const { wrapper, mockGetVersion, mockGetTrendingQueries } = createSearchFixture(); + mockGetVersion.mockResolvedValueOnce({ ...mockVersion, language_tag: languageTag }); + + renderHook(() => useBibleSearch({ versionId: 111 }), { wrapper }); + + await waitFor(() => { + expect(mockGetTrendingQueries).toHaveBeenCalledWith('*'); + }); + }, + ); + + it('debounces suggestions without requesting or displaying a stale query', async () => { + const { wrapper, mockGetSuggestedQueries } = createSearchFixture(); + const { result } = renderHook(() => useBibleSearch({ versionId: 111 }), { wrapper }); + + await waitFor(() => { + expect(result.current.phase).toEqual({ + kind: 'trending', + queries: trending.queries, + loading: false, + }); + }); + + act(() => { + result.current.setQuery('love'); + }); + + expect(result.current.query).toBe('love'); + expect(result.current.phase).toEqual({ + kind: 'suggesting', + queries: [], + loading: false, + debouncing: true, + }); + expect(mockGetSuggestedQueries).not.toHaveBeenCalled(); + + await settleDebounce(); + + await waitFor(() => { + expect(mockGetSuggestedQueries).toHaveBeenCalledWith('love', 'en'); + }); + await waitFor(() => { + expect(result.current.phase).toEqual({ + kind: 'suggesting', + queries: suggestions.queries, + loading: false, + debouncing: false, + }); + }); + }); + + it('supersedes an earlier suggestion response as soon as normalized input changes', async () => { + const { wrapper, mockGetSuggestedQueries } = createSearchFixture(); + let resolveEarlier: (value: SearchQueries) => void = () => {}; + mockGetSuggestedQueries.mockReturnValueOnce( + new Promise((resolve) => { + resolveEarlier = resolve; + }), + ); + const { result } = renderHook(() => useBibleSearch({ versionId: 111 }), { wrapper }); + + await waitFor(() => { + expect(result.current.phase.kind).toBe('trending'); + }); + act(() => { + result.current.setQuery('love'); + }); + await settleDebounce(); + await waitFor(() => { + expect(mockGetSuggestedQueries).toHaveBeenCalledWith('love', 'en'); + }); + + act(() => { + result.current.setQuery('loved'); + }); + expect(result.current.phase).toEqual({ + kind: 'suggesting', + queries: [], + loading: false, + debouncing: true, + }); + + await act(async () => { + resolveEarlier({ queries: [{ text: 'stale love' }] }); + }); + expect(result.current.phase).toEqual({ + kind: 'suggesting', + queries: [], + loading: false, + debouncing: true, + }); + }); + + it('submits verses, suppresses suggestions, paginates, and retries a failed next page', async () => { + const { wrapper, mockGetSuggestedQueries, mockSearchVerses } = createSearchFixture(); + const { result } = renderHook(() => useBibleSearch({ versionId: 111 }), { wrapper }); + + await waitFor(() => { + expect(result.current.phase.kind).toBe('trending'); + }); + + act(() => { + result.current.setQuery('love'); + }); + await settleDebounce(); + await waitFor(() => { + expect(mockGetSuggestedQueries).toHaveBeenCalledTimes(1); + }); + + mockGetSuggestedQueries.mockClear(); + act(() => { + result.current.submit(); + }); + + expect(mockGetSuggestedQueries).not.toHaveBeenCalled(); + await waitFor(() => { + expect(mockSearchVerses).toHaveBeenCalledWith('love', 111, { + pageSize: SEARCH_VERSES_PAGE_SIZE, + }); + }); + await waitFor(() => { + expect(result.current.phase).toEqual({ + kind: 'results', + verses: [{ id: 'JHN.3.16', book: 'JHN', chapter: '3', verses: [16] }], + nextPage: 'available', + }); + }); + + mockSearchVerses.mockRejectedValueOnce(new Error('page 2 failed')); + act(() => { + result.current.loadMore(); + }); + + await waitFor(() => { + expect(mockSearchVerses).toHaveBeenCalledWith('love', 111, { + pageSize: SEARCH_VERSES_PAGE_SIZE, + pageToken: 'page-2', + }); + }); + await waitFor(() => { + expect(result.current.phase).toEqual({ + kind: 'results', + verses: [{ id: 'JHN.3.16', book: 'JHN', chapter: '3', verses: [16] }], + nextPage: 'failed', + }); + }); + + mockSearchVerses.mockResolvedValueOnce(versesPage(['JHN.3.16', 'ROM.8.28'], null)); + act(() => { + result.current.retry(); + }); + + await waitFor(() => { + expect(result.current.phase).toEqual({ + kind: 'results', + verses: [ + { id: 'JHN.3.16', book: 'JHN', chapter: '3', verses: [16] }, + { id: 'ROM.8.28', book: 'ROM', chapter: '8', verses: [28] }, + ], + nextPage: 'none', + }); + }); + }); + + it('does not re-request the submitted query as suggestions during the first edit debounce', async () => { + const { wrapper, mockGetSuggestedQueries } = createSearchFixture(); + const { result } = renderHook(() => useBibleSearch({ versionId: 111 }), { wrapper }); + + await waitFor(() => { + expect(result.current.phase.kind).toBe('trending'); + }); + + act(() => { + result.current.setQuery('love'); + }); + await settleDebounce(); + await waitFor(() => { + expect(mockGetSuggestedQueries).toHaveBeenCalledTimes(1); + }); + + act(() => { + result.current.submit(); + }); + await waitFor(() => { + expect(result.current.phase.kind).toBe('results'); + }); + + mockGetSuggestedQueries.mockClear(); + act(() => { + result.current.setQuery('loved'); + }); + + expect(mockGetSuggestedQueries).not.toHaveBeenCalled(); + expect(result.current.phase).toEqual({ + kind: 'suggesting', + queries: [], + loading: false, + debouncing: true, + }); + + await settleDebounce(); + + await waitFor(() => { + expect(mockGetSuggestedQueries).toHaveBeenCalledTimes(1); + }); + expect(mockGetSuggestedQueries).toHaveBeenCalledWith('loved', 'en'); + expect(mockGetSuggestedQueries).not.toHaveBeenCalledWith('love', 'en'); + }); + + it('selectSuggestion submits immediately without a filled-but-idle frame', async () => { + const { wrapper, mockSearchVerses } = createSearchFixture(); + const { result } = renderHook(() => useBibleSearch({ versionId: 111 }), { wrapper }); + + await waitFor(() => { + expect(result.current.phase.kind).toBe('trending'); + }); + + act(() => { + result.current.selectSuggestion('love of God'); + }); + + expect(result.current.query).toBe('love of God'); + expect(result.current.phase.kind).toBe('searching'); + await waitFor(() => { + expect(mockSearchVerses).toHaveBeenCalledWith('love of God', 111, { + pageSize: SEARCH_VERSES_PAGE_SIZE, + }); + }); + }); + + it('returns the hook override without fetching search', async () => { + const { bibleClient, searchClient, mockGetTrendingQueries, mockSearchVerses } = + createSearchFixture(); + const stub: UseBibleSearchResult = { + query: 'love', + phase: { kind: 'empty' }, + setQuery: () => {}, + submit: () => {}, + selectSuggestion: () => {}, + loadMore: () => {}, + retry: () => {}, + }; + const overrideWrapper = createYVWrapper('test-app-key', { + bibleClient, + searchClient, + hookOverrides: { useBibleSearch: () => stub }, + }); + + const { result } = renderHook(() => useBibleSearch({ versionId: 111 }), { + wrapper: overrideWrapper, + }); + + expect(result.current).toBe(stub); + await act(async () => { + await Promise.resolve(); + }); + expect(mockGetTrendingQueries).not.toHaveBeenCalled(); + expect(mockSearchVerses).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/hooks/src/useBibleSearch.ts b/packages/hooks/src/useBibleSearch.ts new file mode 100644 index 00000000..a56020e2 --- /dev/null +++ b/packages/hooks/src/useBibleSearch.ts @@ -0,0 +1,182 @@ +'use client'; + +import { useCallback, useEffect, useReducer, useState } from 'react'; +import { parseSearchLanguageRange, type SearchVersesOptions } from '@youversion/platform-core'; +import { useApiData } from './useApiData'; +import { useHookOverride } from './useHookOverride'; +import { useQueryKeyBase } from './internal/useQueryKeyBase'; +import { useSearchClient } from './useSearchClient'; +import { useVersion } from './useVersion'; +import { useDebounce } from './utility/useDebounce'; +import { + EMPTY_QUERY, + SEARCH_SUGGESTION_DEBOUNCE_MS, + SEARCH_VERSES_PAGE_SIZE, + clampSearchInput, + createSearchSession, + deriveSearchPhase, + normalizeQuery, + queriesRequest, + searchSessionReducer, + versesRequest, + type BibleSearchPhase, + type BibleSearchResult, +} from './internal/bible-search-session'; + +export type { BibleSearchPhase, BibleSearchResult }; + +export type UseBibleSearchProps = Readonly<{ + versionId: number; + /** Client-side result filter. Unset includes all books; empty includes none. + * Pages without matches are skipped until a match or the end is reached. */ + bookIds?: readonly string[]; +}>; + +export type UseBibleSearchResult = Readonly<{ + query: string; + phase: BibleSearchPhase; + setQuery: (raw: string) => void; + submit: () => void; + selectSuggestion: (text: string) => void; + loadMore: () => void; + /** Re-requests failed suggestions, the initial search, or a failed results page. */ + retry: () => void; +}>; + +export function useBibleSearch(props: UseBibleSearchProps): UseBibleSearchResult { + const { versionId } = props; + const override = useHookOverride('useBibleSearch'); + const client = useSearchClient(); + const keyBase = useQueryKeyBase(); + const [session, dispatch] = useReducer(searchSessionReducer, versionId, createSearchSession); + const [submission, setSubmission] = useState(0); + + useEffect(() => { + dispatch({ type: 'setVersion', versionId }); + }, [versionId]); + + const { version, loading: versionLoading } = useVersion(versionId); + let languageRanges: string | null = null; + if (!versionLoading) { + try { + languageRanges = parseSearchLanguageRange(version?.language_tag ?? '*'); + } catch { + languageRanges = '*'; + } + } + const debounced = useDebounce(session.normalized, SEARCH_SUGGESTION_DEBOUNCE_MS); + const settled = session.normalized === EMPTY_QUERY ? EMPTY_QUERY : debounced; + const qReq = queriesRequest(session, settled); + const suggestQuery = qReq?.kind === 'suggest' ? qReq.query : ''; + const queries = useApiData( + [...keyBase, 'searchQueries', languageRanges, qReq?.kind ?? 'off', suggestQuery], + () => { + if (qReq === null || languageRanges === null) { + return Promise.reject(new Error('search queries requested while disabled')); + } + return qReq.kind === 'trending' + ? client.getTrendingQueries(languageRanges) + : client.getSuggestedQueries(qReq.query, languageRanges); + }, + { + enabled: !override && qReq !== null && languageRanges !== null, + keepPreviousData: false, + }, + ); + + const vReq = versesRequest(session); + const verses = useApiData( + [ + ...keyBase, + 'searchVerses', + vReq?.versionId, + vReq?.query ?? '', + vReq?.pageToken ?? '', + props.bookIds, + submission, + ], + () => { + if (vReq === null) { + return Promise.reject(new Error('search verses requested while disabled')); + } + const options: SearchVersesOptions = { pageSize: SEARCH_VERSES_PAGE_SIZE }; + if (vReq.pageToken !== null) { + options.pageToken = vReq.pageToken; + } + return client.searchVerses(vReq.query, vReq.versionId, options); + }, + { + enabled: !override && vReq !== null, + keepPreviousData: false, + }, + ); + + useEffect(() => { + const request = versesRequest(session); + if (request !== null && verses.data !== null) { + dispatch({ type: 'commitPage', request, response: verses.data }); + } + }, [session, verses.data]); + + const setQuery = useCallback((raw: string) => { + dispatch({ type: 'setQuery', raw }); + }, []); + const selectSuggestion = useCallback( + (text: string) => { + const normalized = normalizeQuery(clampSearchInput(text)); + if (session.lane.kind === 'submitted' && session.lane.query === normalized) { + if (verses.loading) { + dispatch({ type: 'setQuery', raw: text }); + return; + } + // A deliberate resubmission must not recommit a cached empty or failed search. + setSubmission((previous) => previous + 1); + } + dispatch({ type: 'selectSuggestion', text }); + }, + [session.lane, verses.loading], + ); + const submit = useCallback(() => { + selectSuggestion(session.raw); + }, [selectSuggestion, session.raw]); + const loadMore = useCallback(() => { + dispatch({ type: 'loadMore' }); + }, []); + const retry = useCallback(() => { + if (vReq !== null && verses.error !== null) { + verses.refetch(); + } else if (qReq !== null && queries.error !== null) { + queries.refetch(); + } + }, [vReq, verses, qReq, queries]); + + const phase = deriveSearchPhase({ + session, + bookIds: props.bookIds, + settled, + queries: queries.data?.queries ?? null, + queriesLoading: queries.loading, + queriesError: queries.error, + versesLoading: verses.loading, + versesError: verses.error, + }); + useEffect(() => { + if (!override && phase.kind === 'searching' && vReq === null) { + dispatch({ type: 'loadMore' }); + } + }, [override, phase.kind, vReq]); + + if (override) { + return override(props); + } + + return { + query: session.raw, + phase, + setQuery, + submit, + selectSuggestion, + loadMore, + retry, + }; +} diff --git a/packages/hooks/src/useSearchClient.test.tsx b/packages/hooks/src/useSearchClient.test.tsx new file mode 100644 index 00000000..25351677 --- /dev/null +++ b/packages/hooks/src/useSearchClient.test.tsx @@ -0,0 +1,50 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { ReactNode } from 'react'; +import { SearchClient } from '@youversion/platform-core'; +import { useSearchClient } from './useSearchClient'; +import { YouVersionContext } from './context'; +import { createSearchClientStub, createYVWrapper } from './test/utils'; + +describe('useSearchClient', () => { + it('creates a SearchClient when context is valid', () => { + const wrapper = createYVWrapper(); + const { result } = renderHook(() => useSearchClient(), { wrapper }); + + expect(result.current).toBeInstanceOf(SearchClient); + }); + + it('returns the injected SearchClient when present', () => { + const searchClient = createSearchClientStub({}); + const wrapper = createYVWrapper('test-app-key', { searchClient }); + const { result } = renderHook(() => useSearchClient(), { wrapper }); + + expect(result.current).toBe(searchClient); + }); + + it('throws when context is not provided', () => { + expect(() => renderHook(() => useSearchClient())).toThrow( + 'YouVersion context not found. Make sure your component is wrapped with YouVersionProvider and an API key is provided.', + ); + }); + + it('throws when appKey is missing', () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + expect(() => renderHook(() => useSearchClient(), { wrapper })).toThrow( + 'YouVersion context not found. Make sure your component is wrapped with YouVersionProvider and an API key is provided.', + ); + }); + + it('memoizes the SearchClient instance', () => { + const wrapper = createYVWrapper(); + const { result, rerender } = renderHook(() => useSearchClient(), { wrapper }); + const firstClient = result.current; + + rerender(); + + expect(result.current).toBe(firstClient); + }); +}); diff --git a/packages/hooks/src/useSearchClient.ts b/packages/hooks/src/useSearchClient.ts new file mode 100644 index 00000000..faa5f933 --- /dev/null +++ b/packages/hooks/src/useSearchClient.ts @@ -0,0 +1,14 @@ +'use client'; + +import { useContext, useMemo } from 'react'; +import { SearchClient } from '@youversion/platform-core'; +import { YouVersionContext } from './context'; +import { useApiClient } from './internal/useApiClient'; + +/** Mirrors `useBibleClient`. `YouVersionContext.searchClient` wins in tests. */ +export function useSearchClient(): SearchClient { + const override = useContext(YouVersionContext)?.searchClient; + const apiClient = useApiClient(); + const constructed = useMemo(() => new SearchClient(apiClient), [apiClient]); + return override ?? constructed; +} diff --git a/packages/hooks/src/utility/useDebounce.test.tsx b/packages/hooks/src/utility/useDebounce.test.tsx index 01dcd30e..2d74cad3 100644 --- a/packages/hooks/src/utility/useDebounce.test.tsx +++ b/packages/hooks/src/utility/useDebounce.test.tsx @@ -92,4 +92,45 @@ describe('useDebounce', () => { expect(result.current).toBe('updated'); }); + + it('debounces null like any other value and cancels it when superseded', () => { + type NullableProps = { value: string | null }; + const { result, rerender } = renderHook( + ({ value }) => useDebounce(value, 500), + { initialProps: { value: 'love' } }, + ); + + rerender({ value: null }); + expect(result.current).toBe('love'); + act(() => { + vi.advanceTimersByTime(499); + }); + expect(result.current).toBe('love'); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(result.current).toBeNull(); + + rerender({ value: 'loved' }); + expect(result.current).toBeNull(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current).toBe('loved'); + rerender({ value: null }); + act(() => { + vi.advanceTimersByTime(300); + }); + rerender({ value: 'hope' }); + act(() => { + vi.advanceTimersByTime(200); + }); + expect(result.current).toBe('loved'); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe('hope'); + }); }); diff --git a/packages/ui/package.json b/packages/ui/package.json index f0646cd4..f9d88380 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -69,7 +69,8 @@ "react-i18next": "^17.0.0", "tailwind-merge": "3.3.1", "tw-animate-css": "1.4.0", - "xstate": "5.32.4" + "xstate": "5.32.4", + "zod": "4.1.12" }, "peerDependencies": { "react": ">=19.1.0 <20.0.0", diff --git a/packages/ui/src/components/bible-reader-controlled.test.tsx b/packages/ui/src/components/bible-reader-controlled.test.tsx index 50809895..8111d4e4 100644 --- a/packages/ui/src/components/bible-reader-controlled.test.tsx +++ b/packages/ui/src/components/bible-reader-controlled.test.tsx @@ -118,6 +118,15 @@ function defaultOverrides(): HookOverrides { }), useFilteredVersions: () => [], useOrganizations: () => ({ organizations: new Map() }), + useBibleSearch: () => ({ + query: '', + phase: { kind: 'trending', queries: [], loading: false }, + setQuery: () => undefined, + submit: () => undefined, + selectSuggestion: () => undefined, + loadMore: () => undefined, + retry: () => undefined, + }), }; } diff --git a/packages/ui/src/components/bible-reader-navigation.test.tsx b/packages/ui/src/components/bible-reader-navigation.test.tsx new file mode 100644 index 00000000..e6a90711 --- /dev/null +++ b/packages/ui/src/components/bible-reader-navigation.test.tsx @@ -0,0 +1,188 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { expect, it, vi } from 'vitest'; +import { StrictMode } from 'react'; +import { BibleReader, useBibleReaderContext } from './bible-reader'; +import { BibleReaderNavigation } from './bible-reader-navigation'; +import { HookOverrideProvider } from '@/test/hook-overrides'; +import type { HookOverrides } from '@youversion/platform-react-hooks'; + +function CurrentDestination() { + const { book, chapter, versionId, verseFocus } = useBibleReaderContext(); + return ( + + {JSON.stringify({ book, chapter, versionId, verseFocus })} + + ); +} + +it('consumes the newest pre-mount request once and supports mounted cross-version, range and in-place focus requests', () => { + const navigation = new BibleReaderNavigation(); + const changed = vi.fn(); + navigation.request({ versionId: 111, passageId: 'ROM.8.28' }); + navigation.request({ versionId: 3034, passageId: 'JHN.3.16-18' }); + const overrides: HookOverrides = { + useBooks: () => ({ books: null, loading: false, error: null, refetch: () => {} }), + }; + const jsx = ( + + + + + + + + ); + const view = render(jsx); + const destination = () => + JSON.parse(screen.getByRole('status', { name: 'destination' }).textContent ?? '{}'); + expect(destination()).toMatchObject({ + versionId: 3034, + book: 'JHN', + chapter: '3', + verseFocus: { + verses: [16, 17, 18], + showsFullChapter: false, + shouldFocus: false, + }, + }); + expect(changed).toHaveBeenCalledTimes(1); + act(() => navigation.focusReference({ versionId: 3034, passageId: 'JHN.3.17' }, false)); + expect(destination().verseFocus).toMatchObject({ + verses: [17], + scrollsToVerse: false, + shouldFocus: true, + }); + act(() => navigation.focusReference({ versionId: 111, passageId: 'ROM.8.28' }, false)); + expect(destination()).toMatchObject({ versionId: 3034, book: 'JHN', chapter: '3' }); + act(() => navigation.focusReference({ versionId: 111, passageId: 'ROM.8.28' })); + expect(destination()).toMatchObject({ + versionId: 111, + book: 'ROM', + chapter: '8', + verseFocus: { + verses: [28], + showsFullChapter: true, + scrollsToVerse: true, + shouldFocus: true, + }, + }); + view.unmount(); + render(jsx); + expect(destination()).toMatchObject({ + versionId: 111, + book: 'JHN', + chapter: '1', + verseFocus: null, + }); +}); + +it('renders passage-only and full-chapter destinations through the reader fetch', async ({ + onTestFinished, +}) => { + const scrollTo = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTo'); + Element.prototype.scrollTo = vi.fn(); + onTestFinished(() => { + Object.defineProperty( + Element.prototype, + 'scrollTo', + scrollTo ?? { value: undefined, configurable: true, writable: true }, + ); + }); + const navigation = new BibleReaderNavigation(); + const requested = vi.fn(); + const overrides: HookOverrides = { + useBooks: () => ({ books: null, loading: false, error: null, refetch: () => {} }), + useVersion: () => ({ version: null, loading: false, error: null, refetch: () => {} }), + usePassage: (props) => { + requested(props.versionId, props.usfm); + return { passage: null, loading: true, error: null, refetch: () => {} }; + }, + }; + navigation.request({ versionId: 3034, passageId: 'JHN.3.16-18' }); + render( + + + + + + + , + ); + await waitFor(() => expect(requested).toHaveBeenLastCalledWith(3034, 'JHN.3.16-18')); + expect( + requested.mock.calls.every( + ([version, passage]) => version === 3034 && passage === 'JHN.3.16-18', + ), + ).toBe(true); + act(() => navigation.request({ versionId: 111, passageId: 'ROM.8.28' }, true)); + await waitFor(() => expect(requested).toHaveBeenLastCalledWith(111, 'ROM.8')); +}); + +it('waits for controlled destinations and never revives an activated passage or focus after leaving it', ({ + onTestFinished, +}) => { + const scrollTo = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTo'); + Element.prototype.scrollTo = vi.fn(); + onTestFinished(() => { + Object.defineProperty( + Element.prototype, + 'scrollTo', + scrollTo ?? { value: undefined, configurable: true, writable: true }, + ); + }); + const navigation = new BibleReaderNavigation(); + navigation.request({ versionId: 3034, passageId: 'JHN.3.16' }); + const requested = vi.fn(); + const changed = vi.fn(); + const overrides: HookOverrides = { + useBooks: () => ({ books: null, loading: false, error: null, refetch: () => {} }), + useVersion: () => ({ version: null, loading: false, error: null, refetch: () => {} }), + usePassage: (props) => { + if (props.options?.enabled) requested(props.versionId, props.usfm); + return { passage: null, loading: true, error: null, refetch: () => {} }; + }, + }; + const reader = (versionId: number, book: string, chapter: string) => ( + + + + + + + + + ); + const view = render(reader(111, 'ROM', '8')); + const destination = () => + JSON.parse(screen.getByRole('status', { name: 'destination' }).textContent ?? '{}'); + expect(requested).not.toHaveBeenCalled(); + expect(changed.mock.calls).toEqual([[3034], ['JHN'], ['3']]); + // A host can round-trip the controlled fields separately. + view.rerender(reader(3034, 'ROM', '8')); + expect(requested).not.toHaveBeenCalled(); + view.rerender(reader(3034, 'JHN', '3')); + expect(requested).toHaveBeenLastCalledWith(3034, 'JHN.3.16'); + view.rerender(reader(3034, 'JHN', '4')); + expect(destination().verseFocus).toBeNull(); + view.rerender(reader(3034, 'JHN', '3')); + expect(requested).toHaveBeenLastCalledWith(3034, 'JHN.3'); + act(() => navigation.focusReference({ versionId: 111, passageId: 'ROM.8.28' })); + expect(destination().verseFocus).toMatchObject({ passageId: 'ROM.8.28', shouldFocus: true }); + view.rerender(reader(111, 'JHN', '3')); + expect(destination().verseFocus).not.toBeNull(); + view.rerender(reader(111, 'ROM', '8')); + expect(requested).toHaveBeenLastCalledWith(111, 'ROM.8'); + view.rerender(reader(3034, 'ROM', '8')); + expect(destination().verseFocus).toBeNull(); + view.rerender(reader(111, 'ROM', '8')); + expect(destination().verseFocus).toBeNull(); +}); diff --git a/packages/ui/src/components/bible-reader-navigation.ts b/packages/ui/src/components/bible-reader-navigation.ts new file mode 100644 index 00000000..b94b257e --- /dev/null +++ b/packages/ui/src/components/bible-reader-navigation.ts @@ -0,0 +1,75 @@ +import { parseUsfmReference } from '@youversion/platform-core'; + +export type BibleReaderReference = Readonly<{ + versionId: number; + passageId: string; +}>; + +/** @internal */ +export type BibleReaderNavigationRequest = BibleReaderReference & + Readonly<{ + book: string; + chapter: string; + verses: readonly number[]; + showsFullChapter: boolean; + scrollsToVerse: boolean; + shouldFocus: boolean; + }>; + +/** + * A host-owned connection to `BibleReader.Root` via its `navigation` prop. + * Create once and retain it outside the reader. Before mount, the newest request + * replaces any pending request. Mounting consumes that request once. + */ +export class BibleReaderNavigation { + private pending: BibleReaderNavigationRequest | null = null; + private listeners = new Set<() => void>(); + + /** Show a passage, or its full chapter. Ordinary navigation does not focus. */ + request(reference: BibleReaderReference, showsFullChapter = false): void { + this.enqueue(reference, showsFullChapter, true, false); + } + + /** + * Show the full chapter, scroll to the passage, then focus its verses. + * With `scrollsToVerse: false`, focus only if that chapter/version is already + * displayed, without navigating or changing the scroll position. + */ + focusReference(reference: BibleReaderReference, scrollsToVerse = true): void { + this.enqueue(reference, true, scrollsToVerse, true); + } + + private enqueue( + reference: BibleReaderReference, + showsFullChapter: boolean, + scrollsToVerse: boolean, + shouldFocus: boolean, + ): void { + const parsed = parseUsfmReference(reference.passageId); + if (parsed === null || !Number.isSafeInteger(reference.versionId) || reference.versionId <= 0) { + throw new Error('Reader navigation requires a valid passage ID and Bible version ID'); + } + this.pending = { ...reference, ...parsed, showsFullChapter, scrollsToVerse, shouldFocus }; + for (const listener of this.listeners) listener(); + } + + /** @internal Read without consuming during render; consume only after commit. */ + peek(): BibleReaderNavigationRequest | null { + return this.pending; + } + + /** @internal */ + consume(): BibleReaderNavigationRequest | null { + const request = this.pending; + this.pending = null; + return request; + } + + /** @internal */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } +} diff --git a/packages/ui/src/components/bible-reader-search.stories.tsx b/packages/ui/src/components/bible-reader-search.stories.tsx new file mode 100644 index 00000000..c775d63d --- /dev/null +++ b/packages/ui/src/components/bible-reader-search.stories.tsx @@ -0,0 +1,331 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, screen, userEvent, waitFor, within } from 'storybook/test'; +import { BibleReader } from './bible-reader'; +import { useState } from 'react'; +import { BibleReaderNavigation } from './bible-reader-navigation'; +import { Button } from './ui/button'; +import { delay, http, HttpResponse } from 'msw'; +import { globalHandlers } from '@/test/mocks/handlers'; + +const meta = { + title: 'Components/BibleReaderSearch', + component: BibleReader.Root, + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const OpenTrending: Story = { + tags: ['integration'], + args: { + defaultVersionId: 111, + defaultBook: 'JHN', + defaultChapter: '1', + }, + render: (args) => ( +
+ + + + +
+ ), + play: async ({ canvasElement }) => { + await waitFor( + async () => { + const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + await expect(verseContainer).toBeInTheDocument(); + }, + { timeout: 5000 }, + ); + + const searchButton = await waitFor( + () => screen.getByRole('button', { name: 'Search the Bible' }), + { timeout: 5000 }, + ); + await userEvent.click(searchButton); + + await waitFor(async () => { + await expect( + within(screen.getByRole('region', { name: 'Trending Searches' })).getByRole('button', { + name: 'love', + }), + ).toBeInTheDocument(); + }); + }, +}; + +/** Host-owned navigation created before the reader mounts. */ +function NavigationExample() { + const [navigation] = useState(() => { + const connection = new BibleReaderNavigation(); + connection.focusReference({ versionId: 111, passageId: 'JHN.1.51' }); + return connection; + }); + return ( +
+ +
+ + + + +
+
+ ); +} + +export const NavigateAndFocus: Story = { + tags: ['integration'], + render: () => , + play: async ({ canvasElement }) => { + let focused: HTMLElement | null = null; + await waitFor( + async () => { + focused = canvasElement.querySelector('.yv-v-focused[v="51"]'); + await expect(focused).not.toBeNull(); + }, + { timeout: 5000 }, + ); + const scroller = canvasElement.querySelector('main'); + await expect(scroller).not.toBeNull(); + await expect(scroller!.scrollTop).toBeGreaterThan(0); + const bounds = focused!.getBoundingClientRect(); + const viewport = scroller!.getBoundingClientRect(); + await expect(bounds.top).toBeGreaterThanOrEqual(viewport.top); + await expect(bounds.bottom).toBeLessThanOrEqual(viewport.bottom); + await expect(focused).toHaveFocus(); + // A pointer left over the verse can apply its ordinary hover background. + await userEvent.unhover(focused!); + const surrounding = canvasElement.querySelector('.yv-v[v="50"]')!; + await waitFor(async () => { + await expect(getComputedStyle(focused!).opacity).toBe('1'); + await expect(getComputedStyle(focused!).backgroundColor).toBe('rgba(0, 0, 0, 0)'); + await expect(getComputedStyle(surrounding).opacity).toBe('0.35'); + }); + // Native smooth-scroll events must not clear focus after landing. + await new Promise((resolve) => setTimeout(resolve, 250)); + await expect(focused).toHaveClass('yv-v-focused'); + scroller!.dispatchEvent(new WheelEvent('wheel', { deltaY: 100, bubbles: true })); + await waitFor(() => expect(focused).not.toHaveClass('yv-v-focused')); + }, +}; + +export const SearchFailure: Story = { + ...OpenTrending, + tags: ['integration'], + parameters: { + msw: { + handlers: [ + http.get('*/v1/search-verses', () => new HttpResponse(null, { status: 503 })), + ...globalHandlers, + ], + }, + }, + play: async (context) => { + await OpenTrending.play?.(context); + await userEvent.click( + within(screen.getByRole('region', { name: 'Trending Searches' })).getByRole('button', { + name: 'love', + }), + ); + await expect(await screen.findByRole('alert')).toBeVisible(); + await expect(screen.getByRole('button', { name: 'Try again' })).toBeEnabled(); + await expect(screen.getByRole('button', { name: 'Back to search' })).toBeEnabled(); + }, +}; + +export const SearchAndReturn: Story = { + ...OpenTrending, + tags: ['integration'], + parameters: { + msw: { + handlers: [ + http.get('*/v1/search-verses', () => + HttpResponse.json({ + verses: [{ reference: 'JHN.1' }, { reference: 'JHN.1.51' }], + did_you_mean: [], + search_instead_for: null, + next_page_token: null, + }), + ), + ...globalHandlers, + ], + }, + }, + play: async (context) => { + await OpenTrending.play?.(context); + const input = screen.getByRole('textbox', { name: 'Search the Bible' }); + await expect(input).toHaveFocus(); + await userEvent.type(input, 'angels{Enter}'); + const result = await screen.findByRole('button', { name: /John 1:51/i }); + await expect( + screen.queryByRole('button', { name: /^John 1(?:\s|$)/i }), + ).not.toBeInTheDocument(); + await userEvent.click(result); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(context.canvasElement.querySelector('.yv-v[v="51"]')).toHaveFocus()); + await userEvent.click(screen.getByRole('button', { name: 'Search the Bible' })); + await expect(screen.getByRole('textbox')).toHaveValue(''); + const recents = within(screen.getByRole('region', { name: 'Recent Searches' })); + await waitFor(() => expect(recents.getByRole('button', { name: 'angels' })).toBeVisible()); + await userEvent.click(screen.getByRole('button', { name: 'Close search' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Search the Bible' })).toHaveFocus(), + ); + await userEvent.click(screen.getByRole('button', { name: 'Search the Bible' })); + await expect(screen.getByRole('dialog')).not.toHaveAttribute('aria-modal', 'true'); + await userEvent.click(screen.getByRole('button', { name: 'Settings' })); + await waitFor(() => + expect(screen.queryByRole('textbox', { name: 'Search the Bible' })).not.toBeInTheDocument(), + ); + }, +}; + +export const EmptyResults: Story = { + ...OpenTrending, + tags: ['integration'], + parameters: { + msw: { + handlers: [ + http.get('*/v1/search-verses', () => + HttpResponse.json({ verses: [{ reference: 'JHN.1' }], did_you_mean: [] }), + ), + ...globalHandlers, + ], + }, + }, + play: async (context) => { + await OpenTrending.play?.(context); + await userEvent.type(screen.getByRole('textbox'), 'no matches{Enter}'); + await waitFor(() => + expect(screen.queryByRole('status', { name: 'Loading' })).not.toBeInTheDocument(), + ); + await expect(screen.getByRole('status')).toBeVisible(); + await expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + await expect(screen.getByRole('textbox')).toHaveValue('no matches'); + }, +}; + +export const SearchLoading: Story = { + ...OpenTrending, + tags: ['integration'], + parameters: { + msw: { + handlers: [ + http.get('*/v1/search-verses', async () => { + await delay('infinite'); + return HttpResponse.json({ verses: [], did_you_mean: [] }); + }), + ...globalHandlers, + ], + }, + }, + play: async (context) => { + await OpenTrending.play?.(context); + await userEvent.type(screen.getByRole('textbox'), 'patience{Enter}'); + await expect(screen.getByRole('status', { name: 'Loading' })).toBeVisible(); + await expect(screen.getByRole('button', { name: 'Back to search' })).toBeEnabled(); + }, +}; + +export const VersePreviewsLoading: Story = { + ...OpenTrending, + tags: ['integration'], + parameters: { + msw: { + handlers: [ + http.get('*/v1/search-verses', async () => { + await delay(500); + return HttpResponse.json({ + verses: [{ reference: 'JHN.3.16' }], + did_you_mean: [], + }); + }), + http.get('*/v1/bibles/:versionId/passages/:passageId', async ({ request }) => { + if (new URL(request.url).searchParams.get('format') !== 'text') return; + await delay('infinite'); + return HttpResponse.json({}); + }), + ...globalHandlers, + ], + }, + }, + play: async (context) => { + await OpenTrending.play?.(context); + await Promise.all( + screen + .getByRole('dialog') + .getAnimations() + .map((animation) => animation.finished), + ); + await userEvent.type(screen.getByRole('textbox'), 'love{Enter}'); + const searchSpinnerTop = screen + .getByRole('status', { name: 'Loading' }) + .parentElement!.getBoundingClientRect().top; + await waitFor(() => + expect(screen.getByRole('dialog').querySelector('li[hidden]')).not.toBeNull(), + ); + await expect(screen.getAllByRole('status', { name: 'Loading' })).toHaveLength(1); + await expect( + screen.getByRole('status', { name: 'Loading' }).parentElement!.getBoundingClientRect().top, + ).toBe(searchSpinnerTop); + await expect( + within(screen.getByRole('dialog')).queryByRole('button', { name: /John 3:16/i }), + ).not.toBeInTheDocument(); + }, +}; + +export const TestamentFilters: Story = { + ...OpenTrending, + tags: ['integration'], + parameters: { + msw: { + handlers: [ + http.get('*/v1/search-verses', ({ request }) => { + const next = new URL(request.url).searchParams.has('page_token'); + return HttpResponse.json({ + verses: [{ reference: next ? 'GEN.1.1' : 'JHN.3.16' }], + did_you_mean: [], + next_page_token: next ? null : 'second', + }); + }), + http.get('*/v1/bibles/111/passages/GEN.1.1', () => + HttpResponse.json({ + id: 'GEN.1.1', + reference: 'Genesis 1:1', + content: 'In the beginning God created the heavens and the earth.', + }), + ), + ...globalHandlers, + ], + }, + }, + play: async (context) => { + await OpenTrending.play?.(context); + await userEvent.type(screen.getByRole('textbox'), 'love{Enter}'); + await waitFor(() => expect(screen.getByRole('button', { name: /John 3:16/i })).toBeVisible()); + await expect(screen.getByRole('button', { name: 'Filters' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + await userEvent.click(screen.getByRole('button', { name: 'Filters' })); + await userEvent.click(screen.getByRole('button', { name: 'Old Testament' })); + await waitFor(() => expect(screen.getByRole('button', { name: /Genesis 1:1/i })).toBeVisible()); + await expect(screen.queryByRole('button', { name: /John 3:16/i })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'New Testament' })); + await waitFor(() => expect(screen.getByRole('button', { name: /John 3:16/i })).toBeVisible()); + await expect(screen.queryByRole('button', { name: /Genesis 1:1/i })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Back to search' })); + await expect(screen.getByRole('textbox')).toHaveValue(''); + await expect(screen.getByRole('textbox')).toHaveFocus(); + await expect(screen.getByRole('button', { name: 'Close search' })).toBeVisible(); + await expect(screen.queryByRole('button', { name: 'Clear search' })).not.toBeInTheDocument(); + }, +}; diff --git a/packages/ui/src/components/bible-reader-search.test.tsx b/packages/ui/src/components/bible-reader-search.test.tsx new file mode 100644 index 00000000..02a606d3 --- /dev/null +++ b/packages/ui/src/components/bible-reader-search.test.tsx @@ -0,0 +1,693 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState, type ReactElement } from 'react'; +import type { BibleBook, BiblePassage, BibleVersion } from '@youversion/platform-core'; +import type { + BibleSearchPhase, + BibleSearchResult, + HookOverrides, + UseBibleSearchResult, +} from '@youversion/platform-react-hooks'; +import { HookOverrideProvider } from '@/test/hook-overrides'; +import { installResizeObserverStub } from '@/test/dom-stubs'; +import { RECENT_SEARCHES_KEY } from '@/lib/use-recent-searches'; +import { BibleReader } from './bible-reader'; +import { BibleReaderSearch } from './bible-reader-search'; + +installResizeObserverStub(); + +const john316: BibleSearchResult = { + id: 'JHN.3.16', + book: 'JHN', + chapter: '3', + verses: [16], +}; + +const mockBooks: BibleBook[] = [ + { + id: 'JHN', + title: 'John', + full_title: 'The Gospel According to John', + canon: 'new_testament', + abbreviation: 'John', + chapters: [ + { id: '1', title: '1', passage_id: 'JHN.1' }, + { id: '3', title: '3', passage_id: 'JHN.3' }, + ], + }, +]; + +const mockVersion: BibleVersion = { + id: 111, + localized_abbreviation: 'NIV', + abbreviation: 'NIV', + title: 'New International Version', + localized_title: 'New International Version', + language_tag: 'en', + books: ['JHN'], + youversion_deep_link: 'https://bible.com/versions/111', +}; + +const mockPassage: BiblePassage = { + id: 'JHN.3.16', + content: 'For God so loved the world', + reference: 'John 3:16 NIV', +}; + +const idleActions = { + setQuery: () => undefined, + submit: () => undefined, + selectSuggestion: () => undefined, + loadMore: () => undefined, + retry: () => undefined, +}; + +function searchOf(phase: BibleSearchPhase, query = ''): UseBibleSearchResult { + return { query, phase, ...idleActions }; +} + +function baseOverrides(search: UseBibleSearchResult): HookOverrides { + return { + useBooks: () => ({ + books: { data: [...mockBooks], next_page_token: null }, + loading: false, + error: null, + refetch: () => undefined, + }), + useVersion: () => ({ + version: mockVersion, + loading: false, + error: null, + refetch: () => undefined, + }), + usePassage: () => ({ + passage: mockPassage, + loading: false, + error: null, + refetch: () => undefined, + }), + useLanguages: () => ({ + languages: { data: [], next_page_token: null }, + loading: false, + error: null, + refetch: () => undefined, + }), + useLanguage: () => ({ + language: { id: 'en', language: 'English', display_names: { en: 'English' } }, + loading: false, + error: null, + refetch: () => undefined, + }), + useVersions: () => ({ + versions: { data: [], next_page_token: null }, + loading: false, + error: null, + refetch: () => undefined, + }), + useFilteredVersions: () => [], + useOrganizations: () => ({ organizations: new Map() }), + useBibleSearch: () => search, + }; +} + +function renderSearch(search: UseBibleSearchResult, extra?: ReactElement) { + return render( + + + + {extra} + + , + ); +} + +function installControlledIntersectionObserver() { + const original = globalThis.IntersectionObserver; + const observed = new Map(); + globalThis.IntersectionObserver = class implements IntersectionObserver { + readonly root = null; + readonly rootMargin = '200px 0px'; + readonly scrollMargin = '0px'; + readonly thresholds = [0]; + disconnect(): void {} + observe(target: Element): void { + observed.set(target, this.callback); + } + unobserve(target: Element): void { + observed.delete(target); + } + takeRecords(): IntersectionObserverEntry[] { + return []; + } + constructor(private readonly callback: IntersectionObserverCallback) {} + }; + return { + intersect(target: Element) { + const callback = observed.get(target); + if (!callback) throw new Error('element is not observed'); + callback( + // SAFETY: the callback only reads isIntersecting; target identifies the controlled row. + [{ isIntersecting: true, target } as IntersectionObserverEntry], + // SAFETY: neither visibility callback reads the observer argument. + {} as IntersectionObserver, + ); + }, + restore() { + globalThis.IntersectionObserver = original; + }, + }; +} + +describe('BibleReaderSearch', () => { + it('maps testament controls to book metadata and returns to discovery with one X action', async () => { + const observed = vi.fn(); + const overrides = baseOverrides(searchOf({ kind: 'empty' })); + overrides.useBooks = () => ({ + books: { + data: [ + ...mockBooks, + { ...mockBooks[0]!, id: 'GEN', title: 'Genesis', canon: 'old_testament' }, + { ...mockBooks[0]!, id: 'TOB', title: 'Tobit', canon: 'deuterocanon' }, + ], + next_page_token: null, + }, + loading: false, + error: null, + refetch: () => undefined, + }); + overrides.useBibleSearch = function useSearchFixture(props) { + const [query, setQuery] = useState('love'); + observed(props.bookIds); + return { + ...searchOf( + query + ? { kind: 'results', verses: [john316], nextPage: 'none' } + : { kind: 'trending', queries: [], loading: false }, + ), + query, + setQuery, + }; + }; + const user = userEvent.setup(); + render( + + + + + , + ); + const filters = screen.getByRole('button', { name: 'Filters' }); + expect(filters).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('group', { name: 'Filter by testament' })).not.toBeInTheDocument(); + const disclosure = document.getElementById(filters.getAttribute('aria-controls')!)! + .parentElement!.parentElement!; + expect(disclosure).toHaveAttribute('inert'); + filters.focus(); + await user.keyboard('{Enter}'); + expect(filters).toHaveAttribute('aria-expanded', 'true'); + expect(disclosure).not.toHaveAttribute('inert'); + expect(disclosure).toHaveClass('yv:transition-none'); + expect(filters).toHaveAttribute( + 'aria-controls', + screen.getByRole('group', { name: 'Filter by testament' }).id, + ); + expect(screen.getByRole('button', { name: 'Both' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.queryByRole('button', { name: 'Close search' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Old Testament' })); + expect(observed).toHaveBeenLastCalledWith(['GEN']); + await user.click(screen.getByRole('button', { name: 'New Testament' })); + expect(observed).toHaveBeenLastCalledWith(['JHN']); + await user.click(filters); + expect(filters).toHaveAttribute('aria-expanded', 'false'); + expect(disclosure).toHaveAttribute('inert'); + expect(disclosure).not.toHaveClass('yv:transition-none'); + expect(filters).toHaveClass('yv:bg-foreground', 'yv:text-background'); + expect(filters).toHaveAccessibleName('Filters'); + expect(filters).toHaveAccessibleDescription('New Testament'); + expect(observed).toHaveBeenLastCalledWith(['JHN']); + expect(screen.queryByRole('button', { name: 'New Testament' })).not.toBeInTheDocument(); + await user.click(filters); + expect(filters).not.toHaveClass('yv:bg-foreground'); + expect(filters).toHaveClass('yv:bg-muted'); + expect(filters.querySelector('svg')).not.toHaveClass('yv:rotate-180'); + expect(screen.getByRole('button', { name: 'New Testament' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await user.click(screen.getByRole('button', { name: 'Both' })); + expect(observed).toHaveBeenLastCalledWith(undefined); + await user.click(filters); + expect(filters).not.toHaveClass('yv:bg-foreground'); + expect(filters).not.toHaveClass('yv:bg-muted'); + await user.click(filters); + await user.click(screen.getByRole('button', { name: 'New Testament' })); + await user.click(screen.getByRole('button', { name: 'Back to search' })); + expect(observed).toHaveBeenLastCalledWith(undefined); + expect(screen.getByRole('textbox')).toHaveValue(''); + expect(screen.getByRole('textbox')).toHaveFocus(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Trending Searches' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Clear search' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Close search' })).toBeInTheDocument(); + await user.type(screen.getByRole('textbox'), 'hope'); + expect(screen.getByRole('button', { name: 'Filters' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + await user.click(screen.getByRole('button', { name: 'Filters' })); + expect(screen.getByRole('button', { name: 'Both' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('lists trending queries as keyboard-operable buttons', () => { + renderSearch( + searchOf({ + kind: 'trending', + queries: [{ text: 'love' }, { text: 'hope' }], + loading: false, + }), + ); + + const trending = within(screen.getByRole('region', { name: 'Trending Searches' })); + expect(trending.getByRole('button', { name: 'love' })).toBeInTheDocument(); + expect(trending.getByRole('button', { name: 'hope' })).toBeInTheDocument(); + }); + + it('uses a text input and one custom clear control when the query is non-empty', () => { + renderSearch( + searchOf( + { + kind: 'suggesting', + queries: [{ text: 'love of God' }], + loading: false, + debouncing: false, + }, + 'love', + ), + ); + + expect(screen.getByRole('textbox', { name: 'Search the Bible' })).toHaveAttribute( + 'type', + 'text', + ); + expect(screen.getAllByRole('button', { name: 'Clear search' })).toHaveLength(1); + expect(screen.queryByRole('button', { name: 'Back to search' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Close search' })).not.toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Search the Bible' })).not.toHaveAttribute( + 'maxlength', + ); + }); + + it('provides a visible keyboard-operable close action', async () => { + const user = userEvent.setup(); + renderSearch(searchOf({ kind: 'trending', queries: [], loading: false })); + + const close = screen.getByRole('button', { name: 'Close search' }); + close.focus(); + await user.keyboard('{Enter}'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('lists suggestions as buttons', () => { + renderSearch( + searchOf( + { + kind: 'suggesting', + queries: [{ text: 'love of God' }], + loading: false, + debouncing: false, + }, + 'love', + ), + ); + + expect(screen.getByRole('button', { name: 'love of God' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + }); + + it('shows a status spinner while searching', () => { + renderSearch(searchOf({ kind: 'searching' }, 'love')); + + expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); + + it('renders verse rows and load more when results are available', async () => { + const loadMore = vi.fn(); + const search: UseBibleSearchResult = { + ...searchOf({ + kind: 'results', + verses: [john316], + nextPage: 'available', + }), + loadMore, + }; + const user = userEvent.setup(); + renderSearch(search); + + expect(screen.getByRole('button', { name: /john 3:16/i })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Load more' })); + expect(loadMore).toHaveBeenCalledTimes(1); + }); + + it('reveals previews together and retains earlier results while the next batch loads or fails', () => { + const second = { ...john316, id: 'JHN.3.17', verses: [17] }; + const third = { ...john316, id: 'JHN.3.18', verses: [18] }; + function fixture( + verses: [BibleSearchResult, ...BibleSearchResult[]], + pending: string[], + failed: string[] = [], + ) { + const overrides = baseOverrides(searchOf({ kind: 'results', verses, nextPage: 'none' })); + overrides.usePassage = ({ usfm }) => ({ + passage: + pending.includes(usfm) || failed.includes(usfm) + ? null + : { + ...mockPassage, + id: usfm, + reference: usfm, + content: `Preview ${usfm}`, + }, + loading: pending.includes(usfm), + error: failed.includes(usfm) ? new Error('preview failed') : null, + refetch: () => undefined, + }); + return ( + + + + + + ); + } + const view = render(fixture([john316, second], [second.id])); + expect(screen.getAllByRole('status', { name: 'Loading' })).toHaveLength(1); + expect(screen.queryByRole('button', { name: /Preview/ })).not.toBeInTheDocument(); + view.rerender(fixture([john316, second], [])); + expect(screen.queryByRole('status', { name: 'Loading' })).not.toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /Preview/ })).toHaveLength(2); + view.rerender(fixture([john316, second, third], [third.id])); + expect(screen.getAllByRole('status', { name: 'Loading' })).toHaveLength(1); + expect(screen.getAllByRole('button', { name: /Preview/ })).toHaveLength(2); + expect(screen.queryByRole('button', { name: /3:18/ })).not.toBeInTheDocument(); + view.rerender(fixture([john316, second, third], [], [third.id])); + expect(screen.queryByRole('status', { name: 'Loading' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /John 3:18/ })).toBeEnabled(); + }); + + it('shows only the first three trending queries and the latest three recents', () => { + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(['peace', 'joy', 'faith', 'older'])); + const view = renderSearch( + searchOf({ + kind: 'trending', + loading: false, + queries: ['love', 'hope', 'grace', 'fourth'].map((text) => ({ text })), + }), + ); + const trending = within(screen.getByRole('region', { name: 'Trending Searches' })); + const recent = within(screen.getByRole('region', { name: 'Recent Searches' })); + expect(trending.getAllByRole('button').map((button) => button.textContent)).toEqual([ + 'love', + 'hope', + 'grace', + ]); + expect(recent.getAllByRole('button').map((button) => button.textContent)).toEqual([ + 'peace', + 'joy', + 'faith', + ]); + view.unmount(); + localStorage.removeItem(RECENT_SEARCHES_KEY); + }); + + it('auto-loads near the final five rows once and stops when pagination is exhausted', () => { + const observer = installControlledIntersectionObserver(); + const loadMore = vi.fn(); + const generatedVerses = Array.from({ length: 8 }, (_, index) => ({ + ...john316, + id: `JHN.3.${index + 1}`, + verses: [index + 1], + })); + const verses: [BibleSearchResult, ...BibleSearchResult[]] = [ + generatedVerses[0]!, + ...generatedVerses.slice(1), + ]; + const search = { + ...searchOf({ kind: 'results' as const, verses, nextPage: 'available' as const }), + loadMore, + }; + const view = renderSearch(search); + const rows = screen.getAllByRole('button', { name: /john 3:/i }); + + // Jumping to the bottom must work even if the fifth-last row was skipped. + act(() => observer.intersect(rows[7]!.closest('li')!)); + act(() => observer.intersect(rows[3]!.closest('li')!)); + expect(loadMore).toHaveBeenCalledTimes(1); + + const next = (nextPage: 'available' | 'loading' | 'none') => + view.rerender( + + + + + , + ); + next('loading'); + act(() => observer.intersect(rows[3]!.closest('li')!)); + expect(loadMore).toHaveBeenCalledTimes(1); + // A duplicate-only page can retain the same rows but advance the token. + next('available'); + act(() => observer.intersect(rows[3]!.closest('li')!)); + expect(loadMore).toHaveBeenCalledTimes(2); + next('none'); + act(() => observer.intersect(rows[3]!.closest('li')!)); + expect(loadMore).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument(); + observer.restore(); + }); + + it('keeps the dialog open with no list when the verse search is empty', () => { + renderSearch(searchOf({ kind: 'empty' }, 'xyzzy')); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent( + "We're sorry, there are no Bible results for this search.", + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /john 3:16/i })).not.toBeInTheDocument(); + }); + + it('retries a failed verse search', async () => { + const retry = vi.fn(); + const search: UseBibleSearchResult = { + ...searchOf({ kind: 'failed', error: new Error('network') }, 'love'), + retry, + }; + const user = userEvent.setup(); + renderSearch(search); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Try again' })); + expect(retry).toHaveBeenCalledTimes(1); + }); + + it('records submissions, not typing or IME confirmation, and reopens with history and input focus', async () => { + localStorage.removeItem(RECENT_SEARCHES_KEY); + const submit = vi.fn(); + const selectSuggestion = vi.fn(); + function useSearchFixture(): UseBibleSearchResult { + const [query, setQuery] = useState(''); + return { + ...searchOf({ kind: 'trending', queries: [{ text: 'hope' }], loading: false }), + query, + setQuery, + submit, + selectSuggestion: (text) => { + selectSuggestion(text); + setQuery(text); + }, + }; + } + const overrides = baseOverrides(searchOf({ kind: 'trending', queries: [], loading: false })); + overrides.useBibleSearch = useSearchFixture; + const user = userEvent.setup(); + const view = render( + + + + + , + ); + await user.click(screen.getByRole('button', { name: 'Search the Bible' })); + const input = screen.getByRole('textbox'); + expect(input).toHaveFocus(); + await user.type(input, ' Love '); + expect(localStorage.getItem(RECENT_SEARCHES_KEY)).toBeNull(); + fireEvent.keyDown(input, { key: 'Enter', isComposing: true }); + expect(submit).not.toHaveBeenCalled(); + await user.keyboard('{Enter}'); + expect(submit).toHaveBeenCalledTimes(1); + expect(JSON.parse(localStorage.getItem(RECENT_SEARCHES_KEY)!)).toEqual(['Love']); + await user.click(screen.getByRole('button', { name: 'Clear search' })); + expect(input).toHaveValue(''); + expect(input).toHaveFocus(); + await user.click(screen.getByRole('button', { name: 'hope' })); + expect(selectSuggestion).toHaveBeenCalledWith('hope'); + await user.keyboard('{Escape}'); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Search the Bible' })).toHaveFocus(), + ); + await user.keyboard('{Enter}'); + expect(screen.getByRole('textbox')).toHaveValue(''); + const recents = within(screen.getByRole('region', { name: 'Recent Searches' })); + expect(recents.getAllByRole('button').map((button) => button.textContent)).toEqual([ + 'hope', + 'Love', + ]); + expect(recents.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument(); + await user.click(recents.getByRole('button', { name: 'Love' })); + expect(selectSuggestion).toHaveBeenLastCalledWith('Love'); + view.unmount(); + localStorage.removeItem(RECENT_SEARCHES_KEY); + }); + + it('keeps recent queries usable when trending fails and retries discovery independently', async () => { + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(['peace'])); + const retry = vi.fn(); + const selectSuggestion = vi.fn(); + const view = renderSearch({ + ...searchOf({ kind: 'trending', queries: [], loading: false, error: new Error('offline') }), + retry, + selectSuggestion, + }); + const user = userEvent.setup(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Try again' })); + expect(retry).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: 'peace' })); + expect(selectSuggestion).toHaveBeenCalledWith('peace'); + view.unmount(); + localStorage.removeItem(RECENT_SEARCHES_KEY); + }); + + it('retains results and query during pagination failure, including selectable failed previews', async () => { + const retry = vi.fn(); + const overrides = baseOverrides({ + ...searchOf({ kind: 'results', verses: [john316], nextPage: 'failed' }, 'love'), + retry, + }); + overrides.usePassage = () => ({ + passage: null, + loading: false, + error: new Error('preview'), + refetch: () => undefined, + }); + render( + + + + + , + ); + const user = userEvent.setup(); + expect(screen.getByRole('textbox')).toHaveValue('love'); + const result = screen.getByRole('button', { name: /john 3:16/i }); + await user.click(screen.getByRole('button', { name: 'Try again' })); + expect(retry).toHaveBeenCalledTimes(1); + expect(result).toBeEnabled(); + await user.click(result); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('navigates to the selected verse and closes the dialog', async () => { + function Harness() { + const [book, setBook] = useState('JHN'); + const [chapter, setChapter] = useState('1'); + return ( + + + +

{`${book}.${chapter}`}

+
+
+ ); + } + + const user = userEvent.setup(); + render(); + + expect(screen.getByTestId('location')).toHaveTextContent('JHN.1'); + await user.click(screen.getByRole('button', { name: /john 3:16/i })); + expect(screen.getByTestId('location')).toHaveTextContent('JHN.3'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); + +describe('BibleReader.Toolbar search control', () => { + it('passes serializable reader context to an async override without opening built-in search', async () => { + const onSearchPress = vi.fn(async () => undefined); + const user = userEvent.setup(); + render( + + + + + , + ); + await user.click(screen.getByRole('button', { name: 'Search the Bible' })); + expect(onSearchPress).toHaveBeenCalledExactlyOnceWith({ + versionId: 111, + book: 'JHN', + chapter: '3', + }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('omits the search trigger when search is none', () => { + render( + + + + + , + ); + + expect(screen.queryByRole('button', { name: 'Search the Bible' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/bible-reader-search.tsx b/packages/ui/src/components/bible-reader-search.tsx new file mode 100644 index 00000000..28543877 --- /dev/null +++ b/packages/ui/src/components/bible-reader-search.tsx @@ -0,0 +1,689 @@ +'use client'; + +import i18n from '@/i18n'; +import { buildVerseReference } from '@/lib/verse-share'; +import { useRecentSearches } from '@/lib/use-recent-searches'; +import { cn } from '@/lib/utils'; +import { useControllableState } from '@radix-ui/react-use-controllable-state'; +import type { BibleBook } from '@youversion/platform-core'; +import { + useBibleSearch, + usePassage, + type BibleSearchResult, + type UseBibleSearchResult, +} from '@youversion/platform-react-hooks'; +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type ReactElement, + type RefCallback, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { useBibleReaderContext } from './bible-reader'; +import { SearchIcon } from './icons/search'; +import { XIcon } from './icons/x'; +import { LoaderIcon } from './icons/loader'; +import { FlameIcon } from './icons/flame'; +import { ClockBackIcon } from './icons/clock-back'; +import { ChevronBackwardIcon } from './icons/chevron-backward'; +import { Button } from './ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; +import { InputGroup, InputGroupAddon, InputGroupInput } from './ui/input-group'; + +export type BibleReaderSearchProps = { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +/** Serializable snapshot for host-owned search, including Expo DOM callbacks. */ +export type BibleReaderSearchPressData = { + versionId: number; + book: string; + chapter: string; +}; + +type SearchQueryItem = Readonly<{ text: string }>; + +type PassageText = + | { readonly status: 'loading' } + | { readonly status: 'ready'; readonly value: string } + | { readonly status: 'unavailable' }; + +/** + * Trigger, popover, suggestion list, result list, and navigation on select. + * Must render inside `BibleReader.Root`. + * + * Zero required props. Optional open control is for a host-owned trigger. + * Fresh session on open comes from unmounting popover content on close. + */ +export function BibleReaderSearch({ + open: openProp, + defaultOpen, + onOpenChange, +}: BibleReaderSearchProps): ReactElement { + const { t } = useTranslation(undefined, { i18n }); + const { background, onSearchPress, versionId, book, chapter } = useBibleReaderContext(); + const history = useRecentSearches(); + const navigating = useRef(false); + const [open, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen ?? false, + onChange: onOpenChange, + }); + + if (onSearchPress) { + return ( + + ); + } + + return ( + + + + + { + if (navigating.current) event.preventDefault(); + navigating.current = false; + }} + > + setOpen(false)} + onNavigate={() => { + navigating.current = true; + setOpen(false); + }} + /> + + + ); +} + +function SearchPanel({ + onClose, + onNavigate, + recentSearches, + rememberSearch, +}: { + onClose: () => void; + onNavigate: () => void; + recentSearches: readonly string[]; + rememberSearch: (query: string) => void; +}): ReactElement { + const { t } = useTranslation(undefined, { i18n }); + const { versionId, navigation, booksData, booksLoading } = useBibleReaderContext(); + const [testament, setTestament] = useState<'both' | 'old_testament' | 'new_testament'>('both'); + const [filtersOpen, setFiltersOpen] = useState(false); + const [animateFilters, setAnimateFilters] = useState(false); + const filtersId = useId(); + const testamentOptions = [ + ['old_testament', t('oldTestamentHeading', 'Old Testament')], + ['new_testament', t('newTestamentHeading', 'New Testament')], + ['both', t('bibleSearchBothTestaments', 'Both')], + ] as const; + const search = useBibleSearch({ + versionId, + bookIds: + testament === 'both' + ? undefined + : booksData.filter((book) => book.canon === testament).map((book) => book.id), + }); + const inputRef = useRef(null); + const scrollRef = useRef(null); + const hasQuery = search.query !== ''; + const showingResults = !['trending', 'suggesting'].includes(search.phase.kind); + const resetSearch = (): void => { + search.setQuery(''); + setTestament('both'); + setFiltersOpen(false); + inputRef.current?.focus(); + if (scrollRef.current) scrollRef.current.scrollTop = 0; + }; + + const onPick = (text: string): void => { + rememberSearch(text); + search.selectSuggestion(text); + inputRef.current?.focus(); + if (scrollRef.current) scrollRef.current.scrollTop = 0; + }; + + const onSelectVerse = (verse: BibleSearchResult): void => { + onNavigate(); + navigation.focusReference({ versionId, passageId: verse.id }); + }; + + return ( +
+
+ {showingResults ? ( + + ) : null} + + + + + search.setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault(); + rememberSearch(search.query); + search.submit(); + if (scrollRef.current) scrollRef.current.scrollTop = 0; + } + }} + /> + {search.query !== '' ? ( + + + + ) : null} + + {!hasQuery ? ( + + ) : null} +
+ {showingResults ? ( +
+
+

{t('bibleSearchBibleHeading', 'Bible')}

+ +
+
+
+
+ {testamentOptions.map(([value, label]) => ( + + ))} +
+
+
+
+ ) : null} +
+ +
+
+ ); +} + +function SearchPhaseBody({ + search, + recentSearches, + onPick, + versionId, + booksData, + onSelectVerse, +}: { + search: UseBibleSearchResult; + recentSearches: readonly string[]; + onPick: (text: string) => void; + versionId: number; + booksData: readonly BibleBook[]; + onSelectVerse: (verse: BibleSearchResult) => void; +}): ReactElement { + const { t } = useTranslation(undefined, { i18n }); + const phase = search.phase; + + switch (phase.kind) { + case 'trending': + return ( +
+ + {recentSearches.length > 0 ? ( + ({ text }))} + busy={false} + onPick={onPick} + /> + ) : null} +
+ ); + case 'suggesting': + return ( + + ); + case 'searching': + return ( +
+ +
+ ); + case 'results': + return ( + + ); + case 'empty': + return ( +

+ {t('noBibleSearchResults')} +

+ ); + case 'failed': + return ( +
+

+ {t('bibleSearchFailure', "We couldn't complete this search. Try again.")} +

+ +
+ ); + default: { + const _exhaustive: never = phase; + return _exhaustive; + } + } +} + +function QueryList({ + heading, + icon, + items, + busy, + error, + onRetry, + onPick, +}: { + heading: string; + icon?: 'trending' | 'recent'; + items: readonly SearchQueryItem[]; + busy: boolean; + error?: Error; + onRetry?: () => void; + onPick: (text: string) => void; +}): ReactElement { + const { t } = useTranslation(undefined, { i18n }); + const headingId = useId(); + const Icon = icon === 'trending' ? FlameIcon : icon === 'recent' ? ClockBackIcon : SearchIcon; + return ( +
+

+ {heading} +

+ {busy ? ( +
+ +
+ ) : error ? ( +
+

+ {t( + 'bibleSearchQueriesFailure', + "We couldn't load search suggestions. You can still search by typing a query.", + )} +

+ +
+ ) : ( +
    + {items.map((item) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +function VerseResults({ + verses, + nextPage, + versionId, + booksData, + onLoadMore, + onRetry, + onSelect, +}: { + verses: readonly [BibleSearchResult, ...BibleSearchResult[]]; + nextPage: 'none' | 'available' | 'loading' | 'failed'; + versionId: number; + booksData: readonly BibleBook[]; + onLoadMore: () => void; + onRetry: () => void; + onSelect: (verse: BibleSearchResult) => void; +}): ReactElement { + const { t } = useTranslation(undefined, { i18n }); + const requested = useRef(false); + const [settled, setSettled] = useState>(() => new Set()); + const [revealed, setRevealed] = useState>(() => new Set()); + const onSettled = useCallback((id: string) => { + setSettled((previous) => (previous.has(id) ? previous : new Set([...previous, id]))); + }, []); + const previewsLoading = verses.some((verse) => !settled.has(verse.id)); + const hasVisibleResults = verses.some((verse) => revealed.has(verse.id)); + useEffect(() => { + if (!previewsLoading) setRevealed(new Set(verses.map((verse) => verse.id))); + }, [previewsLoading, verses]); + useEffect(() => { + if (nextPage !== 'available') requested.current = false; + }, [nextPage]); + const loadMoreWhenNearEnd = useCallback(() => { + if (nextPage !== 'available' || requested.current) return; + requested.current = true; + onLoadMore(); + }, [nextPage, onLoadMore]); + const paginationTriggerRef = useIntersectionCallback(loadMoreWhenNearEnd); + const paginationTriggerIndex = Math.max(0, verses.length - 5); + + return ( +
+
    + {verses.map((verse, index) => ( + + ))} +
+ {nextPage === 'available' && !previewsLoading ? ( + + ) : null} + {nextPage === 'loading' || previewsLoading ? ( +
+ +
+ ) : null} + {nextPage === 'failed' ? ( +
+

+ {t('bibleSearchFailure', "We couldn't complete this search. Try again.")} +

+ +
+ ) : null} +
+ ); +} + +function verseRowLabel(verse: BibleSearchResult, bookName: string | undefined): string { + if (bookName === undefined) return verse.id; + if (verse.verses.length === 0) { + return `${bookName} ${verse.chapter}`; + } + return buildVerseReference({ + bookName, + chapter: verse.chapter, + verses: [...verse.verses], + versionAbbreviation: '', + }); +} + +function SearchVerseRow({ + verse, + versionId, + bookName, + onSettled, + onSelect, +}: { + verse: BibleSearchResult; + versionId: number; + bookName: string | undefined; + onSettled: (id: string) => void; + onSelect: () => void; +}): ReactElement { + const { passage, loading, error } = usePassage({ + versionId, + usfm: verse.id, + format: 'text', + include_headings: false, + include_notes: false, + options: { keepPreviousData: false }, + }); + useEffect(() => { + if (!loading) onSettled(verse.id); + }, [loading, onSettled, verse.id]); + + const text: PassageText = loading + ? { status: 'loading' } + : error !== null || passage === null + ? { status: 'unavailable' } + : { status: 'ready', value: passage.content }; + + const label = verseRowLabel(verse, bookName); + + return ( + + ); +} + +const PREFETCH_ROOT_MARGIN = '200px 0px'; + +function useIntersectionCallback(onIntersect: () => void): RefCallback { + return useCallback>( + (element) => { + if (element === null || globalThis.IntersectionObserver === undefined) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) onIntersect(); + }, + { rootMargin: PREFETCH_ROOT_MARGIN }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, + [onIntersect], + ); +} diff --git a/packages/ui/src/components/bible-reader.test.tsx b/packages/ui/src/components/bible-reader.test.tsx index b900fe7c..dacdbae4 100644 --- a/packages/ui/src/components/bible-reader.test.tsx +++ b/packages/ui/src/components/bible-reader.test.tsx @@ -66,6 +66,15 @@ function defaultOverrides(): HookOverrides { }), useFilteredVersions: () => [], useOrganizations: () => ({ organizations: new Map() }), + useBibleSearch: () => ({ + query: '', + phase: { kind: 'trending', queries: [], loading: false }, + setQuery: () => undefined, + submit: () => undefined, + selectSuggestion: () => undefined, + loadMore: () => undefined, + retry: () => undefined, + }), }; } diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index e653560e..65dcb95f 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -60,6 +60,9 @@ import { buildVerseReference, buildVerseShareText, joinVerseTexts } from '@/lib/ import { isHighlightsLive } from '@/lib/feature-flags'; import { YvComponentStyles } from '@/lib/yv-styles-components'; import { YouVersionPlatformConfiguration } from '@youversion/platform-core'; +import { BibleReaderSearch, type BibleReaderSearchPressData } from './bible-reader-search'; +import { useTransientVerseFocus, type VerseFocusRequest } from '@/lib/use-transient-verse-focus'; +import { BibleReaderNavigation } from './bible-reader-navigation'; type BibleReaderContextType = { book: string; @@ -81,6 +84,7 @@ type BibleReaderContextType = { onFootnotePress?: (data: FootnoteData) => void; onChapterPickerPress?: (data: BibleChapterPickerPressData) => void; onVersionPickerPress?: (data: BibleVersionPickerPressData) => void; + onSearchPress?: (data: BibleReaderSearchPressData) => void | Promise; languageId?: string; defaultLanguageId?: string; onLanguageChange?: (languageId: string) => void; @@ -95,6 +99,8 @@ type BibleReaderContextType = { onHighlightRemove?: (intent: BibleReaderHighlightIntent) => void; verseActions: 'popover' | 'none'; clearSelectionSignal?: number; + navigation: BibleReaderNavigation; + verseFocus: VerseFocusRequest | null; scriptureDirection?: TextDirection; }; @@ -180,7 +186,7 @@ export type BibleReaderShareData = { const BibleReaderContext = createContext(null); -function useBibleReaderContext() { +export function useBibleReaderContext(): BibleReaderContextType { const context = useContext(BibleReaderContext); if (!context) { throw new Error('BibleReader components must be used within BibleReader.Root'); @@ -189,6 +195,8 @@ function useBibleReaderContext() { } export type RootProps = { + /** Retain this connection outside the reader to request navigation before mount. */ + navigation?: BibleReaderNavigation; book?: string; defaultBook?: string; onBookChange?: (book: string) => void; @@ -218,6 +226,8 @@ export type RootProps = { onFootnotePress?: (data: FootnoteData) => void; onChapterPickerPress?: (data: BibleChapterPickerPressData) => void; onVersionPickerPress?: (data: BibleVersionPickerPressData) => void; + /** Replaces the built-in search popover with host-owned search. */ + onSearchPress?: (data: BibleReaderSearchPressData) => void | Promise; /** * Bible translation language for the version picker (`en`, `es`, …). * Controlled when set with `onLanguageChange`. @@ -451,6 +461,7 @@ export function createBibleThemeSettingsContentHandlers(options: { } function Root({ + navigation: navigationProp, book: controlledBook, defaultBook = 'JHN', onBookChange, @@ -475,6 +486,7 @@ function Root({ onFootnotePress, onChapterPickerPress, onVersionPickerPress, + onSearchPress, languageId, defaultLanguageId, onLanguageChange, @@ -496,24 +508,45 @@ function Root({ // render as "no highlights", never fall through to the self-contained path. const isHighlightsControlled = useHighlightsControlledLatch(highlights, 'BibleReader.Root'); - const [book, setBook] = useControllableState({ + const [localNavigation] = useState(() => new BibleReaderNavigation()); + const navigation = navigationProp ?? localNavigation; + const [initialNavigation, setInitialNavigation] = useState(() => { + const target = navigation.peek(); + if ( + target && + !target.scrollsToVerse && + (target.versionId !== (controlledVersionId ?? defaultVersionId) || + target.book !== (controlledBook ?? defaultBook) || + target.chapter !== (controlledChapter ?? defaultChapter)) + ) + return null; + return target; + }); + + const [currentBook, setBook] = useControllableState({ prop: controlledBook, defaultProp: defaultBook, onChange: onBookChange, }); - const [chapter, setChapter] = useControllableState({ + const [currentChapter, setChapter] = useControllableState({ prop: controlledChapter, defaultProp: defaultChapter, onChange: onChapterChange, }); - const [versionId, setVersionId] = useControllableState({ + const [currentVersionId, setVersionId] = useControllableState({ prop: controlledVersionId, defaultProp: defaultVersionId, onChange: onVersionChange, }); + // Show the queued destination on the first render without consuming it during + // render. Controlled values still belong to the host; Content waits for them. + const book = controlledBook ?? initialNavigation?.book ?? currentBook; + const chapter = controlledChapter ?? initialNavigation?.chapter ?? currentChapter; + const versionId = controlledVersionId ?? initialNavigation?.versionId ?? currentVersionId; + const validatedDefaultFontSize = defaultFontSize > MAX_FONT_SIZE || defaultFontSize < MIN_FONT_SIZE ? DEFAULT_FONT_SIZE @@ -635,13 +668,65 @@ function Root({ const { books, loading: booksLoading } = useBooks(versionId); const booksData = books?.data ?? []; + const [verseFocus, setVerseFocus] = useState(() => + initialNavigation ? { ...initialNavigation, seq: 0 } : null, + ); + const [activatedFocus, setActivatedFocus] = useState(null); + const atFocusDestination = + verseFocus?.versionId === versionId && + verseFocus.book === book && + verseFocus.chapter === chapter; + if (verseFocus !== null && atFocusDestination && activatedFocus !== verseFocus) { + setActivatedFocus(verseFocus); + } else if (verseFocus !== null && !atFocusDestination && activatedFocus === verseFocus) { + // Clear before children render when controlled props leave an activated + // destination. A request awaiting its first host round-trip stays pending. + setVerseFocus(null); + setActivatedFocus(null); + } + const verseFocusSeqRef = useRef(0); + useEffect(() => { + const consume = (): void => { + const target = navigation.consume(); + if (target === null) return; + setInitialNavigation(null); + if ( + !target.scrollsToVerse && + (target.versionId !== versionId || target.book !== book || target.chapter !== chapter) + ) + return; + verseFocusSeqRef.current += 1; + setVersionId(target.versionId); + setBook(target.book); + setChapter(target.chapter); + if (target !== initialNavigation) { + setVerseFocus({ + ...target, + seq: verseFocusSeqRef.current, + }); + } + }; + const unsubscribe = navigation.subscribe(consume); + consume(); + return unsubscribe; + }, [navigation, initialNavigation, versionId, book, chapter, setVersionId, setBook, setChapter]); + const contextValue: BibleReaderContextType = { book, chapter, versionId, - setBook, - setChapter, - setVersionId, + setBook: (value) => { + setVerseFocus(null); + setBook(value); + }, + setChapter: (value) => { + setVerseFocus(null); + setChapter(value); + }, + setVersionId: (value) => { + setVerseFocus(null); + setVersionId(value); + }, booksData, booksLoading, currentFontFamily, @@ -655,6 +740,7 @@ function Root({ onFootnotePress, onChapterPickerPress, onVersionPickerPress, + onSearchPress, languageId, defaultLanguageId, onLanguageChange, @@ -669,6 +755,8 @@ function Root({ onHighlightRemove, verseActions, clearSelectionSignal, + navigation, + verseFocus, scriptureDirection, }; @@ -709,6 +797,7 @@ function Content() { onHighlightRemove, verseActions, clearSelectionSignal, + verseFocus, scriptureDirection, } = useBibleReaderContext(); const { version } = useVersion(versionId); @@ -717,7 +806,18 @@ function Content() { return booksData.find((b) => b.id === book); }, [booksData, book]); - const usfmReference = `${book}.${chapter}`; + const chapterReference = `${book}.${chapter}`; + const activeNavigation = + verseFocus?.versionId === versionId && + verseFocus.book === book && + verseFocus.chapter === chapter + ? verseFocus + : null; + const usfmReference = + activeNavigation && !activeNavigation.showsFullChapter + ? activeNavigation.passageId + : chapterReference; + const awaitingNavigation = verseFocus !== null && activeNavigation === null; // Check if the current chapter is available in this version const chapterUnavailable = useMemo(() => { @@ -730,16 +830,19 @@ function Content() { // Own the passage fetch here (instead of BibleTextView) to control the loading // treatment. Args mirror BibleTextView's internal fetch so the cache key matches. const { - passage, - loading: passageLoading, - error: passageError, + passage: fetchedPassage, + loading: fetchingPassage, + error: fetchedPassageError, } = usePassage({ versionId, usfm: usfmReference, include_headings: true, include_notes: true, - options: { enabled: !chapterUnavailable }, + options: { enabled: !chapterUnavailable && !awaitingNavigation }, }); + const passage = awaitingNavigation ? null : fetchedPassage; + const passageLoading = awaitingNavigation || fetchingPassage; + const passageError = awaitingNavigation ? null : fetchedPassageError; const resolvedScriptureDirection = useResolvedScriptureDirection( passage?.content, scriptureDirection, @@ -750,7 +853,7 @@ function Content() { // Version-only changes intentionally preserve scroll position. const scrollContainerRef = useRef(null); - useEffect(() => { + useLayoutEffect(() => { scrollContainerRef.current?.scrollTo({ top: 0 }); }, [book, chapter]); @@ -761,6 +864,12 @@ function Content() { // dark-launched behind HIGHLIGHTS_LIVE. The reader DOM ref anchors the // popover and supplies clean verse text for Copy / Share. const readerRef = useRef(null); + const renderedReference = !passageLoading && !passageError && passage ? usfmReference : ''; + useTransientVerseFocus({ + request: activeNavigation, + renderedReference, + containerRef: readerRef, + }); const [selectedVerses, setSelectedVerses] = useState([]); const [popoverOpen, setPopoverOpen] = useState(false); const [anchorElement, setAnchorElement] = useState(null); @@ -824,7 +933,7 @@ function Content() { shareData: null, }); } - }, [book, chapter, versionId]); + }, [book, chapter, versionId, activeNavigation?.seq]); // Distinct colors present in the current selection → drives the X (remove) circles. const activeHighlights = useMemo( @@ -1391,9 +1500,18 @@ export function BibleThemeSettingsContent({ export type BibleReaderToolbarProps = { border?: 'top' | 'bottom'; onOpenBibleThemeSettings?: (snapshot: BibleThemeSettingsSnapshot) => void; + /** + * Whether the toolbar renders the built-in search control. + * `'control'` (default) | `'none'`. Mirrors `RootProps.verseActions`. + */ + search?: 'control' | 'none'; }; -function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolbarProps) { +function Toolbar({ + border = 'top', + onOpenBibleThemeSettings, + search = 'control', +}: BibleReaderToolbarProps) { const { t } = useTranslation(undefined, { i18n }); const interfaceDirection = useInterfaceDirection(); const { @@ -1489,6 +1607,7 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba const nextResult = getAdjacentChapter(booksData, book, chapter, 'next'); const canNavigatePrevious = !booksLoading && prevResult !== null; const canNavigateNext = !booksLoading && nextResult !== null; + const showSearch = search !== 'none'; return (
{yvContext?.authEnabled && } @@ -1616,6 +1739,8 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba + {showSearch ? : null} + {onOpenBibleThemeSettings ? (