diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index da23ec25..579dddd1 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -199,6 +199,12 @@ "%interlinearizer_segmentation_lostBoundaries%": "The source text changed, so {count} of your segment boundary changes no longer fit it and aren't applied.", "%interlinearizer_segmentation_lostBoundaries_one%": "The source text changed, so one of your segment boundary changes no longer fits it and isn't applied.", "%interlinearizer_segmentation_lostBoundaries_dismiss%": "Dismiss this warning", + "%interlinearizer_staleNotice_glossesAndFreeTranslations%": "{glossCount} glosses and {freeTranslationCount} free translations in this book no longer match the text.", + "%interlinearizer_staleNotice_glosses%": "{count} glosses in this book no longer match the text.", + "%interlinearizer_staleNotice_freeTranslations%": "{count} free translations in this book no longer match the text.", + "%interlinearizer_staleNotice_reviewGlosses%": "Review glosses", + "%interlinearizer_staleNotice_nextFreeTranslation%": "Next free translation", + "%interlinearizer_staleNotice_dismiss%": "Dismiss this notice", "%interlinearizer_phraseBox_glossLabel%": "Phrase gloss", "%interlinearizer_phraseBox_edit%": "Edit phrase", "%interlinearizer_phraseBox_unlink%": "Unlink phrase", diff --git a/src/__tests__/components/AnalysisCatalogPanel.test.tsx b/src/__tests__/components/AnalysisCatalogPanel.test.tsx index 8d8f9845..6d818e1e 100644 --- a/src/__tests__/components/AnalysisCatalogPanel.test.tsx +++ b/src/__tests__/components/AnalysisCatalogPanel.test.tsx @@ -1931,6 +1931,59 @@ describe('AnalysisCatalogPanel', () => { expect(listedAnalysisIds()).toEqual(['stale']); }); + describe('narrowed by its owner', () => { + /** An analysis applied at its place, and another stale at its. */ + const LIVE_AND_STALE: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'live', surfaceText: 'λόγος' }, + { ...FIXTURE_STAMPS, id: 'stale', surfaceText: 'ἦν' }, + ], + tokenAnalysisLinks: [link('live', 'GEN 1:1:0'), link('stale', 'GEN 1:2:0', 'stale')], + }; + + it('keeps only the rows the filters it is handed keep', () => { + const ref = createRef(); + renderPanel({ ref, analysis: LIVE_AND_STALE }); + + act(() => ref.current?.filterTo({ stale: true })); + + expect(listedAnalysisIds()).toEqual(['stale']); + }); + + it('replaces the filters the reader had set', async () => { + const ref = createRef(); + const analysis: TextAnalysis = { + ...LIVE_AND_STALE, + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'live', surfaceText: 'λόγος' }, + { ...FIXTURE_STAMPS, id: 'stale', surfaceText: 'ἦν', gloss: { en: 'was' } }, + ], + }; + renderPanel({ ref, analysis, analysisLanguage: 'en' }); + await openFilters(); + await userEvent.click( + screen.getByRole('checkbox', { + name: '%interlinearizer_analysisCatalog_filter_missingGloss%', + }), + ); + + act(() => ref.current?.filterTo({ stale: true })); + + expect(listedAnalysisIds()).toEqual(['stale']); + }); + + it('clears a search that would hide those rows', async () => { + const ref = createRef(); + renderPanel({ ref, analysis: LIVE_AND_STALE }); + await userEvent.type(searchBox(), 'λόγος'); + + act(() => ref.current?.filterTo({ stale: true })); + + expect(searchBox()).toHaveValue(''); + }); + }); + describe('over an unsaved breakdown', () => { /** Types a re-segmentation into an expanded row's breakdown without saving it. */ async function typeUnsavedBreakdown(analysisId = 'ta-1'): Promise { diff --git a/src/__tests__/components/AnalysisStore.test.tsx b/src/__tests__/components/AnalysisStore.test.tsx index 63e9899a..04de237f 100644 --- a/src/__tests__/components/AnalysisStore.test.tsx +++ b/src/__tests__/components/AnalysisStore.test.tsx @@ -28,6 +28,7 @@ import { useSegmentsWithApprovedTranslation, useStaleFreeTranslationDispatch, useStaleFreeTranslationsBySegment, + useStaleTokenLinks, useGloss, useGlossDispatch, useMorphemeBreakdownDispatch, @@ -1084,6 +1085,17 @@ describe('useStaleFreeTranslationsBySegment', () => { }); }); +describe('useStaleTokenLinks', () => { + it('lists the places analyses went stale at', () => { + const stale: TokenAnalysisLink = { ...approvedLink('ta-1', 'tok-2'), status: 'stale' }; + const { result } = renderStoreHook(() => useStaleTokenLinks(), { + initialAnalysis: twoHomographs([approvedLink('ta-1', 'tok-1'), stale]), + }); + + expect(result.current).toEqual([stale]); + }); +}); + describe('useSegmentFreeTranslationDispatch adopting a stale translation', () => { it('persists the stale translation a value was typed over, approved', () => { const onSave = jest.fn(); diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index c99b2349..d0cc0b2c 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -18,7 +18,7 @@ import { useState as useReactState } from 'react'; import type { Dispatch, ReactNode, SetStateAction } from 'react'; import { useStore } from 'react-redux'; import { useAnalysis, useGlossDispatch, usePhraseDispatch } from '../../components/AnalysisStore'; -import { useInterlinearNav } from '../../components/InterlinearNavContext'; +import { useInterlinearNav, type RecenterRequest } from '../../components/InterlinearNavContext'; import InterlinearizerLoader, { BOOK_NAME_TIMEOUT_MS, UNDO_NOTIFICATION_DURATION_MS, @@ -30,6 +30,8 @@ import useLexiconRegistry from '../../hooks/useLexiconRegistry'; import useLostBoundaryDismissal from '../../hooks/useLostBoundaryDismissal'; import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting'; import useProjectBookIds from '../../hooks/useProjectBookIds'; +import useStaleAnalysisDismissal from '../../hooks/useStaleAnalysisDismissal'; +import type { StaleAnalysesReport } from '../../components/StaleAnalysesReporter'; import type { OpenableProject } from '../../hooks/useDraftProject'; import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; import { PT9_MANIFEST_TIMEOUT_MS } from '../../utils/pt9-manifest'; @@ -56,6 +58,20 @@ jest.mock('../../hooks/useLexiconRegistry'); jest.mock('../../hooks/useLostBoundaryDismissal'); jest.mock('../../hooks/useOptimisticBooleanSetting'); jest.mock('../../hooks/useProjectBookIds'); +jest.mock('../../hooks/useStaleAnalysisDismissal'); + +/** What the loader last handed the stale-analyses reporter, `undefined` until it mounts one. */ +let capturedReporterProps: + { book: Book; onReport: (report: StaleAnalysesReport | undefined) => void } | undefined; + +// Stubbed to record where the loader mounts it, which is all these tests ask of it. +jest.mock('../../components/StaleAnalysesReporter', () => ({ + __esModule: true, + default: (props: NonNullable) => { + capturedReporterProps = props; + return undefined; + }, +})); jest.mock('../../components/BookNotInProjectView', () => ({ __esModule: true, @@ -254,6 +270,12 @@ let probeCreatePhrase: ((tokens: TokenSnapshot[]) => string) | undefined; /** The token a pending focus request names in GEN, read by the probe. */ let probeFocusRequest: string | undefined; +/** The latest recenter request, read by the probe. */ +let probeRecenterRequest: RecenterRequest | undefined; + +/** Publishes the word the reader focused, as the book view does. */ +let probePublishFocus: ((tokenRef: string | undefined) => void) | undefined; + /** Writes a gloss through the store the probe is mounted in. */ let probeWriteGloss: ((tokenRef: string, surfaceText: string, value: string) => void) | undefined; @@ -267,6 +289,8 @@ function StoreProbe() { probeWriteGloss = useGlossDispatch(); probeCreatePhrase = usePhraseDispatch().createPhrase; probeFocusRequest = useInterlinearNav().peekFocusRequest('GEN'); + probeRecenterRequest = useInterlinearNav().recenterRequest; + probePublishFocus = useInterlinearNav().publishedFocus.publish; return undefined; } @@ -683,6 +707,29 @@ function mockLostBoundaries(undismissedLostBoundaries: readonly string[]): jest. return onDismiss; } +/** + * Serves the stale analyses the notice has not been dismissed for, none unless given. + * + * @returns The dismissal handler served. + */ +function mockStaleAnalyses( + undismissed: Partial<{ + glossCount: number; + freeTranslationCount: number; + freeTranslationSegmentIds: readonly string[]; + }> = {}, +): jest.Mock { + const onDismiss = jest.fn(); + jest.mocked(useStaleAnalysisDismissal).mockReturnValue({ + glossCount: 0, + freeTranslationCount: 0, + freeTranslationSegmentIds: [], + ...undismissed, + onDismiss, + }); + return onDismiss; +} + /** * Serves a lexicon registry offering `openChooser`, or none when it is omitted - which is a project * that already has a lexicon, or one with no lexicon software to reach. Only the chooser is served: @@ -711,6 +758,8 @@ describe('InterlinearizerLoader', () => { mockBookData(); mockOptimisticSetting(); mockLostBoundaries([]); + mockStaleAnalyses(); + capturedReporterProps = undefined; mockLexiconRegistry(); // The loader's draft hook calls `interlinearizer.getDraft` on mount; default to a valid empty // draft so the editor renders. Individual tests override with mockResolvedValueOnce. @@ -3304,6 +3353,417 @@ describe('InterlinearizerLoader', () => { }); }); + describe('stale analyses notice', () => { + /** Genesis 1:1–3, verse 2 emptied of its words. */ + const THREE_VERSE_BOOK: Book = { + ...GEN_1_1_BOOK, + segments: [ + makeSegment('GEN 1:1', 'In the beginning.', [makeWordToken('GEN 1:1:0', 'In')]), + makeSegment('GEN 1:2', '', []), + makeSegment('GEN 1:3', 'And God said.', [makeWordToken('GEN 1:3:0', 'And')]), + ], + }; + + /** Genesis 1:1–3, verse 2 resuming after a mid-verse heading with only its closing stop. */ + const RESUMED_PUNCTUATION_BOOK: Book = { + ...GEN_1_1_BOOK, + segments: [ + makeSegment('GEN 1:1', 'In the beginning.', [makeWordToken('GEN 1:1:0', 'In')]), + makeSegment('GEN 1:2', 'God said', [makeWordToken('GEN 1:2:0', 'God')]), + { + ...makeSegment('GEN 1:2', '.', [makePunctToken('GEN 1:2:9')]), + id: 'GEN 1:2:9', + startRef: { book: 'GEN', chapter: 1, verse: 2, charIndex: 9 }, + }, + makeSegment('GEN 1:3', 'And God said.', [makeWordToken('GEN 1:3:0', 'And')]), + ], + }; + + /** + * Renders the loader on `book` parked at `verseNum` of Genesis 1, the scroll group's reference + * held in state so each step starts from where the last one took the reader. + * + * @returns A way to move the reader to another verse of Genesis 1. + */ + async function renderWithHeldScrRef( + book: Book, + verseNum: number, + ): Promise<(toVerse: number) => void> { + mountStoreProbe = true; + mockBookData({ book }); + let setHeldScrRef: (scrRef: SerializedVerseRef) => void = () => {}; + const useScrollGroup = (): ScrollGroupTuple => { + // eslint-disable-next-line react-hooks/rules-of-hooks -- stands in for the host's hook + const [scrRef, setScrRef] = useReactState({ + book: 'GEN', + chapterNum: 1, + verseNum, + }); + setHeldScrRef = setScrRef; + return [scrRef, setScrRef, undefined, () => {}, undefined]; + }; + await act(async () => { + renderLoader({ useWebViewScrollGroupScrRef: useScrollGroup }); + }); + return (toVerse) => + act(() => setHeldScrRef({ book: 'GEN', chapterNum: 1, verseNum: toVerse })); + } + + /** A stale place of `analysisId` at `tokenRef`, whose word read differently when glossed. */ + function staleAt(analysisId: string, tokenRef: string) { + return { + ...FIXTURE_STAMPS, + analysisId, + status: 'stale' as const, + token: { tokenRef, surfaceText: 'Formerly' }, + }; + } + + /** Analyses stale in GEN and in EXO, and another applied in GEN. */ + const STALE_IN_TWO_BOOKS: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-gen', surfaceText: 'Formerly' }, + { ...FIXTURE_STAMPS, id: 'ta-exo', surfaceText: 'Formerly' }, + { ...FIXTURE_STAMPS, id: 'ta-live', surfaceText: 'In' }, + ], + tokenAnalysisLinks: [ + staleAt('ta-gen', 'GEN 1:1:0'), + staleAt('ta-exo', 'EXO 1:1:0'), + { + ...FIXTURE_STAMPS, + analysisId: 'ta-live', + status: 'approved', + token: { tokenRef: 'GEN 1:1:0', surfaceText: 'In' }, + }, + ], + }; + + afterEach(() => { + mountStoreProbe = false; + }); + + /** + * Renders the loader on a loaded book, parked on GEN 1:1 unless a `scrRef` elsewhere asks for a + * cross-book swap. + * + * @returns The spy on the scroll group's reference setter. + */ + async function renderOnLoadedBook( + options: { book?: Book; scrRef?: SerializedVerseRef } = {}, + ): Promise { + const setScrRef = jest.fn(); + mockBookData({ book: options.book ?? GEN_1_1_BOOK }); + await act(async () => { + renderLoader({ + useWebViewScrollGroupScrRef: makeScrollGroupHook(options.scrRef, setScrRef), + }); + }); + return setScrRef; + } + + /** Overrides one localized template, echoing every other key. */ + function mockTemplate(key: string, template: string): void { + mockKeyAsValueLocalizedStrings({ [key]: template }); + } + + it('reports on the loaded book from inside the draft’s store', async () => { + await renderOnLoadedBook(); + + expect(capturedReporterProps?.book).toBe(GEN_1_1_BOOK); + }); + + it('reports on no book while one is loading', async () => { + mockBookData({ book: undefined, isLoading: true }); + + await act(async () => { + renderLoader(); + }); + + expect(capturedReporterProps).toBeUndefined(); + }); + + it('tracks the dismissal of what the reporter finds', async () => { + await renderOnLoadedBook(); + const report: StaleAnalysesReport = { + bookRef: 'GEN', + glosses: ['gloss ta-1 GEN 1:1:0'], + freeTranslations: [], + }; + + act(() => capturedReporterProps?.onReport(report)); + + expect(jest.mocked(useStaleAnalysisDismissal)).toHaveBeenLastCalledWith( + expect.objectContaining({ report }), + ); + }); + + it('shows the notice while stale analyses it has not been dismissed for remain', async () => { + mockStaleAnalyses({ glossCount: 1 }); + + await renderOnLoadedBook(); + + expect(screen.getByTestId('stale-analyses-banner')).toBeInTheDocument(); + }); + + it('shows no notice when none remain', async () => { + await renderOnLoadedBook(); + + expect(screen.queryByTestId('stale-analyses-banner')).not.toBeInTheDocument(); + }); + + it('holds the notice back until the localized strings resolve', async () => { + jest + .mocked(useLocalizedStrings) + .mockImplementation((keys: readonly string[]) => [ + Object.fromEntries(keys.map((k) => [k, k])), + true, + ]); + mockStaleAnalyses({ glossCount: 1 }); + + await renderOnLoadedBook(); + + expect(screen.queryByTestId('stale-analyses-banner')).not.toBeInTheDocument(); + }); + + it('holds the notice back during a cross-book swap', async () => { + mockStaleAnalyses({ glossCount: 1 }); + + await renderOnLoadedBook({ scrRef: { book: 'EXO', chapterNum: 1, verseNum: 1 } }); + + expect(screen.queryByTestId('stale-analyses-banner')).not.toBeInTheDocument(); + }); + + it('counts both kinds when the book has stale glosses and free translations', async () => { + mockTemplate( + '%interlinearizer_staleNotice_glossesAndFreeTranslations%', + '{glossCount} and {freeTranslationCount}', + ); + mockStaleAnalyses({ + glossCount: 3, + freeTranslationCount: 1, + freeTranslationSegmentIds: ['GEN 1:1'], + }); + + await renderOnLoadedBook(); + + expect(screen.getByTestId('stale-analyses-banner')).toHaveTextContent('3 and 1'); + }); + + it('counts only the glosses when the book has no stale free translation', async () => { + mockTemplate('%interlinearizer_staleNotice_glosses%', '{count} glosses'); + mockStaleAnalyses({ glossCount: 2 }); + + await renderOnLoadedBook(); + + expect(screen.getByTestId('stale-analyses-banner')).toHaveTextContent('2 glosses'); + }); + + it('counts only the free translations when the book has no stale gloss', async () => { + mockTemplate('%interlinearizer_staleNotice_freeTranslations%', '{count} translations'); + mockStaleAnalyses({ freeTranslationCount: 2, freeTranslationSegmentIds: ['GEN 1:1'] }); + + await renderOnLoadedBook(); + + expect(screen.getByTestId('stale-analyses-banner')).toHaveTextContent('2 translations'); + }); + + it('offers a review of the glosses when it reports some', async () => { + mockStaleAnalyses({ glossCount: 1 }); + + await renderOnLoadedBook(); + + expect(screen.getByTestId('stale-review-glosses')).toBeInTheDocument(); + }); + + it('offers no gloss review when it reports only free translations', async () => { + mockStaleAnalyses({ freeTranslationCount: 1, freeTranslationSegmentIds: ['GEN 1:1'] }); + + await renderOnLoadedBook(); + + expect(screen.queryByTestId('stale-review-glosses')).not.toBeInTheDocument(); + }); + + it('offers the next free translation when it reports some', async () => { + mockStaleAnalyses({ freeTranslationCount: 1, freeTranslationSegmentIds: ['GEN 1:1'] }); + + await renderOnLoadedBook(); + + expect(screen.getByTestId('stale-next-free-translation')).toBeInTheDocument(); + }); + + it('offers no free translation step when it reports only glosses', async () => { + mockStaleAnalyses({ glossCount: 1 }); + + await renderOnLoadedBook(); + + expect(screen.queryByTestId('stale-next-free-translation')).not.toBeInTheDocument(); + }); + + it('opens the catalog on the analyses stale in this book', async () => { + mockSendCommand.mockResolvedValueOnce( + JSON.stringify({ ...emptyDraft(testProjectId), analysis: STALE_IN_TWO_BOOKS }), + ); + mockStaleAnalyses({ glossCount: 1 }); + await renderOnLoadedBook(); + + await userEvent.click(screen.getByTestId('stale-review-glosses')); + + expect(screen.getAllByTestId('catalog-row').map((row) => row.dataset.analysisId)).toEqual([ + 'ta-gen', + ]); + }); + + it('narrows a catalog already open to the analyses stale in this book', async () => { + mockSendCommand.mockResolvedValueOnce( + JSON.stringify({ ...emptyDraft(testProjectId), analysis: STALE_IN_TWO_BOOKS }), + ); + mockStaleAnalyses({ glossCount: 1 }); + await renderOnLoadedBook(); + await userEvent.click(screen.getByTestId('tab-toolbar-analysis-catalog')); + + await userEvent.click(screen.getByTestId('stale-review-glosses')); + + expect(screen.getAllByTestId('catalog-row').map((row) => row.dataset.analysisId)).toEqual([ + 'ta-gen', + ]); + }); + + it('takes the reader to the next segment showing a stale free translation', async () => { + mockStaleAnalyses({ freeTranslationCount: 1, freeTranslationSegmentIds: ['GEN 1:3'] }); + const setScrRef = await renderOnLoadedBook({ book: THREE_VERSE_BOOK }); + + await userEvent.click(screen.getByTestId('stale-next-free-translation')); + + expect(setScrRef).toHaveBeenLastCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 3 }); + }); + + it('asks to focus that segment', async () => { + mountStoreProbe = true; + mockStaleAnalyses({ freeTranslationCount: 1, freeTranslationSegmentIds: ['GEN 1:3'] }); + await renderOnLoadedBook({ book: THREE_VERSE_BOOK }); + + await userEvent.click(screen.getByTestId('stale-next-free-translation')); + + expect(probeFocusRequest).toBe('GEN 1:3'); + }); + + it('asks the segment list to recenter on that segment', async () => { + mountStoreProbe = true; + mockStaleAnalyses({ freeTranslationCount: 1, freeTranslationSegmentIds: ['GEN 1:3'] }); + await renderOnLoadedBook({ book: THREE_VERSE_BOOK }); + + await userEvent.click(screen.getByTestId('stale-next-free-translation')); + + expect(probeRecenterRequest).toEqual({ + ref: { book: 'GEN', chapterNum: 1, verseNum: 3 }, + segmentId: 'GEN 1:3', + }); + }); + + it('withdraws a pending focus request for a segment with no word to focus', async () => { + mockStaleAnalyses({ + freeTranslationCount: 2, + freeTranslationSegmentIds: ['GEN 1:1', 'GEN 1:2'], + }); + await renderWithHeldScrRef(THREE_VERSE_BOOK, 3); + await userEvent.click(screen.getByTestId('stale-next-free-translation')); + expect(probeFocusRequest).toBe('GEN 1:1'); + + await userEvent.click(screen.getByTestId('stale-next-free-translation')); + + expect(probeFocusRequest).toBeUndefined(); + }); + + describe('from a segment with no word to focus', () => { + beforeEach(() => { + mockStaleAnalyses({ + freeTranslationCount: 3, + freeTranslationSegmentIds: ['GEN 1:1', 'GEN 1:2:9', 'GEN 1:3'], + }); + }); + + afterEach(() => { + probePublishFocus = undefined; + }); + + /** Presses the notice's Next free translation button. */ + async function stepToNext(): Promise { + await userEvent.click(screen.getByTestId('stale-next-free-translation')); + } + + it('moves on rather than landing there again', async () => { + await renderWithHeldScrRef(RESUMED_PUNCTUATION_BOOK, 1); + await stepToNext(); + expect(probeRecenterRequest?.segmentId).toBe('GEN 1:2:9'); + + await stepToNext(); + + expect(probeRecenterRequest?.segmentId).toBe('GEN 1:3'); + }); + + it('moves on while the word focused before landing there stays focused', async () => { + await renderWithHeldScrRef(RESUMED_PUNCTUATION_BOOK, 2); + act(() => probePublishFocus?.('GEN 1:2:0')); + await stepToNext(); + expect(probeRecenterRequest?.segmentId).toBe('GEN 1:2:9'); + + await stepToNext(); + + expect(probeRecenterRequest?.segmentId).toBe('GEN 1:3'); + }); + + it('starts from a word the reader focuses after landing there', async () => { + await renderWithHeldScrRef(RESUMED_PUNCTUATION_BOOK, 1); + await stepToNext(); + act(() => probePublishFocus?.('GEN 1:3:0')); + + await stepToNext(); + + expect(probeRecenterRequest?.segmentId).toBe('GEN 1:1'); + }); + + it('starts from a verse the reader moves to after landing there', async () => { + const moveToVerse = await renderWithHeldScrRef(RESUMED_PUNCTUATION_BOOK, 1); + await stepToNext(); + moveToVerse(3); + + await stepToNext(); + + expect(probeRecenterRequest?.segmentId).toBe('GEN 1:1'); + }); + }); + + it('turns the free translation line on to show it', async () => { + const onChangeByKey = mockOptimisticSetting(false); + mockStaleAnalyses({ freeTranslationCount: 1, freeTranslationSegmentIds: ['GEN 1:3'] }); + await renderOnLoadedBook({ book: THREE_VERSE_BOOK }); + + await userEvent.click(screen.getByTestId('stale-next-free-translation')); + + expect(onChangeByKey.get('interlinearizer.showFreeTranslation')).toHaveBeenCalledWith(true); + }); + + it('leaves a free translation line already on alone', async () => { + const onChangeByKey = mockOptimisticSetting(true); + mockStaleAnalyses({ freeTranslationCount: 1, freeTranslationSegmentIds: ['GEN 1:3'] }); + await renderOnLoadedBook({ book: THREE_VERSE_BOOK }); + + await userEvent.click(screen.getByTestId('stale-next-free-translation')); + + expect(onChangeByKey.get('interlinearizer.showFreeTranslation')).not.toHaveBeenCalled(); + }); + + it('dismisses through the hook when the notice close button is clicked', async () => { + const onDismiss = mockStaleAnalyses({ glossCount: 1 }); + await renderOnLoadedBook(); + + await userEvent.click(screen.getByTestId('stale-analyses-dismiss')); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + }); + describe('save command', () => { it('saves the draft analysis to the active project when Save is clicked with an active project', async () => { const draftAnalysis = emptyAnalysis(); @@ -4416,6 +4876,7 @@ function prepareStoreProbeTest(): void { mockLexiconRegistry(); mockOptimisticSetting(); mockLostBoundaries([]); + mockStaleAnalyses(); mockProjectBookIds(undefined); mockSendCommand.mockResolvedValue(JSON.stringify(emptyDraft(testProjectId))); jest @@ -5254,6 +5715,7 @@ describe('analysis language switch', () => { mockLexiconRegistry(); mockOptimisticSetting(); mockLostBoundaries([]); + mockStaleAnalyses(); mockProjectBookIds(undefined); jest .mocked(useData) diff --git a/src/__tests__/components/StaleAnalysesReporter.test.tsx b/src/__tests__/components/StaleAnalysesReporter.test.tsx new file mode 100644 index 00000000..63a1d66b --- /dev/null +++ b/src/__tests__/components/StaleAnalysesReporter.test.tsx @@ -0,0 +1,143 @@ +/// + +import { render } from '@testing-library/react'; +import type { TokenAnalysisLink } from 'interlinearizer'; +import { + useStaleFreeTranslationsBySegment, + useStaleTokenLinks, +} from '../../components/AnalysisStore'; +import StaleAnalysesReporter from '../../components/StaleAnalysesReporter'; +import type { StaleFreeTranslation } from '../../utils/stale-free-translations'; +import { FIXTURE_STAMPS, GEN_1_1_BOOK } from '../test-helpers'; + +jest.mock('../../components/AnalysisStore', () => ({ + __esModule: true, + useStaleTokenLinks: jest.fn(), + useStaleFreeTranslationsBySegment: jest.fn(), +})); + +function staleLink(analysisId: string, tokenRef: string): TokenAnalysisLink { + return { ...FIXTURE_STAMPS, analysisId, token: { tokenRef, surfaceText: 'In' }, status: 'stale' }; +} + +function staleTranslation(analysisId: string, segmentId: string): StaleFreeTranslation { + return { analysisId, segmentId, text: 'Au début' }; +} + +/** Serves the store's stale analyses: these stale links, and these translations by segment. */ +function mockStale( + links: readonly TokenAnalysisLink[], + translationsBySegment: ReadonlyMap = new Map(), +): void { + jest.mocked(useStaleTokenLinks).mockReturnValue(links); + jest.mocked(useStaleFreeTranslationsBySegment).mockReturnValue(translationsBySegment); +} + +describe('StaleAnalysesReporter', () => { + it('reports a key per place in the book an analysis went stale at', () => { + mockStale([staleLink('a', 'GEN 1:1:0'), staleLink('b', 'GEN 1:1:0')]); + const onReport = jest.fn(); + + render(); + + expect(onReport).toHaveBeenLastCalledWith( + expect.objectContaining({ + bookRef: 'GEN', + glosses: ['gloss a GEN 1:1:0', 'gloss b GEN 1:1:0'], + }), + ); + }); + + it('leaves out places in other books', () => { + mockStale([staleLink('a', 'EXO 1:1:0')]); + const onReport = jest.fn(); + + render(); + + expect(onReport).toHaveBeenLastCalledWith(expect.objectContaining({ glosses: [] })); + }); + + it('reports a place an analysis went stale at twice over once', () => { + mockStale([staleLink('a', 'GEN 1:1:0'), staleLink('a', 'GEN 1:1:0')]); + const onReport = jest.fn(); + + render(); + + expect(onReport).toHaveBeenLastCalledWith( + expect.objectContaining({ glosses: ['gloss a GEN 1:1:0'] }), + ); + }); + + it('reports each stale free translation with the segment showing it, in segment order', () => { + mockStale( + [], + new Map([ + ['GEN 1:1', [staleTranslation('s1', 'GEN 1:1'), staleTranslation('s2', 'GEN 1:1:9')]], + ['GEN 1:2', [staleTranslation('s3', 'GEN 1:2')]], + ]), + ); + const onReport = jest.fn(); + + render(); + + expect(onReport).toHaveBeenLastCalledWith( + expect.objectContaining({ + freeTranslations: [ + { key: 'freeTranslation s1', segmentId: 'GEN 1:1' }, + { key: 'freeTranslation s2', segmentId: 'GEN 1:1' }, + { key: 'freeTranslation s3', segmentId: 'GEN 1:2' }, + ], + }), + ); + }); + + it('reports again when the stale analyses change', () => { + mockStale([staleLink('a', 'GEN 1:1:0')]); + const onReport = jest.fn(); + const { rerender } = render(); + + mockStale([]); + rerender(); + + expect(onReport).toHaveBeenLastCalledWith(expect.objectContaining({ glosses: [] })); + }); + + it('does not report again when the store rebuilds the same stale analyses', () => { + mockStale( + [staleLink('a', 'GEN 1:1:0')], + new Map([['GEN 1:1', [staleTranslation('s1', 'GEN 1:1')]]]), + ); + const onReport = jest.fn(); + const { rerender } = render(); + + mockStale( + [staleLink('a', 'GEN 1:1:0')], + new Map([['GEN 1:1', [staleTranslation('s1', 'GEN 1:1')]]]), + ); + rerender(); + + expect(onReport).toHaveBeenCalledTimes(1); + }); + + it('reports to a new receiver what it reported to the last', () => { + mockStale([staleLink('a', 'GEN 1:1:0')]); + const { rerender } = render(); + const onReport = jest.fn(); + + rerender(); + + expect(onReport).toHaveBeenLastCalledWith( + expect.objectContaining({ glosses: ['gloss a GEN 1:1:0'] }), + ); + }); + + it('reports nothing known once it unmounts', () => { + mockStale([staleLink('a', 'GEN 1:1:0')]); + const onReport = jest.fn(); + const { unmount } = render(); + + unmount(); + + expect(onReport).toHaveBeenLastCalledWith(undefined); + }); +}); diff --git a/src/__tests__/hooks/useLostBoundaryDismissal.test.ts b/src/__tests__/hooks/useLostBoundaryDismissal.test.ts index be1ef375..5da498fa 100644 --- a/src/__tests__/hooks/useLostBoundaryDismissal.test.ts +++ b/src/__tests__/hooks/useLostBoundaryDismissal.test.ts @@ -1,10 +1,14 @@ /// -import { act, renderHook } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { Book, SegmentationDelta } from 'interlinearizer'; import useLostBoundaryDismissal from '../../hooks/useLostBoundaryDismissal'; +import useRecordDismissal from '../../hooks/useRecordDismissal'; import { GEN_1_1_BOOK, makeSegment, makeWebViewState, makeWordToken } from '../test-helpers'; +// Passing every record through leaves these tests asserting on what the hook finds. +jest.mock('../../hooks/useRecordDismissal'); + /** A two-verse book, with a mid-verse token, that the deltas below anchor into. */ const TWO_VERSE_BOOK: Book = { ...GEN_1_1_BOOK, @@ -17,55 +21,35 @@ const TWO_VERSE_BOOK: Book = { ], }; -/** {@link TWO_VERSE_BOOK} with the verse carrying the anchor the deltas below remove. */ -const THREE_VERSE_BOOK: Book = { - ...TWO_VERSE_BOOK, - segments: [ - ...TWO_VERSE_BOOK.segments, - makeSegment('GEN 1:3', 'Delta.', [makeWordToken('GEN 1:3:0', 'Delta')]), - ], -}; - -/** Another book entirely, whose own anchors are all intact. */ -const OTHER_BOOK: Book = { - ...TWO_VERSE_BOOK, - id: 'EXO', - bookRef: 'EXO', - segments: [makeSegment('EXO 1:1', 'Epsilon.', [makeWordToken('EXO 1:1:0', 'Epsilon')])], -}; - -/** The hook options a test varies between renders; the rest stay fixed. */ +/** The hook options a test varies; the rest stay fixed. */ type HookInput = { verseBook?: Book | undefined; - segmentation?: SegmentationDelta | undefined; - draftVersion?: number; + segmentation: SegmentationDelta | undefined; isImportView?: boolean; }; -/** - * Renders the hook on {@link TWO_VERSE_BOOK} with one WebView-state store held across rerenders, so - * a dismissal persists exactly as it does for a tab. Rerendering takes the whole input afresh - * rather than a patch, keeping each step's book and delta stated where it is asserted on. - */ -function renderDismissal(initial: HookInput, webViewSeed: Record = {}) { - const useWebViewState = makeWebViewState(webViewSeed); - const { result, rerender } = renderHook( - (input: HookInput) => - useLostBoundaryDismissal({ - verseBook: 'verseBook' in input ? input.verseBook : TWO_VERSE_BOOK, - segmentation: input.segmentation, - segmentationVersion: 0, - draftVersion: input.draftVersion ?? 0, - isDraftLoading: false, - isImportView: input.isImportView ?? false, - useWebViewState, - }), - { initialProps: initial }, +/** Renders the hook on {@link TWO_VERSE_BOOK} unless the input names another book or none. */ +function renderDismissal(input: HookInput) { + return renderHook(() => + useLostBoundaryDismissal({ + verseBook: 'verseBook' in input ? input.verseBook : TWO_VERSE_BOOK, + segmentation: input.segmentation, + segmentationVersion: 0, + draftVersion: 0, + isDraftLoading: false, + isImportView: input.isImportView ?? false, + useWebViewState: makeWebViewState(), + }), ); - return { result, rerenderWith: (input: HookInput) => act(() => rerender(input)) }; } describe('useLostBoundaryDismissal', () => { + beforeEach(() => { + jest + .mocked(useRecordDismissal) + .mockImplementation(({ records }) => ({ undismissed: records, onDismiss: jest.fn() })); + }); + describe('finding the lost anchors', () => { it('reports the anchors the source no longer carries', () => { const { result } = renderDismissal({ @@ -125,171 +109,19 @@ describe('useLostBoundaryDismissal', () => { }); }); - describe('dismissal', () => { - it('clears the flag for exactly the anchors it was raised for', () => { - const { result } = renderDismissal({ - segmentation: { removedVerseStarts: ['GEN 1:9:0'], addedStarts: [] }, - }); - - act(() => result.current.onDismiss()); - - expect(result.current.undismissedLostBoundaries.length > 0).toBe(false); - }); - - it('stays down while the same anchors stay lost', () => { - const segmentation = { removedVerseStarts: ['GEN 1:9:0'], addedStarts: [] }; - const { result, rerenderWith } = renderDismissal({ segmentation }); - act(() => result.current.onDismiss()); - - // A source edit re-tokenizes to a fresh Book carrying the same text. - rerenderWith({ verseBook: { ...TWO_VERSE_BOOK }, segmentation }); - - expect(result.current.undismissedLostBoundaries.length > 0).toBe(false); - }); - - it('stays down for a tab whose stored dismissal covers every lost anchor', () => { - const { result } = renderDismissal( - { - segmentation: { - removedVerseStarts: ['GEN 1:9:0'], - addedStarts: [{ tokenRef: 'GEN 1:1:99', surfaceText: 'beta' }], - }, - }, - { dismissedLostBoundaries: ['GEN 1:9:0', 'GEN 1:1:99'] }, - ); - - expect(result.current.undismissedLostBoundaries.length > 0).toBe(false); - }); - - it('comes back for an anchor lost after the dismissal, reporting only that one', () => { - // The stored dismissal covers one of the two anchors the loaded source strands. - const { result } = renderDismissal( - { - segmentation: { - removedVerseStarts: ['GEN 1:9:0'], - addedStarts: [{ tokenRef: 'GEN 1:1:99', surfaceText: 'beta' }], - }, - }, - { dismissedLostBoundaries: ['GEN 1:9:0'] }, - ); - - expect(result.current.undismissedLostBoundaries).toEqual(['GEN 1:1:99']); - }); - - it('keeps another book’s dismissal when dismissing in this one', () => { - // One delta spans the draft, so each book's dismissal covers only the anchors it reports. - const segmentation = { removedVerseStarts: ['GEN 1:9:0', 'EXO 1:9:0'], addedStarts: [] }; - const { result, rerenderWith } = renderDismissal({ segmentation }); - act(() => result.current.onDismiss()); - - rerenderWith({ verseBook: OTHER_BOOK, segmentation }); - expect(result.current.undismissedLostBoundaries).toEqual(['EXO 1:9:0']); - act(() => result.current.onDismiss()); - - rerenderWith({ verseBook: { ...TWO_VERSE_BOOK }, segmentation }); - - expect(result.current.undismissedLostBoundaries).toEqual([]); - }); + it('observes no book in an import view, which says nothing about a recovery', () => { + renderDismissal({ isImportView: true, segmentation: undefined }); - it('stays down when an anchor comes back but the rest are dismissed', () => { - // 'GEN 1:2:0' resolves in this book, so only the dismissed anchor is still lost. - const { result } = renderDismissal( - { segmentation: { removedVerseStarts: ['GEN 1:9:0', 'GEN 1:2:0'], addedStarts: [] } }, - { dismissedLostBoundaries: ['GEN 1:9:0', 'GEN 1:1:99'] }, - ); - - expect(result.current.undismissedLostBoundaries.length > 0).toBe(false); - }); + expect(jest.mocked(useRecordDismissal)).toHaveBeenLastCalledWith( + expect.objectContaining({ observedBookRef: undefined }), + ); }); - describe('dropping a spent dismissal', () => { - it('comes back when a recovered anchor is stranded again', () => { - const segmentation = { removedVerseStarts: ['GEN 1:3:0'], addedStarts: [] }; - const { result, rerenderWith } = renderDismissal({ segmentation }); - act(() => result.current.onDismiss()); - - rerenderWith({ verseBook: THREE_VERSE_BOOK, segmentation }); - expect(result.current.undismissedLostBoundaries.length > 0).toBe(false); - - // The recovery ended the loss the dismissal acknowledged, so losing it again is a fresh one. - rerenderWith({ verseBook: { ...TWO_VERSE_BOOK }, segmentation }); - - expect(result.current.undismissedLostBoundaries.length > 0).toBe(true); - }); - - it('keeps a dismissal across a visit to another book', () => { - const segmentation = { removedVerseStarts: ['GEN 1:9:0'], addedStarts: [] }; - const { result, rerenderWith } = renderDismissal({ segmentation }); - act(() => result.current.onDismiss()); - - // The other book reports none of GEN's anchors, which is not the same as their recovery. - rerenderWith({ verseBook: OTHER_BOOK, segmentation }); - rerenderWith({ verseBook: { ...TWO_VERSE_BOOK }, segmentation }); - - expect(result.current.undismissedLostBoundaries.length > 0).toBe(false); - }); - - it('comes back when the anchor recovers in its own book after a visit elsewhere', () => { - const segmentation = { removedVerseStarts: ['GEN 1:3:0'], addedStarts: [] }; - const { result, rerenderWith } = renderDismissal({ segmentation }); - act(() => result.current.onDismiss()); - - rerenderWith({ verseBook: OTHER_BOOK, segmentation }); - // Back in GEN the anchor resolves, so the dismissal it covered is spent. - rerenderWith({ verseBook: THREE_VERSE_BOOK, segmentation }); - rerenderWith({ verseBook: { ...TWO_VERSE_BOOK }, segmentation }); - - expect(result.current.undismissedLostBoundaries.length > 0).toBe(true); - }); - - it('reads no recovery from an import view, which reports no anchors of its own', () => { - const segmentation = { removedVerseStarts: ['GEN 1:9:0'], addedStarts: [] }; - const { result, rerenderWith } = renderDismissal({ segmentation }); - act(() => result.current.onDismiss()); - - rerenderWith({ isImportView: true, segmentation }); - rerenderWith({ segmentation }); + it('observes the loaded book', () => { + renderDismissal({ segmentation: undefined }); - expect(result.current.undismissedLostBoundaries.length > 0).toBe(false); - }); - - it('drops the dismissal when the draft is replaced wholesale', () => { - const segmentation = { removedVerseStarts: ['GEN 1:9:0'], addedStarts: [] }; - const { result, rerenderWith } = renderDismissal({ segmentation }); - act(() => result.current.onDismiss()); - - // The replacement carries the same delta, so the same anchor is lost afresh. - rerenderWith({ segmentation, draftVersion: 1 }); - - expect(result.current.undismissedLostBoundaries.length > 0).toBe(true); - }); - - it('drops the dismissal when a replacement keeps one lost anchor and recovers another', () => { - const { result, rerenderWith } = renderDismissal({ - segmentation: { removedVerseStarts: ['GEN 1:9:0', 'GEN 1:8:0'], addedStarts: [] }, - }); - act(() => result.current.onDismiss()); - - // The replacement strands only the first anchor, whose loss the user has not seen in it. - rerenderWith({ - segmentation: { removedVerseStarts: ['GEN 1:9:0'], addedStarts: [] }, - draftVersion: 1, - }); - - expect(result.current.undismissedLostBoundaries.length > 0).toBe(true); - }); - - it('keeps a stored dismissal through the mount pass, so a restored tab stays down', () => { - const { result } = renderDismissal( - { - segmentation: { removedVerseStarts: ['GEN 1:9:0'], addedStarts: [] }, - // A restored tab mounts at whatever draft version it left off at, not at zero. - draftVersion: 4, - }, - { dismissedLostBoundaries: ['GEN 1:9:0'] }, - ); - - expect(result.current.undismissedLostBoundaries.length > 0).toBe(false); - }); + expect(jest.mocked(useRecordDismissal)).toHaveBeenLastCalledWith( + expect.objectContaining({ observedBookRef: 'GEN' }), + ); }); }); diff --git a/src/__tests__/hooks/useRecordDismissal.test.ts b/src/__tests__/hooks/useRecordDismissal.test.ts new file mode 100644 index 00000000..d810fb7a --- /dev/null +++ b/src/__tests__/hooks/useRecordDismissal.test.ts @@ -0,0 +1,172 @@ +/// + +import { act, renderHook } from '@testing-library/react'; +import useRecordDismissal from '../../hooks/useRecordDismissal'; +import { makeWebViewState } from '../test-helpers'; + +/** The hook options a test varies between renders; the rest stay fixed. */ +type HookInput = { + records: readonly string[]; + observedBookRef?: string | undefined; + draftVersion?: number; +}; + +/** + * Renders the hook observing GEN unless told otherwise, with one WebView-state store held across + * rerenders, so a dismissal persists exactly as it does for a tab. Rerendering takes the whole + * input afresh rather than a patch, keeping each step's records stated where it is asserted on. + */ +function renderDismissal(initial: HookInput, webViewSeed: Record = {}) { + const useWebViewState = makeWebViewState(webViewSeed); + const { result, rerender } = renderHook( + (input: HookInput) => + useRecordDismissal({ + records: input.records, + observedBookRef: 'observedBookRef' in input ? input.observedBookRef : 'GEN', + draftVersion: input.draftVersion ?? 0, + stateKey: 'dismissedTestRecords', + useWebViewState, + }), + { initialProps: initial }, + ); + return { result, rerenderWith: (input: HookInput) => act(() => rerender(input)) }; +} + +describe('useRecordDismissal', () => { + describe('dismissal', () => { + it('reports every record before any dismissal', () => { + const { result } = renderDismissal({ records: ['a', 'b'] }); + + expect(result.current.undismissed).toEqual(['a', 'b']); + }); + + it('clears the banner for exactly the records it was raised for', () => { + const { result } = renderDismissal({ records: ['a'] }); + + act(() => result.current.onDismiss()); + + expect(result.current.undismissed).toEqual([]); + }); + + it('stays down while the same records stay raised', () => { + const { result, rerenderWith } = renderDismissal({ records: ['a'] }); + act(() => result.current.onDismiss()); + + rerenderWith({ records: ['a'] }); + + expect(result.current.undismissed).toEqual([]); + }); + + it('stays down for a tab whose stored dismissal covers every record', () => { + const { result } = renderDismissal( + { records: ['a', 'b'] }, + { dismissedTestRecords: ['a', 'b'] }, + ); + + expect(result.current.undismissed).toEqual([]); + }); + + it('comes back for a record raised after the dismissal, reporting only that one', () => { + const { result } = renderDismissal({ records: ['a', 'b'] }, { dismissedTestRecords: ['a'] }); + + expect(result.current.undismissed).toEqual(['b']); + }); + + it('keeps another book’s dismissal when dismissing in this one', () => { + const { result, rerenderWith } = renderDismissal({ records: ['gen'] }); + act(() => result.current.onDismiss()); + + rerenderWith({ records: ['exo'], observedBookRef: 'EXO' }); + expect(result.current.undismissed).toEqual(['exo']); + act(() => result.current.onDismiss()); + + rerenderWith({ records: ['gen'] }); + + expect(result.current.undismissed).toEqual([]); + }); + }); + + describe('dropping a spent dismissal', () => { + it('comes back when a cleared record is raised again', () => { + const { result, rerenderWith } = renderDismissal({ records: ['a'] }); + act(() => result.current.onDismiss()); + + rerenderWith({ records: [] }); + expect(result.current.undismissed).toEqual([]); + + // The clearing ended what the dismissal acknowledged, so raising it again is fresh. + rerenderWith({ records: ['a'] }); + + expect(result.current.undismissed).toEqual(['a']); + }); + + it('keeps the rest dismissed when one record clears', () => { + const { result, rerenderWith } = renderDismissal({ records: ['a', 'b'] }); + act(() => result.current.onDismiss()); + + rerenderWith({ records: ['b'] }); + + expect(result.current.undismissed).toEqual([]); + }); + + it('keeps a dismissal across a visit to another book', () => { + const { result, rerenderWith } = renderDismissal({ records: ['a'] }); + act(() => result.current.onDismiss()); + + // The other book reports none of GEN's records, which is not the same as their clearing. + rerenderWith({ records: [], observedBookRef: 'EXO' }); + rerenderWith({ records: ['a'] }); + + expect(result.current.undismissed).toEqual([]); + }); + + it('comes back when the record clears in its own book after a visit elsewhere', () => { + const { result, rerenderWith } = renderDismissal({ records: ['a'] }); + act(() => result.current.onDismiss()); + + rerenderWith({ records: [], observedBookRef: 'EXO' }); + rerenderWith({ records: [] }); + rerenderWith({ records: ['a'] }); + + expect(result.current.undismissed).toEqual(['a']); + }); + + it('reads no clearing while no book is observed', () => { + const { result, rerenderWith } = renderDismissal({ records: ['a'] }); + act(() => result.current.onDismiss()); + + rerenderWith({ records: [], observedBookRef: undefined }); + rerenderWith({ records: ['a'] }); + + expect(result.current.undismissed).toEqual([]); + }); + + it('drops the dismissal when the draft is replaced wholesale', () => { + const { result, rerenderWith } = renderDismissal({ records: ['a'] }); + act(() => result.current.onDismiss()); + + rerenderWith({ records: ['a'], draftVersion: 1 }); + + expect(result.current.undismissed).toEqual(['a']); + }); + + it('drops the dismissal when a replacement keeps one record and clears another', () => { + const { result, rerenderWith } = renderDismissal({ records: ['a', 'b'] }); + act(() => result.current.onDismiss()); + + rerenderWith({ records: ['a'], draftVersion: 1 }); + + expect(result.current.undismissed).toEqual(['a']); + }); + + it('keeps a stored dismissal through the mount pass, so a restored tab stays down', () => { + const { result } = renderDismissal( + // A restored tab mounts at whatever draft version it left off at, not at zero. + { records: ['a'], draftVersion: 4 }, + { dismissedTestRecords: ['a'] }, + ); + + expect(result.current.undismissed).toEqual([]); + }); + }); +}); diff --git a/src/__tests__/hooks/useSegmentWindow.test.ts b/src/__tests__/hooks/useSegmentWindow.test.ts index 8a19bbe8..8f321312 100644 --- a/src/__tests__/hooks/useSegmentWindow.test.ts +++ b/src/__tests__/hooks/useSegmentWindow.test.ts @@ -14,7 +14,7 @@ import useSegmentWindow, { SKIM_SETTLE_MS, SKIM_SLIDE_PX, } from '../../hooks/useSegmentWindow'; -import { verseKey } from '../../components/InterlinearNavContext'; +import { verseKey, type RecenterRequest } from '../../components/InterlinearNavContext'; import { RECENTER_FADE_MS } from '../../components/recenter-fade'; import type { HeightTable } from '../../utils/segment-heights'; import { makeWordToken } from '../test-helpers'; @@ -90,6 +90,7 @@ function renderSegmentWindow( scrRef: SerializedVerseRef, focusedTokenRef?: string, onSettled?: () => void, + recenterRequest?: RecenterRequest, ) { const container = document.createElement('div'); document.body.appendChild(container); @@ -114,9 +115,10 @@ function renderSegmentWindow( focus?: string | undefined; cont?: boolean; segVersion?: number; + recenter?: RecenterRequest; } >( - ({ b, ref, focus, cont, segVersion }) => { + ({ b, ref, focus, cont, segVersion, recenter }) => { const scrollContainerRef = useRef(container); return useSegmentWindow({ book: b, @@ -126,12 +128,21 @@ function renderSegmentWindow( continuousScroll: cont ?? false, scrollContainerRef, consumeInternalNav, + recenterRequest: recenter, onDisplayContinuousScrollChange, heightTable: uniformHeightTable(b), onSettled, }); }, - { initialProps: { b: book, ref: scrRef, focus: focusedTokenRef, cont: false } }, + { + initialProps: { + b: book, + ref: scrRef, + focus: focusedTokenRef, + cont: false, + recenter: recenterRequest, + }, + }, ); return { ...hook, @@ -1573,6 +1584,79 @@ describe('useSegmentWindow', () => { expect(result.current.windowSegments.map((s) => s.id)).toContain(LAST_HEADING); }); + it('recenters on the segment a request names for the verse it is anchored on', () => { + const book = bookWithHeadingsUnderVerse5(); + const { result, rerender } = renderSegmentWindow(book, GEN_1_5, 'GEN 1:5:0'); + expect(result.current.windowSegments.map((s) => s.id)).not.toContain(LAST_HEADING); + + act(() => + rerender({ + b: book, + ref: GEN_1_5, + focus: 'GEN 1:5:0', + recenter: { ref: GEN_1_5, segmentId: LAST_HEADING }, + }), + ); + expect(result.current.isFaded).toBe(true); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + + expect(result.current.windowSegments.map((s) => s.id)).toContain(LAST_HEADING); + }); + + it('snaps the segment a request names to the top rather than the active verse', () => { + const book = bookWithHeadingsUnderVerse5(); + const { container, rerender } = renderSegmentWindow(book, GEN_1_5, 'GEN 1:5:0'); + // The verse sits mid-book, so the mount snaps too; let that pass before stubbing the targets. + act(() => jest.runOnlyPendingTimers()); + const active = mountActiveSegment(container); + active.scrollIntoView = jest.fn(); + const named = document.createElement('div'); + named.setAttribute('data-segment-id', LAST_HEADING); + named.scrollIntoView = jest.fn(); + container.appendChild(named); + + act(() => + rerender({ + b: book, + ref: GEN_1_5, + focus: 'GEN 1:5:0', + recenter: { ref: GEN_1_5, segmentId: LAST_HEADING }, + }), + ); + act(() => jest.advanceTimersByTime(RECENTER_FADE_MS)); + + expect(named.scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' }); + expect(active.scrollIntoView).not.toHaveBeenCalled(); + }); + + it('leaves a request for a verse anchored elsewhere to the navigation', () => { + const book = bookWithHeadingsUnderVerse5(); + const { result, rerender } = renderSegmentWindow(book, GEN_1_5, 'GEN 1:5:0'); + + act(() => + rerender({ + b: book, + ref: GEN_1_5, + focus: 'GEN 1:5:0', + recenter: { ref: { book: 'GEN', chapterNum: 1, verseNum: 50 }, segmentId: 'GEN 1:50' }, + }), + ); + + expect(result.current.isFaded).toBe(false); + }); + + it('honors no request made before it mounted', () => { + const { result } = renderSegmentWindow( + bookWithHeadingsUnderVerse5(), + GEN_1_5, + 'GEN 1:5:0', + undefined, + { ref: GEN_1_5, segmentId: LAST_HEADING }, + ); + + expect(result.current.isFaded).toBe(false); + }); + it('centers on the verse when the focused segment lies outside it', () => { const { result } = renderSegmentWindow(bookWithHeadingsUnderVerse5(), GEN_1_5, 'GEN 1:50:0'); diff --git a/src/__tests__/hooks/useStaleAnalysisDismissal.test.ts b/src/__tests__/hooks/useStaleAnalysisDismissal.test.ts new file mode 100644 index 00000000..941031a5 --- /dev/null +++ b/src/__tests__/hooks/useStaleAnalysisDismissal.test.ts @@ -0,0 +1,112 @@ +/// + +import { renderHook } from '@testing-library/react'; +import type { StaleAnalysesReport } from '../../components/StaleAnalysesReporter'; +import useRecordDismissal from '../../hooks/useRecordDismissal'; +import useStaleAnalysisDismissal from '../../hooks/useStaleAnalysisDismissal'; +import { makeWebViewState } from '../test-helpers'; + +// Mocked so each test decides which records are dismissed. +jest.mock('../../hooks/useRecordDismissal'); + +/** Stale glosses and free translations, with a segment showing more than one of the latter. */ +const REPORT: StaleAnalysesReport = { + bookRef: 'GEN', + glosses: ['gloss a GEN 1:1:0', 'gloss b GEN 1:2:0'], + freeTranslations: [ + { key: 'freeTranslation s1', segmentId: 'GEN 1:1' }, + { key: 'freeTranslation s2', segmentId: 'GEN 1:1' }, + { key: 'freeTranslation s3', segmentId: 'GEN 1:3' }, + ], +}; + +/** Serves a dismissal covering exactly `dismissed`. */ +function mockDismissed(dismissed: readonly string[] = []): void { + jest.mocked(useRecordDismissal).mockImplementation(({ records }) => ({ + undismissed: records.filter((record) => !dismissed.includes(record)), + onDismiss: jest.fn(), + })); +} + +function renderStale(report: StaleAnalysesReport | undefined) { + return renderHook(() => + useStaleAnalysisDismissal({ report, draftVersion: 0, useWebViewState: makeWebViewState() }), + ); +} + +describe('useStaleAnalysisDismissal', () => { + it('counts the stale glosses the notice has not been dismissed for', () => { + mockDismissed(['gloss a GEN 1:1:0']); + + const { result } = renderStale(REPORT); + + expect(result.current.glossCount).toBe(1); + }); + + it('counts the stale free translations the notice has not been dismissed for', () => { + mockDismissed(['freeTranslation s3']); + + const { result } = renderStale(REPORT); + + expect(result.current.freeTranslationCount).toBe(2); + }); + + it('lists each segment showing one of those free translations once, in document order', () => { + mockDismissed(); + + const { result } = renderStale(REPORT); + + expect(result.current.freeTranslationSegmentIds).toEqual(['GEN 1:1', 'GEN 1:3']); + }); + + it('leaves out a segment whose free translations are all dismissed', () => { + mockDismissed(['freeTranslation s3']); + + const { result } = renderStale(REPORT); + + expect(result.current.freeTranslationSegmentIds).toEqual(['GEN 1:1']); + }); + + it('reports nothing before the book’s stale analyses are known', () => { + mockDismissed(); + + const { result } = renderStale(undefined); + + expect(result.current).toEqual( + expect.objectContaining({ + glossCount: 0, + freeTranslationCount: 0, + freeTranslationSegmentIds: [], + }), + ); + }); + + it('tracks a dismissal of every stale analysis in the reported book', () => { + mockDismissed(); + + renderStale(REPORT); + + expect(jest.mocked(useRecordDismissal)).toHaveBeenLastCalledWith( + expect.objectContaining({ + records: [ + 'gloss a GEN 1:1:0', + 'gloss b GEN 1:2:0', + 'freeTranslation s1', + 'freeTranslation s2', + 'freeTranslation s3', + ], + observedBookRef: 'GEN', + }), + ); + }); + + it('observes no book before a report arrives', () => { + mockDismissed(); + + renderStale(undefined); + + expect(jest.mocked(useRecordDismissal)).toHaveBeenLastCalledWith( + expect.objectContaining({ records: [], observedBookRef: undefined }), + ); + }); +}); diff --git a/src/__tests__/store/analysisSlice.test.ts b/src/__tests__/store/analysisSlice.test.ts index 64ed8516..e91504ff 100644 --- a/src/__tests__/store/analysisSlice.test.ts +++ b/src/__tests__/store/analysisSlice.test.ts @@ -47,6 +47,7 @@ import { selectSegmentFreeTranslation, selectSegmentsWithApprovedTranslation, selectStaleFreeTranslations, + selectStaleTokenLinks, selectFreeTranslationsBySegment, selectStaleMorphemesAnalysisId, updatePhrase, @@ -1958,6 +1959,30 @@ describe('selectStaleFreeTranslations', () => { }); }); +describe('selectStaleTokenLinks', () => { + it('lists a stale link', () => { + const ta = logos('ta-1', 'word'); + const stale = makeLink(ta, 'tok-1', 'stale'); + const store = createAnalysisStore(tokenState([ta], [stale])); + + expect(selectStaleTokenLinks(store.getState().analysis)).toEqual([stale]); + }); + + it('leaves out a link of any other status', () => { + const ta = logos('ta-1', 'word'); + const store = createAnalysisStore(tokenState([ta], [makeLink(ta, 'tok-1', 'approved')])); + + expect(selectStaleTokenLinks(store.getState().analysis)).toEqual([]); + }); + + it('leaves out a stale link whose payload is missing', () => { + const ta = logos('ta-1', 'word'); + const store = createAnalysisStore(tokenState([], [makeLink(ta, 'tok-1', 'stale')])); + + expect(selectStaleTokenLinks(store.getState().analysis)).toEqual([]); + }); +}); + describe('selectSegmentsWithApprovedTranslation', () => { it('lists a segment holding an approved translation', () => { const store = createAnalysisStore( diff --git a/src/__tests__/utils/analysis-query.test.ts b/src/__tests__/utils/analysis-query.test.ts index ce68c31d..dc6ff844 100644 --- a/src/__tests__/utils/analysis-query.test.ts +++ b/src/__tests__/utils/analysis-query.test.ts @@ -867,6 +867,30 @@ describe('applyCatalogQuery filters', () => { expect(applyCatalogQuery(rows, query).map((r) => r.analysisId)).toEqual(['ta-2']); }); + it('keeps a row stale in a selected book when filtering for stale by book', () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [{ ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'a' }], + tokenAnalysisLinks: [link('ta-1', 'EXO 1:1:0'), link('ta-1', 'GEN 1:1:0', 'stale')], + }; + const rows = buildCatalogRows(analysis, scope); + const query = makeQuery({ filters: { stale: true, books: ['GEN'] } }); + + expect(applyCatalogQuery(rows, query).map((r) => r.analysisId)).toEqual(['ta-1']); + }); + + it('leaves out a row stale only outside the selected books when filtering for stale', () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [{ ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'a' }], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0'), link('ta-1', 'EXO 1:1:0', 'stale')], + }; + const rows = buildCatalogRows(analysis, scope); + const query = makeQuery({ filters: { stale: true, books: ['GEN'] } }); + + expect(applyCatalogQuery(rows, query)).toEqual([]); + }); + // ta-2 is glossed, just not in the scope's language, so an any-language check would drop it. it('keeps a row glossed only in another language when filtering for a missing gloss', () => { const analysis: TextAnalysis = { diff --git a/src/__tests__/utils/stale-free-translations.test.ts b/src/__tests__/utils/stale-free-translations.test.ts index 4ab5ec9d..36ee07db 100644 --- a/src/__tests__/utils/stale-free-translations.test.ts +++ b/src/__tests__/utils/stale-free-translations.test.ts @@ -3,6 +3,7 @@ import { resegmentBook } from '../../parsers/papi/resegmentBook'; import { adoptedStaleTranslation, + nextSegmentAmong, placeStaleFreeTranslations, type StaleFreeTranslation, } from '../../utils/stale-free-translations'; @@ -139,3 +140,68 @@ describe('adoptedStaleTranslation', () => { expect(adoptedStaleTranslation([{ ...stale('GEN 1:1'), text: '' }], false)).toBeUndefined(); }); }); + +describe('nextSegmentAmong', () => { + const [verse1, verse2, verse3] = verseBook.segments; + const atVerse = (verseNum: number) => ({ book: 'GEN', chapterNum: 1, verseNum }); + + it('finds the first listed segment after the one holding the focused word', () => { + const next = nextSegmentAmong( + verseBook, + ['GEN 1:1', 'GEN 1:3'], + verse1.tokens[0].ref, + atVerse(1), + ); + + expect(next?.id).toBe('GEN 1:3'); + }); + + it('wraps around to the book’s first listed segment past the last', () => { + const next = nextSegmentAmong(verseBook, ['GEN 1:1'], verse3.tokens[0].ref, atVerse(3)); + + expect(next?.id).toBe('GEN 1:1'); + }); + + it('starts from the segment holding the active verse while no word is focused', () => { + const next = nextSegmentAmong(verseBook, ['GEN 1:2', 'GEN 1:3'], undefined, atVerse(2)); + + expect(next?.id).toBe('GEN 1:3'); + }); + + it('starts from the focused word rather than the active verse', () => { + const next = nextSegmentAmong( + verseBook, + ['GEN 1:2', 'GEN 1:3'], + verse2.tokens[0].ref, + atVerse(1), + ); + + expect(next?.id).toBe('GEN 1:3'); + }); + + it('starts from the segment the last jump landed on rather than the focused word', () => { + const next = nextSegmentAmong( + verseBook, + ['GEN 1:2', 'GEN 1:3'], + verse1.tokens[0].ref, + atVerse(1), + 'GEN 1:2', + ); + + expect(next?.id).toBe('GEN 1:3'); + }); + + it('starts ahead of the whole book when neither the focus nor the verse is in it', () => { + const next = nextSegmentAmong(verseBook, ['GEN 1:1', 'GEN 1:3'], undefined, { + book: 'EXO', + chapterNum: 1, + verseNum: 1, + }); + + expect(next?.id).toBe('GEN 1:1'); + }); + + it('finds nothing when the book holds none of the segments', () => { + expect(nextSegmentAmong(verseBook, ['GEN 9:9'], undefined, atVerse(1))).toBeUndefined(); + }); +}); diff --git a/src/components/AnalysisCatalogPanel.tsx b/src/components/AnalysisCatalogPanel.tsx index 7bbb5ceb..ea05e182 100644 --- a/src/components/AnalysisCatalogPanel.tsx +++ b/src/components/AnalysisCatalogPanel.tsx @@ -100,6 +100,8 @@ export type AnalysisCatalogPanelHandle = Readonly<{ requestShowConcordance: () => void; /** Scrolls to the row for `analysisId` when the listing holds it. */ revealRow: (analysisId: string) => void; + /** Narrows the listing to exactly `filters`, clearing the search so nothing else hides a row. */ + filterTo: (filters: CatalogFilters) => void; }>; /** A review of a place an analysis went stale at, held while it waits on the reader's consent. */ @@ -524,10 +526,16 @@ export default function AnalysisCatalogPanel({ setRowToReveal({ analysisId }); }, []); - useImperativeHandle(ref, () => ({ requestShowConcordance: handleSwitchRequest, revealRow }), [ - handleSwitchRequest, - revealRow, - ]); + const filterTo = useCallback((narrowedTo: CatalogFilters) => { + setSearch(''); + setFilters(narrowedTo); + }, []); + + useImperativeHandle( + ref, + () => ({ requestShowConcordance: handleSwitchRequest, revealRow, filterTo }), + [handleSwitchRequest, revealRow, filterTo], + ); /** * Records what an edit did, so a collapse is reported rather than left to look like a vanished diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx index 373306a0..7bf22b20 100644 --- a/src/components/AnalysisStore.tsx +++ b/src/components/AnalysisStore.tsx @@ -3,6 +3,7 @@ import type { MorphemeAnalysis, PhraseAnalysisLink, TextAnalysis, + TokenAnalysisLink, TokenSnapshot, } from 'interlinearizer'; import { @@ -47,6 +48,7 @@ import analysisReducer, { selectSegmentsWithApprovedTranslation, selectStaleFreeTranslations, selectStaleMorphemesAnalysisId, + selectStaleTokenLinks, updatePhrase, writeAnalysisGloss, writeAnalysisMorphemeGloss, @@ -1199,6 +1201,17 @@ export function useStaleFreeTranslationsBySegment( return useMemo(() => placeStaleFreeTranslations(stale, book), [stale, book]); } +/** + * Returns every place in the draft an analysis went stale at, as its stale link, in link order. + * + * @throws When called outside an {@link AnalysisStoreProvider}. + */ +export function useStaleTokenLinks(): readonly TokenAnalysisLink[] { + useRequiredCallbacks('useStaleTokenLinks'); + + return useSelector((state: AnalysisRootState) => selectStaleTokenLinks(state.analysis)); +} + /** * Returns a stable callback that writes a free-translation value for the given segment, then calls * `onSave`. Mirrors {@link useGlossDispatch}: a blank value clears the translation and may drop the diff --git a/src/components/InterlinearNavContext.tsx b/src/components/InterlinearNavContext.tsx index f839fbc0..51a91617 100644 --- a/src/components/InterlinearNavContext.tsx +++ b/src/components/InterlinearNavContext.tsx @@ -46,6 +46,14 @@ export type FadePhase = 'idle' | 'out' | 'in'; */ export type NavOrigin = 'internal' | 'external'; +/** A request that the segment list recenter for a jump. */ +export type RecenterRequest = Readonly<{ + /** Verse jumped to. */ + ref: SerializedVerseRef; + /** Segment the recenter frames. */ + segmentId: string; +}>; + /** * Compares the verse coordinate of two serialized references: book, chapter, and verse number. Used * to detect the host's duplicate deliveries — the scripture picker fires each external navigation @@ -218,6 +226,13 @@ export interface InterlinearNav { * out. */ focusRequestCount: number; + /** + * Asks the segment list to recenter on segment `segmentId` for a jump to `ref`, which it does + * only where navigating there leaves its anchor in place, so the caller navigates as well. + */ + requestRecenter: (ref: SerializedVerseRef, segmentId: string) => void; + /** The latest recenter request, `undefined` until one is made; a fresh object for each. */ + recenterRequest: RecenterRequest | undefined; /** * Claims a pending focus request for the book now on screen, clearing it so a later remount of * that book cannot re-focus a token the user has since navigated away from. A request naming a @@ -356,6 +371,12 @@ export function InterlinearNavProvider({ pendingFocusTokenRef.current = undefined; }, []); + const [recenterRequest, setRecenterRequest] = useState(); + const requestRecenter = useCallback( + (ref: SerializedVerseRef, segmentId: string) => setRecenterRequest({ ref, segmentId }), + [], + ); + const consumeFocusRequest = useCallback((bookCode: string) => { const pending = pendingFocusTokenRef.current; if (pending === undefined || bookOfRef(pending) !== bookCode) return undefined; @@ -482,6 +503,8 @@ export function InterlinearNavProvider({ requestFocusToken, cancelFocusRequest, focusRequestCount, + requestRecenter, + recenterRequest, consumeFocusRequest, peekFocusRequest, publishedFocus, @@ -498,6 +521,8 @@ export function InterlinearNavProvider({ requestFocusToken, cancelFocusRequest, focusRequestCount, + requestRecenter, + recenterRequest, consumeFocusRequest, peekFocusRequest, publishedFocus, diff --git a/src/components/Interlinearizer.tsx b/src/components/Interlinearizer.tsx index cfa9a70a..142baf23 100644 --- a/src/components/Interlinearizer.tsx +++ b/src/components/Interlinearizer.tsx @@ -94,9 +94,9 @@ export default function Interlinearizer({ asOneStep = RUN_UNGROUPED, }: InterlinearizerProps) { // Navigation surface from the context: `consumeInternalNav` lets the segment window suppress the - // fade for internal moves, and `reportSettled` lifts the cross-book curtain once the new book is - // laid out. - const { consumeInternalNav, reportSettled } = useInterlinearNav(); + // fade for internal moves, `recenterRequest` asks it to frame a jump navigation alone would not, + // and `reportSettled` lifts the cross-book curtain once the new book is laid out. + const { consumeInternalNav, recenterRequest, reportSettled } = useInterlinearNav(); useAltHeldAttribute(); @@ -288,6 +288,7 @@ export default function Interlinearizer({ displayContinuousScroll={displayContinuousScroll} onDisplayContinuousScrollChange={setDisplayContinuousScroll} consumeInternalNav={consumeInternalNav} + recenterRequest={recenterRequest} reportSettled={reportSettled} phraseMode={phraseMode} setPhraseMode={setPhraseMode} diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index aeb1968f..2e8d5243 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -19,9 +19,17 @@ import type { SelectMenuItemHandler } from 'platform-bible-react'; import { X } from 'lucide-react'; import { Canon } from '@sillsdev/scripture'; import { formatReplacementString, isPlatformError } from 'platform-bible-utils'; -import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useTransition, +} from 'react'; import type { ComponentProps, ReactNode, RefObject } from 'react'; -import type { TextAnalysis } from 'interlinearizer'; +import type { Segment, TextAnalysis } from 'interlinearizer'; import type { Pt9InterlinearProjectManifest } from 'platform-scripture'; import { resegmentBook } from 'parsers/papi/resegmentBook'; import useUndoRedoKeys from '../hooks/useUndoRedoKeys'; @@ -32,6 +40,7 @@ import useLexiconRegistry from '../hooks/useLexiconRegistry'; import useLostBoundaryDismissal from '../hooks/useLostBoundaryDismissal'; import useOptimisticBooleanSetting from '../hooks/useOptimisticBooleanSetting'; import useProjectBookIds from '../hooks/useProjectBookIds'; +import useStaleAnalysisDismissal from '../hooks/useStaleAnalysisDismissal'; import { isEmptyDelta, mergeSegments, @@ -49,6 +58,7 @@ import { NO_OP_SEGMENTATION_DISPATCH, type SegmentationDispatch } from './Segmen import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; import Interlinearizer from './Interlinearizer'; import { AnalysisStoreProvider } from './AnalysisStore'; +import StaleAnalysesReporter, { type StaleAnalysesReport } from './StaleAnalysesReporter'; import AnalysisCatalogPanel, { type AnalysisCatalogPanelHandle } from './AnalysisCatalogPanel'; import BookNotInProjectView from './BookNotInProjectView'; import { ConcordanceIndexProvider } from './ConcordanceIndexContext'; @@ -66,8 +76,15 @@ import { WipeModal, type WipeScope } from './modals/WipeModal'; import ScriptureNavControls from './controls/ScriptureNavControls'; import { InterlinearNavProvider, useInterlinearNav, type FadePhase } from './InterlinearNavContext'; import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade'; -import { editVerse, firstVerseNumber, segmentContainsVerse } from '../utils/verse-ref'; -import { placeHeadings } from '../utils/analysis-query'; +import { + editVerse, + firstVerseNumber, + isSameVerse, + segmentContainsVerse, + toSerializedVerseRef, +} from '../utils/verse-ref'; +import { placeHeadings, type CatalogFilters } from '../utils/analysis-query'; +import { nextSegmentAmong } from '../utils/stale-free-translations'; import { resolvedOrEmpty } from '../utils/localized-strings'; import { isEmptyMultiString } from '../utils/multi-string'; import usePanelResizeKeys from '../hooks/usePanelResizeKeys'; @@ -266,6 +283,12 @@ const STRING_KEYS = [ '%interlinearizer_segmentation_lostBoundaries%', '%interlinearizer_segmentation_lostBoundaries_one%', '%interlinearizer_segmentation_lostBoundaries_dismiss%', + '%interlinearizer_staleNotice_glossesAndFreeTranslations%', + '%interlinearizer_staleNotice_glosses%', + '%interlinearizer_staleNotice_freeTranslations%', + '%interlinearizer_staleNotice_reviewGlosses%', + '%interlinearizer_staleNotice_nextFreeTranslation%', + '%interlinearizer_staleNotice_dismiss%', '%interlinearizer_tabTitle%', ] as const satisfies `%${string}%`[]; @@ -367,6 +390,9 @@ function InterlinearizerLoaderInner({ fadePhase, cancelFade, requestFocusToken, + cancelFocusRequest, + requestRecenter, + publishedFocus, } = useInterlinearNav(); const [localizedStrings, stringsLoading] = useLocalizedStrings(STRING_KEYS); @@ -706,6 +732,16 @@ function InterlinearizerLoaderInner({ useWebViewState, }); + /** The loaded book's stale analyses, as the draft's store last reported them. */ + const [staleReport, setStaleReport] = useState(); + + const { + glossCount: staleGlossCount, + freeTranslationCount: staleFreeTranslationCount, + freeTranslationSegmentIds: staleFreeTranslationSegmentIds, + onDismiss: handleDismissStaleAnalyses, + } = useStaleAnalysisDismissal({ report: staleReport, draftVersion, useWebViewState }); + /** * Maps each merged-away default verse boundary's word-token split anchor — the verse's first word * token, the ref the boundary slots are keyed by — to the removed default start ref (the verse's @@ -870,8 +906,9 @@ function InterlinearizerLoaderInner({ }, [draftVersion, isImportView]); /** - * The open catalog's handle, so a menu switch away from it can ask first as its own tab does, and - * an undo or redo of a catalog edit can take the reader to its row. + * The open catalog's handle, so a menu switch away from it can ask first as its own tab does, an + * undo or redo of a catalog edit can take the reader to its row, and a stale-gloss review can + * narrow its listing. */ // eslint-disable-next-line no-null/no-null -- React clears an object ref to null on unmount const catalogPanelRef = useRef(null); @@ -1332,6 +1369,74 @@ function InterlinearizerLoaderInner({ const handleShowCatalog = useCallback(() => setSidePanel('catalog'), [setSidePanel]); const handleShowConcordance = useCallback(() => setSidePanel('concordance'), [setSidePanel]); + /** Filters the catalog is to narrow its listing to, held until it is open to take them. */ + const [pendingCatalogFilters, setPendingCatalogFilters] = useState(); + // Laid out rather than passive, so a catalog opening to take the filters never paints its full + // listing first; rerun as the side panel changes, the catalog mounting with it. + useLayoutEffect(() => { + if (!pendingCatalogFilters || !catalogPanelRef.current) return; + catalogPanelRef.current.filterTo(pendingCatalogFilters); + setPendingCatalogFilters(undefined); + }, [pendingCatalogFilters, sidePanel]); + + /** Opens the catalog on the analyses stale in the loaded book. */ + const handleReviewStaleGlosses = useCallback(() => { + /* v8 ignore next -- the notice shows only once a book has loaded */ + if (!book) return; + setSidePanel('catalog'); + setPendingCatalogFilters({ stale: true, books: [book.bookRef] }); + }, [book, setSidePanel]); + + /** + * The segment the last jump to a stale free translation landed on and the focus published then, + * marking the reader's place where focus cannot, in a segment holding no words. + */ + const lastStaleJumpRef = useRef<{ segment: Segment; focus: string | undefined }>(undefined); + + /** + * Takes the reader to the next segment showing a stale free translation the notice reports, + * wrapping around, and turns the free translation line on to show it. + */ + const handleNextStaleFreeTranslation = useCallback(() => { + /* v8 ignore next -- the notice shows only once a book has loaded */ + if (!book) return; + const focus = publishedFocus.get(); + const lastJump = lastStaleJumpRef.current; + // A reseed on landing in another verse withdraws the published focus without the reader moving. + const stayedPut = + lastJump !== undefined && + isSameVerse(lastJump.segment.startRef, activeScrRef) && + (focus === undefined || focus === lastJump.focus); + const target = nextSegmentAmong( + book, + staleFreeTranslationSegmentIds, + focus, + activeScrRef, + stayedPut ? lastJump.segment.id : undefined, + ); + /* v8 ignore next -- the notice offers this only while a segment of the book shows one */ + if (!target) return; + lastStaleJumpRef.current = { segment: target, focus }; + if (!showFreeTranslation) handleShowFreeTranslationChange(true); + if (target.tokens.some(isWordToken)) requestFocusToken(target.id); + else cancelFocusRequest(); + const targetRef = toSerializedVerseRef(target.startRef); + // Navigating to the active verse moves nothing, so the list is asked to frame the target. + requestRecenter(targetRef, target.id); + navigate(targetRef); + }, [ + book, + staleFreeTranslationSegmentIds, + publishedFocus, + activeScrRef, + showFreeTranslation, + handleShowFreeTranslationChange, + requestFocusToken, + cancelFocusRequest, + requestRecenter, + navigate, + ]); + /** * Records a layout the group reports, keeping the stored one naming both panels. A group reports * a layout over the panels mounted at the time, so a closed side panel is reported absent rather @@ -1674,11 +1779,29 @@ function InterlinearizerLoaderInner({ onPendingEditsChange={setPendingEdits} subscribeToReplacements={subscribeToAnalysisReplacements} > + {book && isLoaded && } {panelGroup} ); } + let staleNoticeMessage: string; + if (staleGlossCount > 0 && staleFreeTranslationCount > 0) + staleNoticeMessage = formatReplacementString( + localizedStrings['%interlinearizer_staleNotice_glossesAndFreeTranslations%'], + { glossCount: staleGlossCount, freeTranslationCount: staleFreeTranslationCount }, + ); + else if (staleGlossCount > 0) + staleNoticeMessage = formatReplacementString( + localizedStrings['%interlinearizer_staleNotice_glosses%'], + { count: staleGlossCount }, + ); + else + staleNoticeMessage = formatReplacementString( + localizedStrings['%interlinearizer_staleNotice_freeTranslations%'], + { count: staleFreeTranslationCount }, + ); + return (
)} + {isLoaded && (staleGlossCount > 0 || staleFreeTranslationCount > 0) && !stringsLoading && ( +
+ {staleNoticeMessage} + + {staleGlossCount > 0 && ( + + )} + {staleFreeTranslationCount > 0 && ( + + )} + + +
+ )} +
{/* Above the keyed stores in the view area, so the source text is read once per tab. */} boolean; + /** The latest request that the window recenter for a jump navigation alone would not frame. */ + recenterRequest: RecenterRequest | undefined; /** Reports that the window has settled on the current book; lifts the cross-book curtain. */ reportSettled: () => void; /** Current phrase-interaction mode; passed through to each {@link SegmentView}. */ @@ -239,6 +242,7 @@ export default function SegmentListView({ displayContinuousScroll, onDisplayContinuousScrollChange, consumeInternalNav, + recenterRequest, reportSettled, phraseMode, setPhraseMode, @@ -440,6 +444,7 @@ export default function SegmentListView({ continuousScroll, scrollContainerRef, consumeInternalNav, + recenterRequest, onDisplayContinuousScrollChange, heightTable, onSettled: reportSettled, diff --git a/src/components/StaleAnalysesReporter.tsx b/src/components/StaleAnalysesReporter.tsx new file mode 100644 index 00000000..b22adae2 --- /dev/null +++ b/src/components/StaleAnalysesReporter.tsx @@ -0,0 +1,80 @@ +import type { Book } from 'interlinearizer'; +import { useEffect, useMemo, useRef } from 'react'; +import { bookOfRef } from '../utils/analysis-book'; +import { useStaleFreeTranslationsBySegment, useStaleTokenLinks } from './AnalysisStore'; + +/** The stale analyses a loaded book holds, each named by a key that holds while it stays stale. */ +export type StaleAnalysesReport = Readonly<{ + bookRef: string; + /** A key per place in the book an analysis went stale at. */ + glosses: readonly string[]; + /** + * A key per stale free translation the book shows, with the segment showing it, in document + * order. + */ + freeTranslations: readonly Readonly<{ key: string; segmentId: string }>[]; +}>; + +function sameReport(a: StaleAnalysesReport | undefined, b: StaleAnalysesReport): boolean { + return ( + a !== undefined && + a.bookRef === b.bookRef && + a.glosses.length === b.glosses.length && + a.glosses.every((key, i) => key === b.glosses[i]) && + a.freeTranslations.length === b.freeTranslations.length && + a.freeTranslations.every( + ({ key, segmentId }, i) => + key === b.freeTranslations[i].key && segmentId === b.freeTranslations[i].segmentId, + ) + ); +} + +/** + * Reports the stale analyses `book` holds to a surface outside the analysis store — on mount, on + * each change, and as `undefined` on unmount. Renders nothing. + */ +export default function StaleAnalysesReporter({ + book, + onReport, +}: Readonly<{ + book: Book; + onReport: (report: StaleAnalysesReport | undefined) => void; +}>) { + const staleTokenLinks = useStaleTokenLinks(); + const freeTranslationsBySegment = useStaleFreeTranslationsBySegment(book); + + const report = useMemo( + () => ({ + bookRef: book.bookRef, + glosses: [ + ...new Set( + staleTokenLinks + .filter((link) => bookOfRef(link.token.tokenRef) === book.bookRef) + .map((link) => `gloss ${link.analysisId} ${link.token.tokenRef}`), + ), + ], + freeTranslations: [...freeTranslationsBySegment].flatMap(([segmentId, translations]) => + translations.map((t) => ({ key: `freeTranslation ${t.analysisId}`, segmentId })), + ), + }), + [book.bookRef, staleTokenLinks, freeTranslationsBySegment], + ); + + // Every analysis edit rebuilds the stale links, and reporting each rebuild would re-render the + // receiving surface for edits that staled nothing. + const reportedRef = useRef(undefined); + useEffect(() => { + if (sameReport(reportedRef.current, report)) return; + reportedRef.current = report; + onReport(report); + }, [report, onReport]); + useEffect( + () => () => { + reportedRef.current = undefined; + onReport(undefined); + }, + [onReport], + ); + + return undefined; +} diff --git a/src/hooks/useLostBoundaryDismissal.ts b/src/hooks/useLostBoundaryDismissal.ts index 8615cb8f..f217b317 100644 --- a/src/hooks/useLostBoundaryDismissal.ts +++ b/src/hooks/useLostBoundaryDismissal.ts @@ -1,7 +1,8 @@ import type { UseWebViewStateHook } from '@papi/core'; import type { Book, SegmentationDelta } from 'interlinearizer'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useMemo } from 'react'; import { lostBoundaries as findLostBoundaries } from '../utils/segmentation'; +import useRecordDismissal from './useRecordDismissal'; type LostBoundaryDismissalOptions = { /** The verse-tokenized loaded book, `undefined` while none is loaded. */ @@ -30,12 +31,7 @@ type LostBoundaryDismissal = { /** * Finds the draft's segment boundaries the loaded source text no longer carries — a loss that * reverts those regions to one segment per verse, silently without this — and tracks which of them - * the user has dismissed the banner for. - * - * A dismissal names the boundaries it covered rather than setting a flag, so a later edit that - * strands further boundaries raises the banner again while a shrinking loss leaves it down. It is - * dropped when the draft is replaced wholesale, and singly when one recovers, on the grounds that - * either makes the next loss one the user has not seen. + * the user has dismissed the banner for, a boundary that recovers dropping out of the dismissal. */ export default function useLostBoundaryDismissal({ verseBook, @@ -54,79 +50,13 @@ export default function useLostBoundaryDismissal({ [verseBook, segmentationVersion, draftVersion, isDraftLoading, isImportView], ); - /** - * The lost boundaries the user has dismissed the banner for — an acknowledgement of a message, - * tab-scoped rather than persisted into the draft alongside the analysis. - */ - const [dismissedLostBoundaries, setDismissedLostBoundaries] = useWebViewState( - 'dismissedLostBoundaries', - [], - ); - - const undismissedLostBoundaries = useMemo(() => { - const dismissed = new Set(dismissedLostBoundaries); - return lostBoundaries.filter((ref) => !dismissed.has(ref)); - }, [lostBoundaries, dismissedLostBoundaries]); - - /** - * The stored list spans the whole draft while a loaded book reports only its own boundaries, so a - * dismissal has to leave every other book's acknowledgement standing. - */ - const onDismiss = useCallback(() => { - setDismissedLostBoundaries([...new Set([...dismissedLostBoundaries, ...lostBoundaries])]); - }, [dismissedLostBoundaries, lostBoundaries, setDismissedLostBoundaries]); - - /** - * The draft the dismissal acknowledged, so a wholesale replacement drops it: the acknowledgement - * was of one draft's message, and a replacement stranding the same boundary is a loss the user - * has not seen. - */ - const dismissedDraftVersionRef = useRef(draftVersion); - /** Whether the dismissal has just been dropped wholesale, leaving nothing to drop singly. */ - const draftReplacedRef = useRef(false); - useEffect(() => { - // Skipping the mount pass leaves a dismissal restored with the tab in place. - if (dismissedDraftVersionRef.current === draftVersion) return; - dismissedDraftVersionRef.current = draftVersion; - draftReplacedRef.current = true; - setDismissedLostBoundaries([]); - }, [draftVersion, setDismissedLostBoundaries]); - - /** - * Drops a dismissed boundary once it recovers, so stranding it again raises the banner rather - * than carrying the earlier acknowledgement across the recovery. - * - * Only a recovery seen in this tab counts: a dismissal restored alongside the tab names - * boundaries from whatever the source looked like when it was made, so absence alone implies no - * recovery. - * - * A recovery is only observable in the book that owns the boundary: a book that never reports - * one, like an unloaded or import view, says nothing about whether it came back. - */ - const observedLostBoundariesRef = useRef(new Map()); - useEffect(() => { - const observableBookRef = isImportView ? undefined : verseBook?.bookRef; - if (!observableBookRef) return; - const observed = observedLostBoundariesRef.current; - const previous = observed.get(observableBookRef); - observed.set(observableBookRef, lostBoundaries); - if (draftReplacedRef.current) { - draftReplacedRef.current = false; - return; - } - if (previous === undefined) return; - const stillLost = new Set(lostBoundaries); - const recovered = previous.filter((ref) => !stillLost.has(ref)); - if (recovered.length === 0) return; - const recoveredSet = new Set(recovered); - setDismissedLostBoundaries(dismissedLostBoundaries.filter((ref) => !recoveredSet.has(ref))); - }, [ - lostBoundaries, - dismissedLostBoundaries, - setDismissedLostBoundaries, - verseBook, - isImportView, - ]); + const { undismissed, onDismiss } = useRecordDismissal({ + records: lostBoundaries, + observedBookRef: isImportView ? undefined : verseBook?.bookRef, + draftVersion, + stateKey: 'dismissedLostBoundaries', + useWebViewState, + }); - return { undismissedLostBoundaries, onDismiss }; + return { undismissedLostBoundaries: undismissed, onDismiss }; } diff --git a/src/hooks/useRecordDismissal.ts b/src/hooks/useRecordDismissal.ts new file mode 100644 index 00000000..f71872de --- /dev/null +++ b/src/hooks/useRecordDismissal.ts @@ -0,0 +1,103 @@ +import type { UseWebViewStateHook } from '@papi/core'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +type RecordDismissalOptions = { + /** The records the banner reports for the loaded book. */ + records: readonly string[]; + /** The book `records` were found in, `undefined` while no book's records are observable. */ + observedBookRef: string | undefined; + /** Bumped by every wholesale draft replacement (New / Open / Wipe). */ + draftVersion: number; + /** The WebView-state key the dismissal is stored under, one per banner. */ + stateKey: string; + /** Scopes the dismissal to one tab. */ + useWebViewState: UseWebViewStateHook; +}; + +type RecordDismissal = { + /** The records the user has not dismissed the banner for. */ + undismissed: readonly string[]; + /** Dismisses the banner for exactly the records it is currently reporting. */ + onDismiss: () => void; +}; + +/** + * Tracks which of a banner's records the user has dismissed it for. + * + * A dismissal names the records it covered rather than setting a flag, so a later edit raising + * further records raises the banner again while a shrinking set leaves it down. It is dropped when + * the draft is replaced wholesale, and singly when a record clears, on the grounds that either + * makes the next one a record the user has not seen. + */ +export default function useRecordDismissal({ + records, + observedBookRef, + draftVersion, + stateKey, + useWebViewState, +}: RecordDismissalOptions): RecordDismissal { + /** + * The records the user has dismissed the banner for — an acknowledgment of a message, tab-scoped + * rather than persisted into the draft alongside the analysis. + */ + const [dismissed, setDismissed] = useWebViewState(stateKey, []); + + const undismissed = useMemo(() => { + const dismissedSet = new Set(dismissed); + return records.filter((record) => !dismissedSet.has(record)); + }, [records, dismissed]); + + /** + * The stored list spans the whole draft while a loaded book reports only its own records, so a + * dismissal has to leave every other book's acknowledgment standing. + */ + const onDismiss = useCallback(() => { + setDismissed([...new Set([...dismissed, ...records])]); + }, [dismissed, records, setDismissed]); + + /** + * The draft the dismissal acknowledged, so a wholesale replacement drops it: the acknowledgment + * was of one draft's message, and a replacement raising the same record is one the user has not + * seen. + */ + const dismissedDraftVersionRef = useRef(draftVersion); + /** Whether the dismissal has just been dropped wholesale, leaving nothing to drop singly. */ + const draftReplacedRef = useRef(false); + useEffect(() => { + // Skipping the mount pass leaves a dismissal restored with the tab in place. + if (dismissedDraftVersionRef.current === draftVersion) return; + dismissedDraftVersionRef.current = draftVersion; + draftReplacedRef.current = true; + setDismissed([]); + }, [draftVersion, setDismissed]); + + /** + * Drops a dismissed record once it clears, so raising it again raises the banner rather than + * carrying the earlier acknowledgment across. + * + * Only a clearing seen in this tab counts: a dismissal restored alongside the tab names records + * from whatever the draft looked like when it was made, so absence alone implies no clearing. + * + * A clearing is only observable in the book that owns the record: a book that never reports one, + * like an unloaded or import view, says nothing about whether it cleared. + */ + const observedRecordsRef = useRef(new Map()); + useEffect(() => { + if (!observedBookRef) return; + const observed = observedRecordsRef.current; + const previous = observed.get(observedBookRef); + observed.set(observedBookRef, records); + if (draftReplacedRef.current) { + draftReplacedRef.current = false; + return; + } + if (previous === undefined) return; + const stillRaised = new Set(records); + const cleared = previous.filter((record) => !stillRaised.has(record)); + if (cleared.length === 0) return; + const clearedSet = new Set(cleared); + setDismissed(dismissed.filter((record) => !clearedSet.has(record))); + }, [records, dismissed, setDismissed, observedBookRef]); + + return { undismissed, onDismiss }; +} diff --git a/src/hooks/useSegmentWindow.ts b/src/hooks/useSegmentWindow.ts index 7259fdde..e1814364 100644 --- a/src/hooks/useSegmentWindow.ts +++ b/src/hooks/useSegmentWindow.ts @@ -3,6 +3,7 @@ import type { SerializedVerseRef } from '@sillsdev/scripture'; import type { RefObject } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; +import type { RecenterRequest } from '../components/InterlinearNavContext'; import { RECENTER_FADE_MS } from '../components/recenter-fade'; import type { HeightTable } from '../utils/segment-heights'; import { offsetOfSegment, segmentIndexAtOffset } from '../utils/segment-heights'; @@ -144,6 +145,11 @@ export interface UseSegmentWindowArgs { * at the `navigate` call site. */ consumeInternalNav: (ref: SerializedVerseRef) => boolean; + /** + * The latest recenter request, honored once when its verse resolves to the anchor already held — + * the one navigation that recenters nothing on its own. + */ + recenterRequest: RecenterRequest | undefined; /** * Called — synchronously, inside the recenter midpoint's state batch — with the gated * continuous-scroll value the views should now render. The parent owns the horizontal strip, @@ -310,6 +316,7 @@ export default function useSegmentWindow({ continuousScroll, scrollContainerRef, consumeInternalNav, + recenterRequest, onDisplayContinuousScrollChange, heightTable, onSettled, @@ -464,6 +471,9 @@ export default function useSegmentWindow({ */ const pendingRecenterSnapRef = useRef(needsInitialSnapRef.current); + /** Segment the latest recenter was asked to frame, `undefined` for one on the active verse. */ + const recenterTargetIdRef = useRef(undefined); + /** Latest range, mirrored so the observer callbacks read fresh bounds without re-subscribing. */ const rangeRef = useLatestRef(range); @@ -551,8 +561,8 @@ export default function useSegmentWindow({ ); /** - * Snaps the recenter target — the active verse (the `aria-current` element) — to the top of the - * scroll container. + * Snaps the recenter target — the segment the recenter was asked to frame, else the active verse + * (the `aria-current` element) — to the top of the scroll container. * * When the content below the target is too short for `scrollIntoView` to reach the top (common in * baseline-text mode where segments are compact, and especially after the continuous-scroll strip @@ -563,9 +573,17 @@ export default function useSegmentWindow({ */ const snapActiveToTop = useCallback(() => { const container = scrollContainerRef.current; - const target = container?.querySelector('[aria-current="true"]'); + /* v8 ignore next -- container is always mounted while the window renders */ + if (!container) return; + const targetId = recenterTargetIdRef.current; + const target = + targetId === undefined + ? container.querySelector('[aria-current="true"]') + : Array.from(container.querySelectorAll('[data-segment-id]')).find( + (el) => el.getAttribute('data-segment-id') === targetId, + ); /* v8 ignore next -- the recentered target is always mounted, so its element exists */ - if (!target || !container) return; + if (!target) return; const spacer = container.querySelector('[data-snap-spacer]'); if (spacer) spacer.style.height = '0px'; target.scrollIntoView({ behavior: 'auto', block: 'start' }); @@ -581,9 +599,9 @@ export default function useSegmentWindow({ // so neither an extend nor a recenter ever shows a jump. An extend mutated the window around a // recorded anchor element: add the anchor's measured rect delta to scrollTop so it (and all // visible content) holds its exact viewport position, whatever combination of prepended, appended, - // and culled height the mutation produced. A recenter rebuilds around a new verse: snap that verse - // (the `aria-current` element) to the top. Both are mutually exclusive — a given range change is - // at most one of the two — and self-clear so unrelated renders leave the position alone. An extend + // and culled height the mutation produced. A recenter rebuilds around its target: snap that target + // to the top. Both are mutually exclusive — a given range change is at most one of the two — and + // self-clear so unrelated renders leave the position alone. An extend // invalidates the compensation anchor (its element may have been culled, and the window around it // changed), so re-baseline rather than let the next resize "correct" a shift this effect already // handled (a recenter re-baselines through its epoch-driven re-subscription instead). @@ -629,47 +647,58 @@ export default function useSegmentWindow({ // #region Recenter trigger + navigation reaction /** - * Rebuilds the window centered on the active verse, on its focused segment where it has one, and - * fades it into view. Exposed as the imperative `recenterOnActive`. + * Rebuilds the window centered on segment `targetSegmentId` where one is named, else on the + * active verse — on its focused segment where it has one — and fades it into view. * * Keeps one identity across renders. A fresh call supersedes any in-flight fade, and no unrelated * re-render can cancel one. */ - const triggerRecenter = useCallback(() => { - if (recenterTimeoutRef.current !== undefined) clearTimeout(recenterTimeoutRef.current); - markRecenterStarted(); - setIsFaded(true); - recenterTimeoutRef.current = setTimeout(() => { - recenterTimeoutRef.current = undefined; - pendingRecenterSnapRef.current = true; - const centerIndex = findCenterIndex( - segmentsRef.current, - scrRefRef.current, - focusedTokenRefRef.current, - anchorIndexRef.current, - ); - setRange(buildCenteredRange(centerIndex, totalRef.current)); - beginRecenterSettle(); - setDisplayScrRef(scrRefRef.current); - setDisplayFocusedTokenRef(focusedTokenRefRef.current); - // Flip the parent's strip visibility (and the segments' display mode, which the parent passes - // back down) in this same state batch so the strip mounts/unmounts in the same commit as the - // window rebuild above — the re-snap loop then measures the active verse against the final, - // strip-included layout instead of snapping before the strip exists. - onDisplayContinuousScrollChangeRef.current(continuousScrollRef.current); - setIsFaded(false); - }, RECENTER_FADE_MS); - }, [ - markRecenterStarted, - beginRecenterSettle, - segmentsRef, - anchorIndexRef, - totalRef, - scrRefRef, - focusedTokenRefRef, - continuousScrollRef, - onDisplayContinuousScrollChangeRef, - ]); + const triggerRecenter = useCallback( + (targetSegmentId?: string) => { + if (recenterTimeoutRef.current !== undefined) clearTimeout(recenterTimeoutRef.current); + markRecenterStarted(); + setIsFaded(true); + recenterTimeoutRef.current = setTimeout(() => { + recenterTimeoutRef.current = undefined; + pendingRecenterSnapRef.current = true; + recenterTargetIdRef.current = targetSegmentId; + const targetIndex = segmentsRef.current.findIndex((seg) => seg.id === targetSegmentId); + const centerIndex = + targetIndex !== -1 + ? targetIndex + : findCenterIndex( + segmentsRef.current, + scrRefRef.current, + focusedTokenRefRef.current, + anchorIndexRef.current, + ); + setRange(buildCenteredRange(centerIndex, totalRef.current)); + beginRecenterSettle(); + setDisplayScrRef(scrRefRef.current); + setDisplayFocusedTokenRef(focusedTokenRefRef.current); + // Flip the parent's strip visibility (and the segments' display mode, which the parent passes + // back down) in this same state batch so the strip mounts/unmounts in the same commit as the + // window rebuild above — the re-snap loop then measures the active verse against the final, + // strip-included layout instead of snapping before the strip exists. + onDisplayContinuousScrollChangeRef.current(continuousScrollRef.current); + setIsFaded(false); + }, RECENTER_FADE_MS); + }, + [ + markRecenterStarted, + beginRecenterSettle, + segmentsRef, + anchorIndexRef, + totalRef, + scrRefRef, + focusedTokenRefRef, + continuousScrollRef, + onDisplayContinuousScrollChangeRef, + ], + ); + + // Discards its arguments so none is mistaken for a target segment. + const recenterOnActive = useCallback(() => triggerRecenter(), [triggerRecenter]); // Recenter on external navigation. An `scrRef` change the parent originated internally (a click in // this list, or strip arrow nav echoed back) makes `consumeInternalNav` return true, so skip the @@ -736,6 +765,17 @@ export default function useSegmentWindow({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [anchorIndex, segments, segmentationVersion, triggerRecenter]); + // Seeded at mount, so a remount never honors a request made for an earlier view. + const prevRecenterRequestRef = useRef(recenterRequest); + useEffect(() => { + const prev = prevRecenterRequestRef.current; + prevRecenterRequestRef.current = recenterRequest; + if (!recenterRequest || recenterRequest === prev) return; + if (findAnchorIndex(segmentsRef.current, recenterRequest.ref) !== anchorIndexRef.current) + return; + triggerRecenter(recenterRequest.segmentId); + }, [recenterRequest, segmentsRef, anchorIndexRef, triggerRecenter]); + // Track within-verse focus moves (arrow/click that stays in the active verse) immediately. These // change `focusedTokenRef` without changing `anchorIndex`, so the recenter effect above never // fires for them; sync the display ref here so the focus highlight follows. Skip while a recenter @@ -1070,6 +1110,6 @@ export default function useSegmentWindow({ topSentinelRef, bottomSentinelRef, contentRef, - recenterOnActive: triggerRecenter, + recenterOnActive, }; } diff --git a/src/hooks/useStaleAnalysisDismissal.ts b/src/hooks/useStaleAnalysisDismissal.ts new file mode 100644 index 00000000..405f4a0f --- /dev/null +++ b/src/hooks/useStaleAnalysisDismissal.ts @@ -0,0 +1,63 @@ +import type { UseWebViewStateHook } from '@papi/core'; +import { useMemo } from 'react'; +import type { StaleAnalysesReport } from '../components/StaleAnalysesReporter'; +import useRecordDismissal from './useRecordDismissal'; + +type StaleAnalysisDismissalOptions = { + /** The loaded book's stale analyses, `undefined` while no book's are known. */ + report: StaleAnalysesReport | undefined; + /** Bumped by every wholesale draft replacement (New / Open / Wipe). */ + draftVersion: number; + /** Scopes the dismissal to one tab. */ + useWebViewState: UseWebViewStateHook; +}; + +type StaleAnalysisDismissal = { + /** How many of the book's stale gloss places the user has not dismissed the notice for. */ + glossCount: number; + /** How many of the book's stale free translations the user has not dismissed the notice for. */ + freeTranslationCount: number; + /** The segments showing those free translations, in document order. */ + freeTranslationSegmentIds: readonly string[]; + /** Dismisses the notice for exactly the stale analyses it is currently reporting. */ + onDismiss: () => void; +}; + +const NO_RECORDS: readonly string[] = []; + +/** + * Tracks which of the loaded book's stale analyses the user has dismissed the notice for, an + * analysis that stops being stale dropping out of the dismissal. + */ +export default function useStaleAnalysisDismissal({ + report, + draftVersion, + useWebViewState, +}: StaleAnalysisDismissalOptions): StaleAnalysisDismissal { + const records = useMemo( + () => + report + ? [...report.glosses, ...report.freeTranslations.map((translation) => translation.key)] + : NO_RECORDS, + [report], + ); + + const { undismissed, onDismiss } = useRecordDismissal({ + records, + observedBookRef: report?.bookRef, + draftVersion, + stateKey: 'dismissedStaleAnalyses', + useWebViewState, + }); + + return useMemo(() => { + const raised = new Set(undismissed); + const freeTranslations = (report?.freeTranslations ?? []).filter(({ key }) => raised.has(key)); + return { + glossCount: (report?.glosses ?? []).filter((key) => raised.has(key)).length, + freeTranslationCount: freeTranslations.length, + freeTranslationSegmentIds: [...new Set(freeTranslations.map(({ segmentId }) => segmentId))], + onDismiss, + }; + }, [report, undismissed, onDismiss]); +} diff --git a/src/store/analysisSlice.ts b/src/store/analysisSlice.ts index e2770604..d12837b7 100644 --- a/src/store/analysisSlice.ts +++ b/src/store/analysisSlice.ts @@ -2440,4 +2440,12 @@ export const selectStaleFreeTranslations = createSelector( }, ); +/** Memoized selector listing every stale token link whose payload exists, in link order. */ +export const selectStaleTokenLinks = createSelector( + selectTokenAnalysisLinks, + selectAnalysisById, + (links, byId): readonly TokenAnalysisLink[] => + links.filter((link) => link.status === 'stale' && byId.has(link.analysisId)), +); + // #endregion diff --git a/src/utils/analysis-query.ts b/src/utils/analysis-query.ts index b865b2c0..d1dbf9bf 100644 --- a/src/utils/analysis-query.ts +++ b/src/utils/analysis-query.ts @@ -99,7 +99,7 @@ export interface CatalogFilters { * alongside keeps none. */ zeroUsages?: boolean; - /** Keeps only rows with a stale location. */ + /** Keeps only rows with a stale location, in one of the selected books where `books` is active. */ stale?: boolean; /** Keeps only rows with no gloss in the scope's analysis language. */ missingGloss?: boolean; @@ -580,12 +580,17 @@ function passesFeatures(row: CatalogRow, selected: CatalogFilters['features']): /** Whether the row survives every active filter. */ function passesFilters(row: CatalogRow, filters: CatalogFilters): boolean { - if (isActive(filters.books) && !filters.books.some((book) => row.books.has(book))) return false; + const { books } = filters; + if (isActive(books) && !books.some((book) => row.books.has(book))) return false; if (!passesValue(filters.pos, row.pos)) return false; if (!passesValue(filters.confidence, row.confidence)) return false; if (!passesFeatures(row, filters.features)) return false; if (filters.zeroUsages && (row.usageCount > 0 || row.staleLocations.length > 0)) return false; - if (filters.stale && row.staleLocations.length === 0) return false; + if ( + filters.stale && + !row.staleLocations.some((place) => !isActive(books) || books.includes(place.book)) + ) + return false; if (filters.missingGloss && row.gloss !== '') return false; if (filters.morphemes) { const hasMorphemes = row.morphemes.length > 0; diff --git a/src/utils/stale-free-translations.ts b/src/utils/stale-free-translations.ts index f30656ac..6d33c05f 100644 --- a/src/utils/stale-free-translations.ts +++ b/src/utils/stale-free-translations.ts @@ -1,6 +1,8 @@ -import type { Book } from 'interlinearizer'; +import type { SerializedVerseRef } from '@sillsdev/scripture'; +import type { Book, Segment } from 'interlinearizer'; import { bookOfRef } from './analysis-book'; import { verseOfTokenRef } from './reanchor-analysis'; +import { segmentContainsVerse } from './verse-ref'; /** A free translation whose segment's text has changed since it was written. */ export type StaleFreeTranslation = Readonly<{ @@ -116,3 +118,31 @@ export function placeStaleFreeTranslations( }); return bySegment; } + +/** + * Finds the first of `segmentIds` after the reader's place in `book`, wrapping around to the book's + * first of them, or `undefined` when the book holds none. The reader is at the segment + * `landedSegmentId` names, else at the segment holding `focusedTokenRef`, else at the first holding + * `scrRef`'s verse, else ahead of the whole book. + */ +export function nextSegmentAmong( + book: Book, + segmentIds: readonly string[], + focusedTokenRef: string | undefined, + scrRef: SerializedVerseRef, + landedSegmentId?: string, +): Segment | undefined { + const { segments } = book; + let readerIndex = segments.findIndex((segment) => segment.id === landedSegmentId); + if (readerIndex === -1) + readerIndex = segments.findIndex((segment) => + segment.tokens.some((token) => token.ref === focusedTokenRef), + ); + if (readerIndex === -1) + readerIndex = segments.findIndex((segment) => segmentContainsVerse(segment, scrRef)); + const wanted = new Set(segmentIds); + return ( + segments.find((segment, index) => index > readerIndex && wanted.has(segment.id)) ?? + segments.find((segment) => wanted.has(segment.id)) + ); +}