diff --git a/app/javascript/mastodon/actions/statuses_typed.ts b/app/javascript/mastodon/actions/statuses_typed.ts index f34d9f2bc3..be9bec71bb 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/reducers/contexts.ts b/app/javascript/mastodon/reducers/contexts.ts index cf378b4c04..0a31c49828 100644 --- a/app/javascript/mastodon/reducers/contexts.ts +++ b/app/javascript/mastodon/reducers/contexts.ts @@ -13,7 +13,12 @@ import type { import type { Status } from 'mastodon/models/status'; import { blockAccountSuccess, muteAccountSuccess } from '../actions/accounts'; -import { fetchContext, completeContextRefresh } from '../actions/statuses'; +import { + fetchContext, + completeContextRefresh, + showPendingReplies, + clearPendingReplies, +} from '../actions/statuses'; import { TIMELINE_UPDATE } from '../actions/timelines'; import { compareId } from '../compare_id'; @@ -26,52 +31,84 @@ interface TimelineUpdateAction extends UnknownAction { interface State { inReplyTos: Record; 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]; })