Refresh thread replies periodically & when refocusing window (#36547)

pull/36039/head
diondiondion 2 weeks ago committed by GitHub
parent 6adbd9ce52
commit 7ea2af6ae2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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,31 +56,53 @@ const messages = defineMessages({
type LoadingState = 'idle' | 'more-available' | 'loading' | 'success' | 'error';
export const RefreshController: React.FC<{
statusId: string;
}> = ({ statusId }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
/**
* 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;
const refreshHeader = useAppSelector(
(state) => state.contexts.refreshing[statusId],
);
const hasPendingReplies = useAppSelector(
(state) => !!state.contexts.pendingReplies[statusId]?.length,
);
const [partialLoadingState, setLoadingState] = useState<LoadingState>(
refreshHeader ? 'loading' : 'idle',
);
const loadingState = hasPendingReplies
? 'more-available'
: partialLoadingState;
/**
* 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);
const [wasDismissed, setWasDismissed] = useState(false);
const dismissPrompt = useCallback(() => {
setWasDismissed(true);
setLoadingState('idle');
dispatch(clearPendingReplies({ statusId }));
}, [dispatch, statusId]);
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<React.SetStateAction<LoadingState>>;
}) {
const dispatch = useAppDispatch();
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
@ -87,11 +113,11 @@ export const RefreshController: React.FC<{
) => {
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 === 3;
const { status, result_count } = result.async_refresh;
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
@ -109,7 +135,7 @@ export const RefreshController: React.FC<{
// Exit if there's nothing to fetch
if (result_count === 0) {
if (status === 'finished') {
setLoadingState('idle');
onChangeLoadingState('idle');
} else {
scheduleRefresh(refresh, iteration + 1);
}
@ -126,7 +152,7 @@ export const RefreshController: React.FC<{
// resulted in new pending replies, the `hasPendingReplies`
// flag will switch the loading state to 'more-available'
if (status === 'finished') {
setLoadingState('idle');
onChangeLoadingState('idle');
} else {
// Keep background fetch going if `isLongRunning` is true
scheduleRefresh(refresh, iteration + 1);
@ -134,22 +160,111 @@ export const RefreshController: React.FC<{
})
.catch(() => {
// Show an error if the fetch failed
setLoadingState('error');
onChangeLoadingState('error');
});
});
}, refresh.retry * 1000);
};
// Initialise a refresh
if (refreshHeader && !wasDismissed) {
if (refreshHeader && isEnabled) {
scheduleRefresh(refreshHeader, 1);
setLoadingState('loading');
onChangeLoadingState('loading');
}
return () => {
clearTimeout(timeoutId);
};
}, [dispatch, statusId, refreshHeader, wasDismissed]);
}, [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;
statusCreatedAt: string;
isLocal: boolean;
}> = ({ statusId, statusCreatedAt, isLocal }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const refreshHeader = useAppSelector((state) =>
isLocal ? undefined : state.contexts.refreshing[statusId],
);
const hasPendingReplies = useAppSelector(
(state) => !!state.contexts.pendingReplies[statusId]?.length,
);
const [partialLoadingState, setLoadingState] = useState<LoadingState>(
refreshHeader ? 'loading' : 'idle',
);
const loadingState = hasPendingReplies
? 'more-available'
: partialLoadingState;
const [wasDismissed, setWasDismissed] = useState(false);
const dismissPrompt = useCallback(() => {
setWasDismissed(true);
setLoadingState('idle');
dispatch(clearPendingReplies({ statusId }));
}, [dispatch, statusId]);
// 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 isDocumentVisible = useIsDocumentVisible({
onChange: (isVisible) => {
// Auto-fetch new replies when the page is refocused
if (isVisible && partialLoadingState !== 'loading' && !wasDismissed) {
debouncedFetchContext();
}
},
});
// Check for remote replies
useCheckForRemoteReplies({
statusId,
refreshHeader,
isEnabled: isDocumentVisible && !isLocal && !wasDismissed,
onChangeLoadingState: setLoadingState,
});
// Only auto-fetch new replies if there's no ongoing remote replies check
const shouldAutoFetchReplies =
isDocumentVisible && partialLoadingState !== 'loading' && !wasDismissed;
const autoFetchInterval = useMemo(
() =>
getIsThreadNew(statusCreatedAt)
? SHORT_AUTO_FETCH_REPLIES_INTERVAL
: LONG_AUTO_FETCH_REPLIES_INTERVAL,
[statusCreatedAt],
);
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'
/>

@ -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 = (
<RefreshController
statusId={status.get('id')}
/>
);
}
const handlers = {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
@ -649,7 +641,12 @@ class Status extends ImmutablePureComponent {
</Hotkeys>
{descendants}
{remoteHint}
<RefreshController
isLocal={isLocal}
statusId={status.get('id')}
statusCreatedAt={status.get('created_at')}
/>
</div>
</ScrollContainer>

@ -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]);
}

@ -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;
}
Loading…
Cancel
Save