Refresh thread replies periodically & when refocusing window (#36547)
This commit is contained in:
@@ -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<React.SetStateAction<LoadingState>>;
|
||||
}) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
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<typeof setTimeout>;
|
||||
// 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'
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
39
app/javascript/mastodon/hooks/useInterval.ts
Normal file
39
app/javascript/mastodon/hooks/useInterval.ts
Normal file
@@ -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]);
|
||||
}
|
||||
32
app/javascript/mastodon/hooks/useIsDocumentVisible.ts
Normal file
32
app/javascript/mastodon/hooks/useIsDocumentVisible.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user