diff --git a/app/javascript/mastodon/features/status/components/refresh_controller.tsx b/app/javascript/mastodon/features/status/components/refresh_controller.tsx index 253cce469..92607797b 100644 --- a/app/javascript/mastodon/features/status/components/refresh_controller.tsx +++ b/app/javascript/mastodon/features/status/components/refresh_controller.tsx @@ -1,7 +1,9 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useMemo } from 'react'; import { useIntl, defineMessages } from 'react-intl'; +import { useDebouncedCallback } from 'use-debounce'; + import { fetchContext, completeContextRefresh, @@ -13,6 +15,8 @@ import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes'; import { Alert } from 'mastodon/components/alert'; import { ExitAnimationWrapper } from 'mastodon/components/exit_animation_wrapper'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import { useInterval } from 'mastodon/hooks/useInterval'; +import { useIsDocumentVisible } from 'mastodon/hooks/useIsDocumentVisible'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; const AnimatedAlert: React.FC< @@ -52,14 +56,151 @@ const messages = defineMessages({ type LoadingState = 'idle' | 'more-available' | 'loading' | 'success' | 'error'; +/** + * Age of thread below which we consider it new & fetch + * replies more frequently + */ +const NEW_THREAD_AGE_THRESHOLD = 30 * 60_000; +/** + * Interval at which we check for new replies for old threads + */ +const LONG_AUTO_FETCH_REPLIES_INTERVAL = 5 * 60_000; +/** + * Interval at which we check for new replies for new threads. + * Also used as a threshold to throttle repeated fetch calls + */ +const SHORT_AUTO_FETCH_REPLIES_INTERVAL = 60_000; +/** + * Number of refresh_async checks at which an early fetch + * will be triggered if there are results + */ +const LONG_RUNNING_FETCH_THRESHOLD = 3; + +/** + * Returns whether the thread is new, based on NEW_THREAD_AGE_THRESHOLD + */ +function getIsThreadNew(statusCreatedAt: string) { + const now = new Date(); + const newThreadThreshold = new Date(now.getTime() - NEW_THREAD_AGE_THRESHOLD); + + return new Date(statusCreatedAt) > newThreadThreshold; +} + +/** + * This hook kicks off a background check for the async refresh job + * and loads any newly found replies once the job has finished, + * and when LONG_RUNNING_FETCH_THRESHOLD was reached and replies were found + */ +function useCheckForRemoteReplies({ + statusId, + refreshHeader, + isEnabled, + onChangeLoadingState, +}: { + statusId: string; + refreshHeader?: AsyncRefreshHeader; + isEnabled: boolean; + onChangeLoadingState: React.Dispatch>; +}) { + const dispatch = useAppDispatch(); + + useEffect(() => { + let timeoutId: ReturnType; + + const scheduleRefresh = ( + refresh: AsyncRefreshHeader, + iteration: number, + ) => { + timeoutId = setTimeout(() => { + void apiGetAsyncRefresh(refresh.id).then((result) => { + const { status, result_count } = result.async_refresh; + + // At three scheduled refreshes, we consider the job + // long-running and attempt to fetch any new replies so far + const isLongRunning = iteration === LONG_RUNNING_FETCH_THRESHOLD; + + // If the refresh status is not finished and not long-running, + // we just schedule another refresh and exit + if (status === 'running' && !isLongRunning) { + scheduleRefresh(refresh, iteration + 1); + return; + } + + // If refresh status is finished, clear `refreshHeader` + // (we don't want to do this if it's just a long-running job) + if (status === 'finished') { + dispatch(completeContextRefresh({ statusId })); + } + + // Exit if there's nothing to fetch + if (result_count === 0) { + if (status === 'finished') { + onChangeLoadingState('idle'); + } else { + scheduleRefresh(refresh, iteration + 1); + } + return; + } + + // A positive result count means there _might_ be new replies, + // so we fetch the context in the background to check if there + // are any new replies. + // If so, they will populate `contexts.pendingReplies[statusId]` + void dispatch(fetchContext({ statusId, prefetchOnly: true })) + .then(() => { + // Reset loading state to `idle`. If the fetch has + // resulted in new pending replies, the `hasPendingReplies` + // flag will switch the loading state to 'more-available' + if (status === 'finished') { + onChangeLoadingState('idle'); + } else { + // Keep background fetch going if `isLongRunning` is true + scheduleRefresh(refresh, iteration + 1); + } + }) + .catch(() => { + // Show an error if the fetch failed + onChangeLoadingState('error'); + }); + }); + }, refresh.retry * 1000); + }; + + // Initialise a refresh + if (refreshHeader && isEnabled) { + scheduleRefresh(refreshHeader, 1); + onChangeLoadingState('loading'); + } + + return () => { + clearTimeout(timeoutId); + }; + }, [onChangeLoadingState, dispatch, statusId, refreshHeader, isEnabled]); +} + +/** + * This component fetches new post replies in the background + * and gives users the option to show them. + * + * The following three scenarios are handled: + * + * 1. When the browser tab is visible, replies are refetched periodically + * (more frequently for new posts, less frequently for old ones) + * 2. Replies are refetched when the browser tab is refocused + * after it was hidden or minimised + * 3. For remote posts, remote replies that might not yet be known to the + * server are imported & fetched using the AsyncRefresh API. + */ export const RefreshController: React.FC<{ statusId: string; -}> = ({ statusId }) => { + statusCreatedAt: string; + isLocal: boolean; +}> = ({ statusId, statusCreatedAt, isLocal }) => { const dispatch = useAppDispatch(); const intl = useIntl(); - const refreshHeader = useAppSelector( - (state) => state.contexts.refreshing[statusId], + const refreshHeader = useAppSelector((state) => + isLocal ? undefined : state.contexts.refreshing[statusId], ); const hasPendingReplies = useAppSelector( (state) => !!state.contexts.pendingReplies[statusId]?.length, @@ -78,78 +219,52 @@ export const RefreshController: React.FC<{ dispatch(clearPendingReplies({ statusId })); }, [dispatch, statusId]); - useEffect(() => { - let timeoutId: ReturnType; + // Prevent too-frequent context calls + const debouncedFetchContext = useDebouncedCallback( + () => { + void dispatch(fetchContext({ statusId, prefetchOnly: true })); + }, + // Ensure the debounce is a bit shorter than the auto-fetch interval + SHORT_AUTO_FETCH_REPLIES_INTERVAL - 500, + { + leading: true, + trailing: false, + }, + ); - const scheduleRefresh = ( - refresh: AsyncRefreshHeader, - iteration: number, - ) => { - timeoutId = setTimeout(() => { - void apiGetAsyncRefresh(refresh.id).then((result) => { - // At three scheduled refreshes, we consider the job - // long-running and attempt to fetch any new replies so far - const isLongRunning = iteration === 3; + const isDocumentVisible = useIsDocumentVisible({ + onChange: (isVisible) => { + // Auto-fetch new replies when the page is refocused + if (isVisible && partialLoadingState !== 'loading' && !wasDismissed) { + debouncedFetchContext(); + } + }, + }); - const { status, result_count } = result.async_refresh; + // Check for remote replies + useCheckForRemoteReplies({ + statusId, + refreshHeader, + isEnabled: isDocumentVisible && !isLocal && !wasDismissed, + onChangeLoadingState: setLoadingState, + }); - // If the refresh status is not finished and not long-running, - // we just schedule another refresh and exit - if (status === 'running' && !isLongRunning) { - scheduleRefresh(refresh, iteration + 1); - return; - } + // Only auto-fetch new replies if there's no ongoing remote replies check + const shouldAutoFetchReplies = + isDocumentVisible && partialLoadingState !== 'loading' && !wasDismissed; - // If refresh status is finished, clear `refreshHeader` - // (we don't want to do this if it's just a long-running job) - if (status === 'finished') { - dispatch(completeContextRefresh({ statusId })); - } + const autoFetchInterval = useMemo( + () => + getIsThreadNew(statusCreatedAt) + ? SHORT_AUTO_FETCH_REPLIES_INTERVAL + : LONG_AUTO_FETCH_REPLIES_INTERVAL, + [statusCreatedAt], + ); - // Exit if there's nothing to fetch - if (result_count === 0) { - if (status === 'finished') { - setLoadingState('idle'); - } else { - scheduleRefresh(refresh, iteration + 1); - } - return; - } - - // A positive result count means there _might_ be new replies, - // so we fetch the context in the background to check if there - // are any new replies. - // If so, they will populate `contexts.pendingReplies[statusId]` - void dispatch(fetchContext({ statusId, prefetchOnly: true })) - .then(() => { - // Reset loading state to `idle`. If the fetch has - // resulted in new pending replies, the `hasPendingReplies` - // flag will switch the loading state to 'more-available' - if (status === 'finished') { - setLoadingState('idle'); - } else { - // Keep background fetch going if `isLongRunning` is true - scheduleRefresh(refresh, iteration + 1); - } - }) - .catch(() => { - // Show an error if the fetch failed - setLoadingState('error'); - }); - }); - }, refresh.retry * 1000); - }; - - // Initialise a refresh - if (refreshHeader && !wasDismissed) { - scheduleRefresh(refreshHeader, 1); - setLoadingState('loading'); - } - - return () => { - clearTimeout(timeoutId); - }; - }, [dispatch, statusId, refreshHeader, wasDismissed]); + useInterval(debouncedFetchContext, { + delay: autoFetchInterval, + isEnabled: shouldAutoFetchReplies, + }); useEffect(() => { // Hide success message after a short delay @@ -172,7 +287,7 @@ export const RefreshController: React.FC<{ }; }, [dispatch, statusId]); - const handleClick = useCallback(() => { + const showPending = useCallback(() => { dispatch(showPendingReplies({ statusId })); setLoadingState('success'); }, [dispatch, statusId]); @@ -196,7 +311,7 @@ export const RefreshController: React.FC<{ isActive={loadingState === 'more-available'} message={intl.formatMessage(messages.moreFound)} action={intl.formatMessage(messages.show)} - onActionClick={handleClick} + onActionClick={showPending} onDismiss={dismissPrompt} animateFrom='below' /> @@ -205,7 +320,7 @@ export const RefreshController: React.FC<{ isActive={loadingState === 'error'} message={intl.formatMessage(messages.error)} action={intl.formatMessage(messages.retry)} - onActionClick={handleClick} + onActionClick={showPending} onDismiss={dismissPrompt} animateFrom='below' /> diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index ff32d63e8..a07b20f02 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -571,14 +571,6 @@ class Status extends ImmutablePureComponent { const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1; const isIndexable = !status.getIn(['account', 'noindex']); - if (!isLocal) { - remoteHint = ( - - ); - } - const handlers = { reply: this.handleHotkeyReply, favourite: this.handleHotkeyFavourite, @@ -649,7 +641,12 @@ class Status extends ImmutablePureComponent { {descendants} - {remoteHint} + + diff --git a/app/javascript/mastodon/hooks/useInterval.ts b/app/javascript/mastodon/hooks/useInterval.ts new file mode 100644 index 000000000..d80ef79d6 --- /dev/null +++ b/app/javascript/mastodon/hooks/useInterval.ts @@ -0,0 +1,39 @@ +import { useEffect, useLayoutEffect, useRef } from 'react'; + +/** + * Hook to create an interval that invokes a callback function + * at a specified delay using the setInterval API. + * Based on https://usehooks-ts.com/react-hook/use-interval + */ +export function useInterval( + callback: () => void, + { + delay, + isEnabled = true, + }: { + delay: number; + isEnabled?: boolean; + }, +) { + // Write callback to a ref so we can omit it from + // the interval effect's dependency array + const callbackRef = useRef(callback); + useLayoutEffect(() => { + callbackRef.current = callback; + }, [callback]); + + // Set up the interval. + useEffect(() => { + if (!isEnabled) { + return; + } + + const intervalId = setInterval(() => { + callbackRef.current(); + }, delay); + + return () => { + clearInterval(intervalId); + }; + }, [delay, isEnabled]); +} diff --git a/app/javascript/mastodon/hooks/useIsDocumentVisible.ts b/app/javascript/mastodon/hooks/useIsDocumentVisible.ts new file mode 100644 index 000000000..3587733dc --- /dev/null +++ b/app/javascript/mastodon/hooks/useIsDocumentVisible.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef, useState } from 'react'; + +export function useIsDocumentVisible({ + onChange, +}: { + onChange?: (isVisible: boolean) => void; +} = {}) { + const [isDocumentVisible, setIsDocumentVisible] = useState( + () => document.visibilityState === 'visible', + ); + + const onChangeRef = useRef(onChange); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + function handleVisibilityChange() { + const isVisible = document.visibilityState === 'visible'; + + setIsDocumentVisible(isVisible); + onChangeRef.current?.(isVisible); + } + window.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + window.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + + return isDocumentVisible; +}