diff --git a/src/api.ts b/src/api.ts index b8930f1f3e..0d6148e6fd 100644 --- a/src/api.ts +++ b/src/api.ts @@ -5,6 +5,7 @@ import { NextApiRequest } from 'next'; import { MushafLines, QuranFont } from '@/types/QuranReader'; import { SearchRequestParams, SearchMode } from '@/types/Search/SearchRequestParams'; import NewSearchResponse from '@/types/Search/SearchResponse'; +import { getMushafId } from '@/utils/api'; import { makeAdvancedCopyUrl, makeTafsirsUrl, @@ -28,6 +29,7 @@ import { makeNewSearchResultsUrl, makeByRangeVersesUrl, makeWordByWordTranslationsUrl, + makeVersesFilterUrl, } from '@/utils/apiPaths'; import { getAdditionalHeaders } from '@/utils/headers'; import { AdvancedCopyRequest, PagesLookUpRequest } from 'types/ApiRequests'; @@ -392,3 +394,31 @@ export const getTafsirContent = ( }), ); }; + +/** + * Get the page number for a specific verse by chapter and verse number. + * This is used as a fallback when verse data is not already available in memory. + * + * @param {string} chapterId the chapter ID + * @param {number} verseNumber the verse number + * @param {QuranFont} quranFont the selected Quran font + * @param {MushafLines} mushafLines the selected mushaf lines + * @param {AbortSignal} signal optional abort signal for request cancellation + * @returns {Promise} + */ +export const getVersePageNumber = async ( + chapterId: string, + verseNumber: number, + quranFont: QuranFont, + mushafLines: MushafLines, + signal?: AbortSignal, +): Promise => { + return fetcher( + makeVersesFilterUrl({ + filters: `${chapterId}:${verseNumber}`, + fields: `page_number`, + ...getMushafId(quranFont, mushafLines), + }), + { signal }, + ); +}; diff --git a/src/components/QuranReader/ReadingView/hooks/useFetchVersePageNumber.ts b/src/components/QuranReader/ReadingView/hooks/useFetchVersePageNumber.ts new file mode 100644 index 0000000000..91f8dc7af8 --- /dev/null +++ b/src/components/QuranReader/ReadingView/hooks/useFetchVersePageNumber.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { getVersePageNumber } from '@/api'; +import { logErrorToSentry } from '@/lib/sentry'; +import { MushafLines, QuranFont } from '@/types/QuranReader'; +import { VersesResponse } from 'types/ApiResponses'; + +/** + * Custom hook for fetching verse page number with AbortController support. + * + * @description + * This hook does NOT use SWR because the fetch is triggered imperatively from a callback (`scrollToVerse`), + * not declaratively based on component mount or prop changes. SWR is designed for declarative data fetching + * where the key determines when to fetch. We also need fine-grained AbortController control for race condition + * handling and request cancellation. + * + * @param {QuranFont} quranFont the selected Quran font + * @param {MushafLines} mushafLines the selected mushaf lines + * @returns {Function} A function to fetch verse page number + */ +const useFetchVersePageNumber = (quranFont: QuranFont, mushafLines: MushafLines) => { + const abortControllerRef = useRef(null); + + // Cleanup the abort controller on unmount + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + /** + * Fetch the page number for a specific verse. + * + * @param {string} chapterId the chapter ID + * @param {number} verseNumber the verse number + * @returns {Promise} The verses response containing page number, or void if aborted + */ + const fetchVersePageNumber = useCallback( + async (chapterId: string, verseNumber: number): Promise => { + // Abort previous request (if any) to prevent race conditions + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + const controller = new AbortController(); + // eslint-disable-next-line no-param-reassign + abortControllerRef.current = controller; + + try { + return await getVersePageNumber( + chapterId, + verseNumber, + quranFont, + mushafLines, + controller.signal, + ); + } catch (error: unknown) { + // Ignore abort errors (they're expected when canceling stale requests) + if (error instanceof Error && error.name === 'AbortError') { + return undefined; + } + + // Log other errors to Sentry + logErrorToSentry(error, { + transactionName: 'fetchVersePageNumber', + metadata: { chapterId, verseNumber }, + }); + + return undefined; + } + }, + [quranFont, mushafLines], + ); + + return fetchVersePageNumber; +}; + +export default useFetchVersePageNumber; diff --git a/src/components/QuranReader/ReadingView/hooks/useScrollToVirtualizedVerse.ts b/src/components/QuranReader/ReadingView/hooks/useScrollToVirtualizedVerse.ts index 44b59e689d..a11307a448 100644 --- a/src/components/QuranReader/ReadingView/hooks/useScrollToVirtualizedVerse.ts +++ b/src/components/QuranReader/ReadingView/hooks/useScrollToVirtualizedVerse.ts @@ -1,52 +1,18 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/router'; import { VirtuosoHandle } from 'react-virtuoso'; +import useFetchVersePageNumber from './useFetchVersePageNumber'; + +import useAudioNavigationScroll from '@/hooks/useAudioNavigationScroll'; import QuranReaderStyles from '@/redux/types/QuranReaderStyles'; import { MushafLines, QuranFont, QuranReaderDataType } from '@/types/QuranReader'; -import { getMushafId } from '@/utils/api'; -import { makeVersesFilterUrl } from '@/utils/apiPaths'; -import { getVerseNumberFromKey } from '@/utils/verse'; -import { fetcher } from 'src/api'; +import { getVersePositionWithinAMushafPage } from '@/utils/verse'; import { VersesResponse } from 'types/ApiResponses'; import LookupRecord from 'types/LookupRecord'; -import ScrollAlign from 'types/ScrollAlign'; import Verse from 'types/Verse'; -/** - * Get where a verse lies in a mushaf page. This is achieved by: - * - * 1. Checking where the index of the current verse is within the page. - * 2. Calculating how far the index is from the beginning of the array of verses of that page. - * 3. If it lies in the first third portion, then its position is 'start', the second - * third of the page, its position is 'center', the last third of the page its position - * is 'end'. - * - * @param {string} startingVerseKey - * @param {number} mushafPageNumber - * @param {Record} pagesVersesRange - * @returns {ScrollAlign} - */ -const getVersePositionWithinAMushafPage = ( - startingVerseKey: string, - mushafPageNumber: number, - pagesVersesRange: Record, -): ScrollAlign => { - const pageStartVerseNumber = getVerseNumberFromKey(pagesVersesRange[mushafPageNumber].from); - const pageEndVerseNumber = getVerseNumberFromKey(pagesVersesRange[mushafPageNumber].to); - const verseOrderWithinPage = getVerseNumberFromKey(startingVerseKey) - pageStartVerseNumber + 1; - const totalPageNumberOfVerses = pageEndVerseNumber - pageStartVerseNumber + 1; - const verseKeyPosition = (verseOrderWithinPage * 100) / totalPageNumberOfVerses; - if (verseKeyPosition <= 33.3) { - return ScrollAlign.Start; - } - if (verseKeyPosition <= 66.6) { - return ScrollAlign.Center; - } - return ScrollAlign.End; -}; - /** * This hook listens to startingVerse query param and navigate to the * location where the page of that verse is in the virtualized list if we @@ -82,6 +48,11 @@ const useScrollToVirtualizedReadingView = ( const { startingVerse, chapterId } = router.query; const shouldScroll = useRef(true); + const fetchVersePageNumber = useFetchVersePageNumber( + quranReaderStyles.quranFont, + quranReaderStyles.mushafLines, + ); + /** * We need to scroll again when we have just changed the font since the same * verse might lie on another page/position. Same for when we change the @@ -91,82 +62,88 @@ const useScrollToVirtualizedReadingView = ( shouldScroll.current = true; }, [quranFont, mushafLines, startingVerse]); - useEffect( + /** + * Helper: scroll to a verse number. This consolidates the logic used in + * both the initial startingVerse effect and the audio player subscription. + * + * @param {number} verseNumber + * @param {boolean} respectScrollGuard - when true, guard scrolling with shouldScroll ref + */ + const scrollToVerse = useCallback( // eslint-disable-next-line react-func/max-lines-per-function - () => { - // if we have the data of the page lookup - if (!isPagesLookupLoading && virtuosoRef.current && Object.keys(pagesVersesRange).length) { - // if startingVerse is present in the url - if (quranReaderDataType === QuranReaderDataType.Chapter && startingVerse) { - const startingVerseNumber = Number(startingVerse); - // if the startingVerse is a valid integer and is above 1 - if (Number.isInteger(startingVerseNumber) && startingVerseNumber > 0) { - const firstPageOfCurrentChapter = isUsingDefaultFont - ? initialData.verses[0].pageNumber - : Number(Object.keys(pagesVersesRange)[0]); - // search for the verse number in the already fetched verses first - const startFromVerseData = verses.find( - (verse) => verse.verseNumber === startingVerseNumber, - ); - if ( - startFromVerseData && - shouldScroll.current === true && - pagesVersesRange[startFromVerseData.pageNumber] - ) { - const scrollToPageIndex = startFromVerseData.pageNumber - firstPageOfCurrentChapter; - virtuosoRef.current.scrollToIndex({ - index: scrollToPageIndex, - align: getVersePositionWithinAMushafPage( - `${chapterId}:${startingVerseNumber}`, - startFromVerseData.pageNumber, - pagesVersesRange, - ), - }); - shouldScroll.current = false; - } else { - // get the page number by the verse key and the mushafId (because the page will be different for Indopak Mushafs) - fetcher( - makeVersesFilterUrl({ - filters: `${chapterId}:${startingVerseNumber}`, - fields: `page_number`, - ...getMushafId(quranReaderStyles.quranFont, quranReaderStyles.mushafLines), - }), - ).then((response: VersesResponse) => { - if (response.verses.length && shouldScroll.current === true) { - const scrollToPageIndex = - response.verses[0].pageNumber - firstPageOfCurrentChapter; - if (pagesVersesRange[response.verses[0].pageNumber]) { - virtuosoRef.current.scrollToIndex({ - index: scrollToPageIndex, - align: getVersePositionWithinAMushafPage( - `${chapterId}:${startingVerseNumber}`, - response.verses[0].pageNumber, - pagesVersesRange, - ), - }); - shouldScroll.current = false; - } - } - }); - } - } + async (verseNumber: number, respectScrollGuard = false) => { + if (respectScrollGuard && shouldScroll.current === false) return; + if (!virtuosoRef.current || !Object.keys(pagesVersesRange).length) return; + const firstPageOfCurrentChapter = isUsingDefaultFont + ? initialData.verses[0].pageNumber + : Number(Object.keys(pagesVersesRange)[0]); + + const startFromVerseData = verses.find((verse) => verse.verseNumber === verseNumber); + if (startFromVerseData && pagesVersesRange[startFromVerseData.pageNumber]) { + const scrollToPageIndex = startFromVerseData.pageNumber - firstPageOfCurrentChapter; + virtuosoRef.current.scrollToIndex({ + index: scrollToPageIndex, + align: getVersePositionWithinAMushafPage( + `${chapterId}:${verseNumber}`, + pagesVersesRange[startFromVerseData.pageNumber], + ), + }); + if (respectScrollGuard) shouldScroll.current = false; + return; + } + + const response = await fetchVersePageNumber(chapterId as string, verseNumber); + if (!response || !virtuosoRef.current) return; + if (response.verses.length && (respectScrollGuard ? shouldScroll.current === true : true)) { + const page = response.verses[0].pageNumber; + const scrollToPageIndex = page - firstPageOfCurrentChapter; + if (pagesVersesRange[page]) { + virtuosoRef.current.scrollToIndex({ + index: scrollToPageIndex, + align: getVersePositionWithinAMushafPage( + `${chapterId}:${verseNumber}`, + pagesVersesRange[page], + ), + }); + + if (respectScrollGuard) shouldScroll.current = false; } } }, [ - chapterId, - initialData.verses, - isPagesLookupLoading, - isUsingDefaultFont, + virtuosoRef, pagesVersesRange, - quranReaderDataType, - quranReaderStyles.mushafLines, - quranReaderStyles.quranFont, - startingVerse, + isUsingDefaultFont, + initialData.verses, verses, - virtuosoRef, + chapterId, + fetchVersePageNumber, ], ); + + useEffect(() => { + // if we have the data of the page lookup + if (!isPagesLookupLoading && virtuosoRef.current && Object.keys(pagesVersesRange).length) { + // if startingVerse is present in the url + if (quranReaderDataType === QuranReaderDataType.Chapter && startingVerse) { + const startingVerseNumber = Number(startingVerse); + // if the startingVerse is a valid integer and is above 1 + if (Number.isInteger(startingVerseNumber) && startingVerseNumber > 0) { + scrollToVerse(startingVerseNumber, true); + } + } + } + }, [ + isPagesLookupLoading, + quranReaderDataType, + startingVerse, + virtuosoRef, + scrollToVerse, + pagesVersesRange, + ]); + + // Subscribe to NEXT_AYAH and PREV_AYAH events to scroll when user clicks buttons in audio player + useAudioNavigationScroll(quranReaderDataType, scrollToVerse); }; export default useScrollToVirtualizedReadingView; diff --git a/src/components/QuranReader/TranslationView/hooks/useScrollToVirtualizedVerse.ts b/src/components/QuranReader/TranslationView/hooks/useScrollToVirtualizedVerse.ts index d565994ceb..e3026d7b78 100644 --- a/src/components/QuranReader/TranslationView/hooks/useScrollToVirtualizedVerse.ts +++ b/src/components/QuranReader/TranslationView/hooks/useScrollToVirtualizedVerse.ts @@ -6,6 +6,7 @@ import { VirtuosoHandle } from 'react-virtuoso'; import { useVerseTrackerContext } from '../../contexts/VerseTrackerContext'; import DataContext from '@/contexts/DataContext'; +import useAudioNavigationScroll from '@/hooks/useAudioNavigationScroll'; import { QuranReaderDataType } from '@/types/QuranReader'; import Verse from '@/types/Verse'; import { getPageNumberFromIndexAndPerPage } from '@/utils/number'; @@ -13,6 +14,10 @@ import { isValidVerseId } from '@/utils/validator'; import { makeVerseKey } from '@/utils/verse'; import ScrollAlign from 'types/ScrollAlign'; +// Constants for scroll behavior +const SCROLL_DELAY_MS = 1000; +const CONTEXT_MENU_OFFSET = -70; + /** * This hook listens to startingVerse query param and navigate to * the location where the verse is in the virtualized list. @@ -45,12 +50,13 @@ const useScrollToVirtualizedTranslationView = ( const scrollToBeginningOfVerseCell = useCallback( (verseNumber: number) => { + if (!virtuosoRef.current) return; const verseIndex = verseNumber - 1; virtuosoRef.current.scrollToIndex({ index: verseIndex, align: ScrollAlign.Start, // this offset is to push the scroll a little bit down so that the context menu doesn't cover the verse - offset: -70, + offset: CONTEXT_MENU_OFFSET, }); }, [virtuosoRef], @@ -104,7 +110,7 @@ const useScrollToVirtualizedTranslationView = ( // we need to add the verse we scrolled to to the queue verseKeysQueue.current.add(makeVerseKey(chapterId, startingVerseNumber)); - }, 1000); + }, SCROLL_DELAY_MS); setShouldReadjustScroll(false); } else { @@ -125,6 +131,9 @@ const useScrollToVirtualizedTranslationView = ( chapterId, ]); + // Subscribe to NEXT_AYAH and PREV_AYAH events to scroll when user clicks buttons + useAudioNavigationScroll(quranReaderDataType, scrollToBeginningOfVerseCell); + // this effect clears the timeout when the component unmounts useEffect(() => { return () => { diff --git a/src/hooks/useAudioNavigationScroll.ts b/src/hooks/useAudioNavigationScroll.ts new file mode 100644 index 0000000000..f041fd6f41 --- /dev/null +++ b/src/hooks/useAudioNavigationScroll.ts @@ -0,0 +1,49 @@ +import { useContext, useEffect } from 'react'; + +import { QuranReaderDataType } from '@/types/QuranReader'; +import { AudioPlayerMachineContext } from '@/xstate/AudioPlayerMachineContext'; + +type AudioNavigationHandler = (ayahNumber: number) => unknown; + +/** + * The audio service machine (see `src/xstate/actors/audioPlayer/audioPlayerMachine.ts`) uses raw string event names; + * introducing a shared enum there would require wider changes (10-20 file changes), so we scope the enum here for safer comparisons. + */ +enum AudioNavigationEvent { + NextAyah = 'NEXT_AYAH', + PrevAyah = 'PREV_AYAH', +} + +/** + * Shared hook that subscribes to audio NEXT_AYAH/PREV_AYAH events + * and triggers the provided navigate handler when the ayah number is valid. + */ +const useAudioNavigationScroll = ( + quranReaderDataType: QuranReaderDataType, + onNavigate: AudioNavigationHandler, +) => { + const audioService = useContext(AudioPlayerMachineContext); + + useEffect(() => { + if (!audioService || quranReaderDataType !== QuranReaderDataType.Chapter) { + return undefined; + } + + const subscription = audioService.subscribe((state) => { + if ( + state.event.type === AudioNavigationEvent.NextAyah || + state.event.type === AudioNavigationEvent.PrevAyah + ) { + const { ayahNumber } = state.context; + if (!Number.isInteger(ayahNumber) || ayahNumber <= 0) return; + onNavigate(ayahNumber); + } + }); + + return () => { + subscription.unsubscribe(); + }; + }, [audioService, quranReaderDataType, onNavigate]); +}; + +export default useAudioNavigationScroll; diff --git a/src/utils/verse.ts b/src/utils/verse.ts index 257e192ef8..e43273e17f 100644 --- a/src/utils/verse.ts +++ b/src/utils/verse.ts @@ -8,6 +8,8 @@ import { getChapterData } from './chapter'; import { formatStringNumber } from './number'; import { parseVerseRange } from './verseKeys'; +import LookupRecord from '@/types/LookupRecord'; +import ScrollAlign from '@/types/ScrollAlign'; import ChaptersData from 'types/ChaptersData'; import Verse from 'types/Verse'; import Word, { WordVerse } from 'types/Word'; @@ -485,3 +487,42 @@ export const isVerseKeyWithinRanges = (verseKey: string, ranges: string[] | stri // so we can return false return false; }; + +/** + * The thresholds for the scroll position in a mushaf page. + */ +const SCROLL_POSITION_THRESHOLDS = { + START: 33.3, + CENTER: 66.6, +} as const; + +/** + * Get where a verse lies in a mushaf page. This is achieved by: + * + * 1. Extracting verse numbers from the starting verse key and page verse range. + * 2. Calculating the verse's order/position within the page (1-based). + * 3. Calculating the percentage position of the verse within the page (0-100%). + * 4. If the position is <= 33.3%, return 'start'; if <= 66.6%, return 'center'; + * otherwise return 'end'. + * + * @param {string} startingVerseKey + * @param {LookupRecord} pagesVerseRange + * @returns {ScrollAlign} + */ +export const getVersePositionWithinAMushafPage = ( + startingVerseKey: string, + pagesVerseRange: LookupRecord, +): ScrollAlign => { + const pageStartVerseNumber = getVerseNumberFromKey(pagesVerseRange.from); + const pageEndVerseNumber = getVerseNumberFromKey(pagesVerseRange.to); + const verseOrderWithinPage = getVerseNumberFromKey(startingVerseKey) - pageStartVerseNumber + 1; + const totalPageNumberOfVerses = pageEndVerseNumber - pageStartVerseNumber + 1; + const verseKeyPosition = (verseOrderWithinPage * 100) / totalPageNumberOfVerses; + if (verseKeyPosition <= SCROLL_POSITION_THRESHOLDS.START) { + return ScrollAlign.Start; + } + if (verseKeyPosition <= SCROLL_POSITION_THRESHOLDS.CENTER) { + return ScrollAlign.Center; + } + return ScrollAlign.End; +};