From 474fbb2770a4bff97e25d07897a21acfda373262 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Mon, 6 Oct 2025 16:13:24 +0200 Subject: [PATCH] Fetch all replies: Only display "More replies found" prompt when there really are new replies (#36334) --- .../mastodon/actions/statuses_typed.ts | 14 +- .../status/components/refresh_controller.tsx | 120 +++++++++--------- app/javascript/mastodon/locales/en.json | 1 - app/javascript/mastodon/reducers/contexts.ts | 109 ++++++++++++---- 4 files changed, 155 insertions(+), 89 deletions(-) diff --git a/app/javascript/mastodon/actions/statuses_typed.ts b/app/javascript/mastodon/actions/statuses_typed.ts index f34d9f2bc..be9bec71b 100644 --- a/app/javascript/mastodon/actions/statuses_typed.ts +++ b/app/javascript/mastodon/actions/statuses_typed.ts @@ -9,8 +9,9 @@ import { importFetchedStatuses } from './importer'; export const fetchContext = createDataLoadingThunk( 'status/context', - ({ statusId }: { statusId: string }) => apiGetContext(statusId), - ({ context, refresh }, { dispatch }) => { + ({ statusId }: { statusId: string; prefetchOnly?: boolean }) => + apiGetContext(statusId), + ({ context, refresh }, { dispatch, actionArg: { prefetchOnly = false } }) => { const statuses = context.ancestors.concat(context.descendants); dispatch(importFetchedStatuses(statuses)); @@ -18,6 +19,7 @@ export const fetchContext = createDataLoadingThunk( return { context, refresh, + prefetchOnly, }; }, ); @@ -26,6 +28,14 @@ export const completeContextRefresh = createAction<{ statusId: string }>( 'status/context/complete', ); +export const showPendingReplies = createAction<{ statusId: string }>( + 'status/context/showPendingReplies', +); + +export const clearPendingReplies = createAction<{ statusId: string }>( + 'status/context/clearPendingReplies', +); + export const setStatusQuotePolicy = createDataLoadingThunk( 'status/setQuotePolicy', ({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => { diff --git a/app/javascript/mastodon/features/status/components/refresh_controller.tsx b/app/javascript/mastodon/features/status/components/refresh_controller.tsx index 34faaf1d5..8297922cb 100644 --- a/app/javascript/mastodon/features/status/components/refresh_controller.tsx +++ b/app/javascript/mastodon/features/status/components/refresh_controller.tsx @@ -5,6 +5,8 @@ import { useIntl, defineMessages } from 'react-intl'; import { fetchContext, completeContextRefresh, + showPendingReplies, + clearPendingReplies, } from 'mastodon/actions/statuses'; import type { AsyncRefreshHeader } from 'mastodon/api'; import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes'; @@ -34,10 +36,6 @@ const messages = defineMessages({ id: 'status.context.loading', defaultMessage: 'Loading', }, - loadingMore: { - id: 'status.context.loading_more', - defaultMessage: 'Loading more replies', - }, success: { id: 'status.context.loading_success', defaultMessage: 'All replies loaded', @@ -52,36 +50,33 @@ const messages = defineMessages({ }, }); -type LoadingState = - | 'idle' - | 'more-available' - | 'loading-initial' - | 'loading-more' - | 'success' - | 'error'; +type LoadingState = 'idle' | 'more-available' | 'loading' | 'success' | 'error'; export const RefreshController: React.FC<{ statusId: string; }> = ({ statusId }) => { - const refresh = useAppSelector( - (state) => state.contexts.refreshing[statusId], - ); - const currentReplyCount = useAppSelector( - (state) => state.contexts.replies[statusId]?.length ?? 0, - ); - const autoRefresh = !currentReplyCount; const dispatch = useAppDispatch(); const intl = useIntl(); - const [loadingState, setLoadingState] = useState( - refresh && autoRefresh ? 'loading-initial' : 'idle', + const refreshHeader = useAppSelector( + (state) => state.contexts.refreshing[statusId], ); + const hasPendingReplies = useAppSelector( + (state) => !!state.contexts.pendingReplies[statusId]?.length, + ); + const [partialLoadingState, setLoadingState] = useState( + 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]); useEffect(() => { let timeoutId: ReturnType; @@ -89,36 +84,51 @@ export const RefreshController: React.FC<{ const scheduleRefresh = (refresh: AsyncRefreshHeader) => { timeoutId = setTimeout(() => { void apiGetAsyncRefresh(refresh.id).then((result) => { - if (result.async_refresh.status === 'finished') { - dispatch(completeContextRefresh({ statusId })); - - if (result.async_refresh.result_count > 0) { - if (autoRefresh) { - void dispatch(fetchContext({ statusId })).then(() => { - setLoadingState('idle'); - }); - } else { - setLoadingState('more-available'); - } - } else { - setLoadingState('idle'); - } - } else { + // If the refresh status is not finished, + // schedule another refresh and exit + if (result.async_refresh.status !== 'finished') { scheduleRefresh(refresh); + return; } + + // Refresh status is finished. The action below will clear `refreshHeader` + dispatch(completeContextRefresh({ statusId })); + + // Exit if there's nothing to fetch + if (result.async_refresh.result_count === 0) { + setLoadingState('idle'); + 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` – but if the fetch + // has resulted in new pending replies, the `hasPendingReplies` + // flag will switch the loading state to 'more-available' + setLoadingState('idle'); + }) + .catch(() => { + // Show an error if the fetch failed + setLoadingState('error'); + }); }); }, refresh.retry * 1000); }; - if (refresh && !wasDismissed) { - scheduleRefresh(refresh); - setLoadingState('loading-initial'); + // Initialise a refresh + if (refreshHeader && !wasDismissed) { + scheduleRefresh(refreshHeader); + setLoadingState('loading'); } return () => { clearTimeout(timeoutId); }; - }, [dispatch, statusId, refresh, autoRefresh, wasDismissed]); + }, [dispatch, statusId, refreshHeader, wasDismissed]); useEffect(() => { // Hide success message after a short delay @@ -134,20 +144,19 @@ export const RefreshController: React.FC<{ return () => ''; }, [loadingState]); - const handleClick = useCallback(() => { - setLoadingState('loading-more'); - - dispatch(fetchContext({ statusId })) - .then(() => { - setLoadingState('success'); - return ''; - }) - .catch(() => { - setLoadingState('error'); - }); + useEffect(() => { + // Clear pending replies on unmount + return () => { + dispatch(clearPendingReplies({ statusId })); + }; }, [dispatch, statusId]); - if (loadingState === 'loading-initial') { + const handleClick = useCallback(() => { + dispatch(showPendingReplies({ statusId })); + setLoadingState('success'); + }, [dispatch, statusId]); + + if (loadingState === 'loading') { return (
- ; replies: Record; + pendingReplies: Record< + string, + Pick[] + >; refreshing: Record; } const initialState: State = { inReplyTos: {}, replies: {}, + pendingReplies: {}, refreshing: {}, }; +const addReply = ( + state: Draft, + { id, in_reply_to_id }: Pick, +) => { + if (!in_reply_to_id) { + return; + } + + if (!state.inReplyTos[id]) { + const siblings = (state.replies[in_reply_to_id] ??= []); + const index = siblings.findIndex((sibling) => compareId(sibling, id) < 0); + siblings.splice(index + 1, 0, id); + state.inReplyTos[id] = in_reply_to_id; + } +}; + const normalizeContext = ( state: Draft, id: string, { ancestors, descendants }: ApiContextJSON, ): void => { - const addReply = ({ - id, - in_reply_to_id, - }: { - id: string; - in_reply_to_id?: string; - }) => { - if (!in_reply_to_id) { - return; - } - - if (!state.inReplyTos[id]) { - const siblings = (state.replies[in_reply_to_id] ??= []); - const index = siblings.findIndex((sibling) => compareId(sibling, id) < 0); - siblings.splice(index + 1, 0, id); - state.inReplyTos[id] = in_reply_to_id; - } - }; + ancestors.forEach((item) => { + addReply(state, item); + }); // We know in_reply_to_id of statuses but `id` itself. // So we assume that the status of the id replies to last ancestors. - - ancestors.forEach(addReply); - if (ancestors[0]) { - addReply({ + addReply(state, { id, in_reply_to_id: ancestors[ancestors.length - 1]?.id, }); } - descendants.forEach(addReply); + descendants.forEach((item) => { + addReply(state, item); + }); +}; + +const applyPrefetchedReplies = (state: Draft, statusId: string) => { + const pendingReplies = state.pendingReplies[statusId]; + if (pendingReplies?.length) { + pendingReplies.forEach((item) => { + addReply(state, item); + }); + delete state.pendingReplies[statusId]; + } +}; + +const storePrefetchedReplies = ( + state: Draft, + statusId: string, + { descendants }: ApiContextJSON, +): void => { + descendants.forEach(({ id, in_reply_to_id }) => { + if (!in_reply_to_id) { + return; + } + const isNewReply = !state.replies[in_reply_to_id]?.includes(id); + if (isNewReply) { + const pendingReplies = (state.pendingReplies[statusId] ??= []); + pendingReplies.push({ id, in_reply_to_id }); + } + }); }; const deleteFromContexts = (state: Draft, ids: string[]): void => { @@ -129,12 +166,30 @@ const updateContext = (state: Draft, status: ApiStatusJSON): void => { export const contextsReducer = createReducer(initialState, (builder) => { builder .addCase(fetchContext.fulfilled, (state, action) => { - normalizeContext(state, action.meta.arg.statusId, action.payload.context); + if (action.payload.prefetchOnly) { + storePrefetchedReplies( + state, + action.meta.arg.statusId, + action.payload.context, + ); + } else { + normalizeContext( + state, + action.meta.arg.statusId, + action.payload.context, + ); - if (action.payload.refresh) { - state.refreshing[action.meta.arg.statusId] = action.payload.refresh; + if (action.payload.refresh) { + state.refreshing[action.meta.arg.statusId] = action.payload.refresh; + } } }) + .addCase(showPendingReplies, (state, action) => { + applyPrefetchedReplies(state, action.payload.statusId); + }) + .addCase(clearPendingReplies, (state, action) => { + delete state.pendingReplies[action.payload.statusId]; + }) .addCase(completeContextRefresh, (state, action) => { delete state.refreshing[action.payload.statusId]; })