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 { useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchContext,
|
fetchContext,
|
||||||
completeContextRefresh,
|
completeContextRefresh,
|
||||||
@@ -13,6 +15,8 @@ import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes';
|
|||||||
import { Alert } from 'mastodon/components/alert';
|
import { Alert } from 'mastodon/components/alert';
|
||||||
import { ExitAnimationWrapper } from 'mastodon/components/exit_animation_wrapper';
|
import { ExitAnimationWrapper } from 'mastodon/components/exit_animation_wrapper';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
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';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
const AnimatedAlert: React.FC<
|
const AnimatedAlert: React.FC<
|
||||||
@@ -52,14 +56,151 @@ const messages = defineMessages({
|
|||||||
|
|
||||||
type LoadingState = 'idle' | 'more-available' | 'loading' | 'success' | 'error';
|
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<{
|
export const RefreshController: React.FC<{
|
||||||
statusId: string;
|
statusId: string;
|
||||||
}> = ({ statusId }) => {
|
statusCreatedAt: string;
|
||||||
|
isLocal: boolean;
|
||||||
|
}> = ({ statusId, statusCreatedAt, isLocal }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const refreshHeader = useAppSelector(
|
const refreshHeader = useAppSelector((state) =>
|
||||||
(state) => state.contexts.refreshing[statusId],
|
isLocal ? undefined : state.contexts.refreshing[statusId],
|
||||||
);
|
);
|
||||||
const hasPendingReplies = useAppSelector(
|
const hasPendingReplies = useAppSelector(
|
||||||
(state) => !!state.contexts.pendingReplies[statusId]?.length,
|
(state) => !!state.contexts.pendingReplies[statusId]?.length,
|
||||||
@@ -78,78 +219,52 @@ export const RefreshController: React.FC<{
|
|||||||
dispatch(clearPendingReplies({ statusId }));
|
dispatch(clearPendingReplies({ statusId }));
|
||||||
}, [dispatch, statusId]);
|
}, [dispatch, statusId]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Prevent too-frequent context calls
|
||||||
let timeoutId: ReturnType<typeof setTimeout>;
|
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 = (
|
const isDocumentVisible = useIsDocumentVisible({
|
||||||
refresh: AsyncRefreshHeader,
|
onChange: (isVisible) => {
|
||||||
iteration: number,
|
// Auto-fetch new replies when the page is refocused
|
||||||
) => {
|
if (isVisible && partialLoadingState !== 'loading' && !wasDismissed) {
|
||||||
timeoutId = setTimeout(() => {
|
debouncedFetchContext();
|
||||||
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 { 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,
|
// Only auto-fetch new replies if there's no ongoing remote replies check
|
||||||
// we just schedule another refresh and exit
|
const shouldAutoFetchReplies =
|
||||||
if (status === 'running' && !isLongRunning) {
|
isDocumentVisible && partialLoadingState !== 'loading' && !wasDismissed;
|
||||||
scheduleRefresh(refresh, iteration + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If refresh status is finished, clear `refreshHeader`
|
const autoFetchInterval = useMemo(
|
||||||
// (we don't want to do this if it's just a long-running job)
|
() =>
|
||||||
if (status === 'finished') {
|
getIsThreadNew(statusCreatedAt)
|
||||||
dispatch(completeContextRefresh({ statusId }));
|
? SHORT_AUTO_FETCH_REPLIES_INTERVAL
|
||||||
}
|
: LONG_AUTO_FETCH_REPLIES_INTERVAL,
|
||||||
|
[statusCreatedAt],
|
||||||
|
);
|
||||||
|
|
||||||
// Exit if there's nothing to fetch
|
useInterval(debouncedFetchContext, {
|
||||||
if (result_count === 0) {
|
delay: autoFetchInterval,
|
||||||
if (status === 'finished') {
|
isEnabled: shouldAutoFetchReplies,
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Hide success message after a short delay
|
// Hide success message after a short delay
|
||||||
@@ -172,7 +287,7 @@ export const RefreshController: React.FC<{
|
|||||||
};
|
};
|
||||||
}, [dispatch, statusId]);
|
}, [dispatch, statusId]);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const showPending = useCallback(() => {
|
||||||
dispatch(showPendingReplies({ statusId }));
|
dispatch(showPendingReplies({ statusId }));
|
||||||
setLoadingState('success');
|
setLoadingState('success');
|
||||||
}, [dispatch, statusId]);
|
}, [dispatch, statusId]);
|
||||||
@@ -196,7 +311,7 @@ export const RefreshController: React.FC<{
|
|||||||
isActive={loadingState === 'more-available'}
|
isActive={loadingState === 'more-available'}
|
||||||
message={intl.formatMessage(messages.moreFound)}
|
message={intl.formatMessage(messages.moreFound)}
|
||||||
action={intl.formatMessage(messages.show)}
|
action={intl.formatMessage(messages.show)}
|
||||||
onActionClick={handleClick}
|
onActionClick={showPending}
|
||||||
onDismiss={dismissPrompt}
|
onDismiss={dismissPrompt}
|
||||||
animateFrom='below'
|
animateFrom='below'
|
||||||
/>
|
/>
|
||||||
@@ -205,7 +320,7 @@ export const RefreshController: React.FC<{
|
|||||||
isActive={loadingState === 'error'}
|
isActive={loadingState === 'error'}
|
||||||
message={intl.formatMessage(messages.error)}
|
message={intl.formatMessage(messages.error)}
|
||||||
action={intl.formatMessage(messages.retry)}
|
action={intl.formatMessage(messages.retry)}
|
||||||
onActionClick={handleClick}
|
onActionClick={showPending}
|
||||||
onDismiss={dismissPrompt}
|
onDismiss={dismissPrompt}
|
||||||
animateFrom='below'
|
animateFrom='below'
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -571,14 +571,6 @@ class Status extends ImmutablePureComponent {
|
|||||||
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
|
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
|
||||||
const isIndexable = !status.getIn(['account', 'noindex']);
|
const isIndexable = !status.getIn(['account', 'noindex']);
|
||||||
|
|
||||||
if (!isLocal) {
|
|
||||||
remoteHint = (
|
|
||||||
<RefreshController
|
|
||||||
statusId={status.get('id')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
reply: this.handleHotkeyReply,
|
reply: this.handleHotkeyReply,
|
||||||
favourite: this.handleHotkeyFavourite,
|
favourite: this.handleHotkeyFavourite,
|
||||||
@@ -649,7 +641,12 @@ class Status extends ImmutablePureComponent {
|
|||||||
</Hotkeys>
|
</Hotkeys>
|
||||||
|
|
||||||
{descendants}
|
{descendants}
|
||||||
{remoteHint}
|
|
||||||
|
<RefreshController
|
||||||
|
isLocal={isLocal}
|
||||||
|
statusId={status.get('id')}
|
||||||
|
statusCreatedAt={status.get('created_at')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ScrollContainer>
|
</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