From 17d8e2b6e3653f4743e859553288f2f885e383a9 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 28 Apr 2025 15:38:40 +0200 Subject: [PATCH] Refactor context reducer to TypeScript (#34506) --- app/javascript/mastodon/actions/statuses.js | 53 +----- .../mastodon/actions/statuses_typed.ts | 18 ++ app/javascript/mastodon/api/statuses.ts | 5 + app/javascript/mastodon/api_types/statuses.ts | 5 + .../components/notification_mention.tsx | 6 +- .../mastodon/features/status/index.jsx | 87 ++-------- app/javascript/mastodon/reducers/contexts.js | 109 ------------ app/javascript/mastodon/reducers/contexts.ts | 155 ++++++++++++++++++ app/javascript/mastodon/reducers/index.ts | 4 +- app/javascript/mastodon/reducers/statuses.js | 1 + app/javascript/mastodon/selectors/contexts.ts | 94 +++++++++++ app/javascript/mastodon/store/index.ts | 1 + .../mastodon/store/typed_functions.ts | 4 +- 13 files changed, 308 insertions(+), 234 deletions(-) create mode 100644 app/javascript/mastodon/actions/statuses_typed.ts create mode 100644 app/javascript/mastodon/api/statuses.ts delete mode 100644 app/javascript/mastodon/reducers/contexts.js create mode 100644 app/javascript/mastodon/reducers/contexts.ts create mode 100644 app/javascript/mastodon/selectors/contexts.ts diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 1e5b53c687..42d0c1c0f1 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -4,8 +4,11 @@ import api from '../api'; import { ensureComposeIsVisible, setComposeToStatus } from './compose'; import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer'; +import { fetchContext } from './statuses_typed'; import { deleteFromTimelines } from './timelines'; +export * from './statuses_typed'; + export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; @@ -14,10 +17,6 @@ export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST'; export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; -export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST'; -export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS'; -export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL'; - export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST'; export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS'; export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL'; @@ -54,7 +53,7 @@ export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) { const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null; if (alsoFetchContext) { - dispatch(fetchContext(id)); + dispatch(fetchContext({ statusId: id })); } if (skipLoading) { @@ -178,50 +177,6 @@ export function deleteStatusFail(id, error) { export const updateStatus = status => dispatch => dispatch(importFetchedStatus(status)); -export function fetchContext(id) { - return (dispatch) => { - dispatch(fetchContextRequest(id)); - - api().get(`/api/v1/statuses/${id}/context`).then(response => { - dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants))); - dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants)); - - }).catch(error => { - if (error.response && error.response.status === 404) { - dispatch(deleteFromTimelines(id)); - } - - dispatch(fetchContextFail(id, error)); - }); - }; -} - -export function fetchContextRequest(id) { - return { - type: CONTEXT_FETCH_REQUEST, - id, - }; -} - -export function fetchContextSuccess(id, ancestors, descendants) { - return { - type: CONTEXT_FETCH_SUCCESS, - id, - ancestors, - descendants, - statuses: ancestors.concat(descendants), - }; -} - -export function fetchContextFail(id, error) { - return { - type: CONTEXT_FETCH_FAIL, - id, - error, - skipAlert: true, - }; -} - export function muteStatus(id) { return (dispatch) => { dispatch(muteStatusRequest(id)); diff --git a/app/javascript/mastodon/actions/statuses_typed.ts b/app/javascript/mastodon/actions/statuses_typed.ts new file mode 100644 index 0000000000..b98abbe122 --- /dev/null +++ b/app/javascript/mastodon/actions/statuses_typed.ts @@ -0,0 +1,18 @@ +import { apiGetContext } from 'mastodon/api/statuses'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +import { importFetchedStatuses } from './importer'; + +export const fetchContext = createDataLoadingThunk( + 'status/context', + ({ statusId }: { statusId: string }) => apiGetContext(statusId), + (context, { dispatch }) => { + const statuses = context.ancestors.concat(context.descendants); + + dispatch(importFetchedStatuses(statuses)); + + return { + context, + }; + }, +); diff --git a/app/javascript/mastodon/api/statuses.ts b/app/javascript/mastodon/api/statuses.ts new file mode 100644 index 0000000000..921a7bfe63 --- /dev/null +++ b/app/javascript/mastodon/api/statuses.ts @@ -0,0 +1,5 @@ +import { apiRequestGet } from 'mastodon/api'; +import type { ApiContextJSON } from 'mastodon/api_types/statuses'; + +export const apiGetContext = (statusId: string) => + apiRequestGet(`v1/statuses/${statusId}/context`); diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts index 2c59645ea7..09bd2349b3 100644 --- a/app/javascript/mastodon/api_types/statuses.ts +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -119,3 +119,8 @@ export interface ApiStatusJSON { card?: ApiPreviewCardJSON; poll?: ApiPollJSON; } + +export interface ApiContextJSON { + ancestors: ApiStatusJSON[]; + descendants: ApiStatusJSON[]; +} diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx index d53cb37a83..fe9dc23ef0 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx @@ -39,9 +39,9 @@ export const NotificationMention: React.FC<{ unread: boolean; }> = ({ notification, unread }) => { const [isDirect, isReply] = useAppSelector((state) => { - const status = state.statuses.get(notification.statusId) as - | Status - | undefined; + const status = notification.statusId + ? (state.statuses.get(notification.statusId) as Status | undefined) + : undefined; if (!status) return [false, false] as const; diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index a6c010b6c5..7da2df3742 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -65,6 +65,7 @@ import { textForScreenReader, defaultMediaVisibility } from '../../components/st import StatusContainer from '../../containers/status_container'; import { deleteModal } from '../../initial_state'; import { makeGetStatus, makeGetPictureInPicture } from '../../selectors'; +import { getAncestorsIds, getDescendantsIds } from 'mastodon/selectors/contexts'; import Column from '../ui/components/column'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen'; @@ -83,69 +84,15 @@ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); const getPictureInPicture = makeGetPictureInPicture(); - const getAncestorsIds = createSelector([ - (_, { id }) => id, - state => state.getIn(['contexts', 'inReplyTos']), - ], (statusId, inReplyTos) => { - let ancestorsIds = ImmutableList(); - ancestorsIds = ancestorsIds.withMutations(mutable => { - let id = statusId; - - while (id && !mutable.includes(id)) { - mutable.unshift(id); - id = inReplyTos.get(id); - } - }); - - return ancestorsIds; - }); - - const getDescendantsIds = createSelector([ - (_, { id }) => id, - state => state.getIn(['contexts', 'replies']), - state => state.get('statuses'), - ], (statusId, contextReplies, statuses) => { - let descendantsIds = []; - const ids = [statusId]; - - while (ids.length > 0) { - let id = ids.pop(); - const replies = contextReplies.get(id); - - if (statusId !== id) { - descendantsIds.push(id); - } - - if (replies) { - replies.reverse().forEach(reply => { - if (!ids.includes(reply) && !descendantsIds.includes(reply) && statusId !== reply) ids.push(reply); - }); - } - } - - let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account')); - if (insertAt !== -1) { - descendantsIds.forEach((id, idx) => { - if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) { - descendantsIds.splice(idx, 1); - descendantsIds.splice(insertAt, 0, id); - insertAt += 1; - } - }); - } - - return ImmutableList(descendantsIds); - }); - const mapStateToProps = (state, props) => { const status = getStatus(state, { id: props.params.statusId, contextType: 'detailed' }); - let ancestorsIds = ImmutableList(); - let descendantsIds = ImmutableList(); + let ancestorsIds = []; + let descendantsIds = []; if (status) { - ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') }); - descendantsIds = getDescendantsIds(state, { id: status.get('id') }); + ancestorsIds = getAncestorsIds(state, status.get('in_reply_to_id')); + descendantsIds = getDescendantsIds(state, status.get('id')); } return { @@ -188,8 +135,8 @@ class Status extends ImmutablePureComponent { dispatch: PropTypes.func.isRequired, status: ImmutablePropTypes.map, isLoading: PropTypes.bool, - ancestorsIds: ImmutablePropTypes.list.isRequired, - descendantsIds: ImmutablePropTypes.list.isRequired, + ancestorsIds: PropTypes.arrayOf(PropTypes.string).isRequired, + descendantsIds: PropTypes.arrayOf(PropTypes.string).isRequired, intl: PropTypes.object.isRequired, askReplyConfirmation: PropTypes.bool, multiColumn: PropTypes.bool, @@ -383,7 +330,7 @@ class Status extends ImmutablePureComponent { handleToggleAll = () => { const { status, ancestorsIds, descendantsIds } = this.props; - const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS()); + const statusIds = [status.get('id')].concat(ancestorsIds, descendantsIds); if (status.get('hidden')) { this.props.dispatch(revealStatus(statusIds)); @@ -482,13 +429,13 @@ class Status extends ImmutablePureComponent { const { status, ancestorsIds, descendantsIds } = this.props; if (id === status.get('id')) { - this._selectChild(ancestorsIds.size - 1, true); + this._selectChild(ancestorsIds.length - 1, true); } else { let index = ancestorsIds.indexOf(id); if (index === -1) { index = descendantsIds.indexOf(id); - this._selectChild(ancestorsIds.size + index, true); + this._selectChild(ancestorsIds.length + index, true); } else { this._selectChild(index - 1, true); } @@ -499,13 +446,13 @@ class Status extends ImmutablePureComponent { const { status, ancestorsIds, descendantsIds } = this.props; if (id === status.get('id')) { - this._selectChild(ancestorsIds.size + 1, false); + this._selectChild(ancestorsIds.length + 1, false); } else { let index = ancestorsIds.indexOf(id); if (index === -1) { index = descendantsIds.indexOf(id); - this._selectChild(ancestorsIds.size + index + 2, false); + this._selectChild(ancestorsIds.length + index + 2, false); } else { this._selectChild(index + 1, false); } @@ -536,8 +483,8 @@ class Status extends ImmutablePureComponent { onMoveUp={this.handleMoveUp} onMoveDown={this.handleMoveDown} contextType='thread' - previousId={i > 0 ? list.get(i - 1) : undefined} - nextId={list.get(i + 1) || (ancestors && statusId)} + previousId={i > 0 ? list[i - 1] : undefined} + nextId={list[i + 1] || (ancestors && statusId)} rootId={statusId} /> )); @@ -574,7 +521,7 @@ class Status extends ImmutablePureComponent { componentDidUpdate (prevProps) { const { status, ancestorsIds } = this.props; - if (status && (ancestorsIds.size > prevProps.ancestorsIds.size || prevProps.status?.get('id') !== status.get('id'))) { + if (status && (ancestorsIds.length > prevProps.ancestorsIds.length || prevProps.status?.get('id') !== status.get('id'))) { this._scrollStatusIntoView(); } } @@ -621,11 +568,11 @@ class Status extends ImmutablePureComponent { ); } - if (ancestorsIds && ancestorsIds.size > 0) { + if (ancestorsIds && ancestorsIds.length > 0) { ancestors = <>{this.renderChildren(ancestorsIds, true)}; } - if (descendantsIds && descendantsIds.size > 0) { + if (descendantsIds && descendantsIds.length > 0) { descendants = <>{this.renderChildren(descendantsIds)}; } diff --git a/app/javascript/mastodon/reducers/contexts.js b/app/javascript/mastodon/reducers/contexts.js deleted file mode 100644 index b2c6f3f1ab..0000000000 --- a/app/javascript/mastodon/reducers/contexts.js +++ /dev/null @@ -1,109 +0,0 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; - -import { timelineDelete } from 'mastodon/actions/timelines_typed'; - -import { - blockAccountSuccess, - muteAccountSuccess, -} from '../actions/accounts'; -import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; -import { TIMELINE_UPDATE } from '../actions/timelines'; -import { compareId } from '../compare_id'; - -const initialState = ImmutableMap({ - inReplyTos: ImmutableMap(), - replies: ImmutableMap(), -}); - -const normalizeContext = (immutableState, id, ancestors, descendants) => immutableState.withMutations(state => { - state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => { - state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => { - function addReply({ id, in_reply_to_id }) { - if (in_reply_to_id && !inReplyTos.has(id)) { - - replies.update(in_reply_to_id, ImmutableList(), siblings => { - const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0); - return siblings.insert(index + 1, id); - }); - - inReplyTos.set(id, in_reply_to_id); - } - } - - // 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({ id, in_reply_to_id: ancestors[ancestors.length - 1].id }); - } - - descendants.forEach(addReply); - })); - })); -}); - -const deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => { - state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => { - state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => { - ids.forEach(id => { - const inReplyToIdOfId = inReplyTos.get(id); - const repliesOfId = replies.get(id); - const siblings = replies.get(inReplyToIdOfId); - - if (siblings) { - replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id)); - } - - - if (repliesOfId) { - repliesOfId.forEach(reply => inReplyTos.delete(reply)); - } - - inReplyTos.delete(id); - replies.delete(id); - }); - })); - })); -}); - -const filterContexts = (state, relationship, statuses) => { - const ownedStatusIds = statuses - .filter(status => status.get('account') === relationship.id) - .map(status => status.get('id')); - - return deleteFromContexts(state, ownedStatusIds); -}; - -const updateContext = (state, status) => { - if (status.in_reply_to_id) { - return state.withMutations(mutable => { - const replies = mutable.getIn(['replies', status.in_reply_to_id], ImmutableList()); - - mutable.setIn(['inReplyTos', status.id], status.in_reply_to_id); - - if (!replies.includes(status.id)) { - mutable.setIn(['replies', status.in_reply_to_id], replies.push(status.id)); - } - }); - } - - return state; -}; - -export default function replies(state = initialState, action) { - switch(action.type) { - case blockAccountSuccess.type: - case muteAccountSuccess.type: - return filterContexts(state, action.payload.relationship, action.payload.statuses); - case CONTEXT_FETCH_SUCCESS: - return normalizeContext(state, action.id, action.ancestors, action.descendants); - case timelineDelete.type: - return deleteFromContexts(state, [action.payload.statusId]); - case TIMELINE_UPDATE: - return updateContext(state, action.status); - default: - return state; - } -} diff --git a/app/javascript/mastodon/reducers/contexts.ts b/app/javascript/mastodon/reducers/contexts.ts new file mode 100644 index 0000000000..7ecc6e3b29 --- /dev/null +++ b/app/javascript/mastodon/reducers/contexts.ts @@ -0,0 +1,155 @@ +/* eslint-disable @typescript-eslint/no-dynamic-delete */ +import { createReducer } from '@reduxjs/toolkit'; +import type { Draft, UnknownAction } from '@reduxjs/toolkit'; +import type { List as ImmutableList } from 'immutable'; + +import { timelineDelete } from 'mastodon/actions/timelines_typed'; +import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships'; +import type { + ApiStatusJSON, + ApiContextJSON, +} from 'mastodon/api_types/statuses'; +import type { Status } from 'mastodon/models/status'; + +import { blockAccountSuccess, muteAccountSuccess } from '../actions/accounts'; +import { fetchContext } from '../actions/statuses'; +import { TIMELINE_UPDATE } from '../actions/timelines'; +import { compareId } from '../compare_id'; + +interface TimelineUpdateAction extends UnknownAction { + timeline: string; + status: ApiStatusJSON; + usePendingItems: boolean; +} + +interface State { + inReplyTos: Record; + replies: Record; +} + +const initialState: State = { + inReplyTos: {}, + replies: {}, +}; + +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; + } + }; + + // 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({ + id, + in_reply_to_id: ancestors[ancestors.length - 1]?.id, + }); + } + + descendants.forEach(addReply); +}; + +const deleteFromContexts = (state: Draft, ids: string[]): void => { + ids.forEach((id) => { + const inReplyToIdOfId = state.inReplyTos[id]; + const repliesOfId = state.replies[id]; + + if (inReplyToIdOfId) { + const siblings = state.replies[inReplyToIdOfId]; + + if (siblings) { + state.replies[inReplyToIdOfId] = siblings.filter( + (sibling) => sibling !== id, + ); + } + } + + if (repliesOfId) { + repliesOfId.forEach((reply) => { + delete state.inReplyTos[reply]; + }); + } + + delete state.inReplyTos[id]; + delete state.replies[id]; + }); +}; + +const filterContexts = ( + state: Draft, + relationship: ApiRelationshipJSON, + statuses: ImmutableList, +): void => { + const ownedStatusIds = statuses + .filter((status) => (status.get('account') as string) === relationship.id) + .map((status) => status.get('id') as string); + + deleteFromContexts(state, ownedStatusIds.toArray()); +}; + +const updateContext = (state: Draft, status: ApiStatusJSON): void => { + if (!status.in_reply_to_id) { + return; + } + + const siblings = (state.replies[status.in_reply_to_id] ??= []); + + state.inReplyTos[status.id] = status.in_reply_to_id; + + if (!siblings.includes(status.id)) { + siblings.push(status.id); + } +}; + +export const contextsReducer = createReducer(initialState, (builder) => { + builder + .addCase(fetchContext.fulfilled, (state, action) => { + normalizeContext(state, action.meta.arg.statusId, action.payload.context); + }) + .addCase(blockAccountSuccess, (state, action) => { + filterContexts( + state, + action.payload.relationship, + action.payload.statuses as ImmutableList, + ); + }) + .addCase(muteAccountSuccess, (state, action) => { + filterContexts( + state, + action.payload.relationship, + action.payload.statuses as ImmutableList, + ); + }) + .addCase(timelineDelete, (state, action) => { + deleteFromContexts(state, [action.payload.statusId]); + }) + .addMatcher( + (action: UnknownAction): action is TimelineUpdateAction => + action.type === TIMELINE_UPDATE, + (state, action) => { + updateContext(state, action.status); + }, + ); +}); diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index e98d835f47..a1b349af80 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -8,7 +8,7 @@ import accounts_map from './accounts_map'; import { alertsReducer } from './alerts'; import announcements from './announcements'; import { composeReducer } from './compose'; -import contexts from './contexts'; +import { contextsReducer } from './contexts'; import conversations from './conversations'; import custom_emojis from './custom_emojis'; import { dropdownMenuReducer } from './dropdown_menu'; @@ -55,7 +55,7 @@ const reducers = { settings, push_notifications, server, - contexts, + contexts: contextsReducer, compose: composeReducer, search: searchReducer, media_attachments, diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index d92174f806..7a1b7967e5 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -64,6 +64,7 @@ const statusTranslateUndo = (state, id) => { }); }; +/** @type {ImmutableMap>} */ const initialState = ImmutableMap(); /** @type {import('@reduxjs/toolkit').Reducer} */ diff --git a/app/javascript/mastodon/selectors/contexts.ts b/app/javascript/mastodon/selectors/contexts.ts new file mode 100644 index 0000000000..1c76d7cc82 --- /dev/null +++ b/app/javascript/mastodon/selectors/contexts.ts @@ -0,0 +1,94 @@ +import { createAppSelector } from 'mastodon/store'; + +export const getAncestorsIds = createAppSelector( + [(_, id: string) => id, (state) => state.contexts.inReplyTos], + (statusId, inReplyTos) => { + const ancestorsIds: string[] = []; + + let currentId: string | undefined = statusId; + + while (currentId && !ancestorsIds.includes(currentId)) { + ancestorsIds.unshift(currentId); + currentId = inReplyTos[currentId]; + } + + return ancestorsIds; + }, +); + +export const getDescendantsIds = createAppSelector( + [ + (_, id: string) => id, + (state) => state.contexts.replies, + (state) => state.statuses, + ], + (statusId, contextReplies, statuses) => { + const descendantsIds: string[] = []; + + const visitIds = [statusId]; + + while (visitIds.length > 0) { + const id = visitIds.pop(); + + if (!id) { + break; + } + + const replies = contextReplies[id]; + + if (statusId !== id) { + descendantsIds.push(id); + } + + if (replies) { + replies.reverse().forEach((replyId) => { + if ( + !visitIds.includes(replyId) && + !descendantsIds.includes(replyId) && + statusId !== replyId + ) { + visitIds.push(replyId); + } + }); + } + } + + let insertAt = descendantsIds.findIndex((id) => { + const status = statuses.get(id); + + if (!status) { + return false; + } + + const inReplyToAccountId = status.get('in_reply_to_account_id') as + | string + | null; + const accountId = status.get('account') as string; + + return inReplyToAccountId !== accountId; + }); + + if (insertAt !== -1) { + descendantsIds.forEach((id, idx) => { + const status = statuses.get(id); + + if (!status) { + return; + } + + const inReplyToAccountId = status.get('in_reply_to_account_id') as + | string + | null; + const accountId = status.get('account') as string; + + if (idx > insertAt && inReplyToAccountId === accountId) { + descendantsIds.splice(idx, 1); + descendantsIds.splice(insertAt, 0, id); + insertAt += 1; + } + }); + } + + return descendantsIds; + }, +); diff --git a/app/javascript/mastodon/store/index.ts b/app/javascript/mastodon/store/index.ts index c2629b0ed7..0b9564c909 100644 --- a/app/javascript/mastodon/store/index.ts +++ b/app/javascript/mastodon/store/index.ts @@ -3,6 +3,7 @@ export type { GetState, AppDispatch, RootState } from './store'; export { createAppAsyncThunk, + createAppSelector, useAppDispatch, useAppSelector, } from './typed_functions'; diff --git a/app/javascript/mastodon/store/typed_functions.ts b/app/javascript/mastodon/store/typed_functions.ts index 9fcc90c61b..f0a18a0681 100644 --- a/app/javascript/mastodon/store/typed_functions.ts +++ b/app/javascript/mastodon/store/typed_functions.ts @@ -1,5 +1,5 @@ import type { GetThunkAPI } from '@reduxjs/toolkit'; -import { createAsyncThunk } from '@reduxjs/toolkit'; +import { createAsyncThunk, createSelector } from '@reduxjs/toolkit'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { useDispatch, useSelector } from 'react-redux'; @@ -24,6 +24,8 @@ export const createAppAsyncThunk = createAsyncThunk.withTypes<{ rejectValue: AsyncThunkRejectValue; }>(); +export const createAppSelector = createSelector.withTypes(); + interface AppThunkConfig { state: RootState; dispatch: AppDispatch;