diff --git a/packages/pl-fe/src/actions/importer.ts b/packages/pl-fe/src/actions/importer.ts index f1e922f0d..c26342833 100644 --- a/packages/pl-fe/src/actions/importer.ts +++ b/packages/pl-fe/src/actions/importer.ts @@ -2,6 +2,7 @@ import { importEntities as importEntityStoreEntities } from '@/entity-store/acti import { Entities } from '@/entity-store/entities'; import { queryClient } from '@/queries/client'; import { selectAccount } from '@/selectors'; +import { useContextStore } from '@/stores/contexts'; import type { AppDispatch, RootState } from '@/store'; import type { @@ -97,6 +98,7 @@ const importEntities = ); if (entities.statuses?.length === 1 && entities.statuses[0] && options.idempotencyKey) { + useContextStore.getState().actions.importStatus(entities.statuses[0], options.idempotencyKey); dispatch({ type: STATUS_IMPORT, status: entities.statuses[0], @@ -132,6 +134,9 @@ const importEntities = ); } } + if (!isEmpty(statuses)) + useContextStore.getState().actions.importStatuses(Object.values(statuses)); + if (!isEmpty(statuses)) dispatch({ type: STATUSES_IMPORT, statuses: Object.values(statuses) }); }; diff --git a/packages/pl-fe/src/actions/statuses.ts b/packages/pl-fe/src/actions/statuses.ts index 005794f65..6e42df438 100644 --- a/packages/pl-fe/src/actions/statuses.ts +++ b/packages/pl-fe/src/actions/statuses.ts @@ -1,5 +1,6 @@ import { queryClient } from '@/queries/client'; import { scheduledStatusesQueryOptions } from '@/queries/statuses/scheduled-statuses'; +import { useContextStore } from '@/stores/contexts'; import { useModalsStore } from '@/stores/modals'; import { usePendingStatusesStore } from '@/stores/pending-statuses'; import { useSettingsStore } from '@/stores/settings'; @@ -53,6 +54,7 @@ const createStatus = (dispatch: AppDispatch, getState: () => RootState) => { if (!params.preview) { usePendingStatusesStore.getState().actions.importStatus(params, idempotencyKey); + useContextStore.getState().actions.importPendingStatus(params.in_reply_to_id, idempotencyKey); dispatch({ type: STATUS_CREATE_REQUEST, params, @@ -96,6 +98,13 @@ const createStatus = editing: !!editedId, }); + useContextStore + .getState() + .actions.deletePendingStatus( + 'in_reply_to_id' in status ? status.in_reply_to_id : null, + idempotencyKey, + ); + // Poll the backend for the updated card if (expectsCard) { const delay = 1000; @@ -120,6 +129,9 @@ const createStatus = }) .catch((error) => { usePendingStatusesStore.getState().actions.deleteStatus(idempotencyKey); + useContextStore + .getState() + .actions.deletePendingStatus(params.in_reply_to_id, idempotencyKey); dispatch({ type: STATUS_CREATE_FAIL, error, @@ -241,6 +253,7 @@ const fetchContext = const { ancestors, descendants } = context; const statuses = ancestors.concat(descendants); dispatch(importEntities({ statuses })); + useContextStore.getState().actions.importContext(statusId, context); dispatch({ type: CONTEXT_FETCH_SUCCESS, statusId, ancestors, descendants }); return context; }) diff --git a/packages/pl-fe/src/actions/timelines.ts b/packages/pl-fe/src/actions/timelines.ts index ca72eedd3..04de2ced3 100644 --- a/packages/pl-fe/src/actions/timelines.ts +++ b/packages/pl-fe/src/actions/timelines.ts @@ -1,4 +1,5 @@ import { getLocale } from '@/actions/settings'; +import { useContextStore } from '@/stores/contexts'; import { usePendingStatusesStore } from '@/stores/pending-statuses'; import { useSettingsStore } from '@/stores/settings'; import { shouldFilter } from '@/utils/timelines'; @@ -131,6 +132,8 @@ const deleteFromTimelines = .map(([key, status]) => [key, status.account_id]); const reblogOf = getState().statuses[statusId]?.reblog_id ?? null; + useContextStore.getState().actions.deleteStatuses([statusId]); + dispatch({ type: TIMELINE_DELETE, statusId, diff --git a/packages/pl-fe/src/features/status/components/thread-status.tsx b/packages/pl-fe/src/features/status/components/thread-status.tsx index 8bcee6e5b..a922540a7 100644 --- a/packages/pl-fe/src/features/status/components/thread-status.tsx +++ b/packages/pl-fe/src/features/status/components/thread-status.tsx @@ -5,6 +5,7 @@ import Tombstone from '@/components/tombstone'; import StatusContainer from '@/containers/status-container'; import PlaceholderStatus from '@/features/placeholder/components/placeholder-status'; import { useAppSelector } from '@/hooks/use-app-selector'; +import { useReplyCount, useReplyToId } from '@/stores/contexts'; interface IThreadStatus { id: string; @@ -19,8 +20,8 @@ interface IThreadStatus { const ThreadStatus: React.FC = (props): JSX.Element => { const { id, focusedStatusId } = props; - const replyToId = useAppSelector((state) => state.contexts.inReplyTos[id]); - const replyCount = useAppSelector((state) => (state.contexts.replies[id] || []).length); + const replyToId = useReplyToId(id); + const replyCount = useReplyCount(id); const isLoaded = useAppSelector((state) => Boolean(state.statuses[id])); const isDeleted = useAppSelector((state) => Boolean(state.statuses[id]?.deleted)); diff --git a/packages/pl-fe/src/features/status/components/thread.tsx b/packages/pl-fe/src/features/status/components/thread.tsx index a2ed16b58..d8e2d81c8 100644 --- a/packages/pl-fe/src/features/status/components/thread.tsx +++ b/packages/pl-fe/src/features/status/components/thread.tsx @@ -1,4 +1,3 @@ -import { createSelector } from '@reduxjs/toolkit'; import { useNavigate } from '@tanstack/react-router'; import clsx from 'clsx'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; @@ -14,14 +13,13 @@ import PlaceholderStatus from '@/features/placeholder/components/placeholder-sta import { Hotkeys } from '@/features/ui/components/hotkeys'; import PendingStatus from '@/features/ui/components/pending-status'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useAppSelector } from '@/hooks/use-app-selector'; import { useFavouriteStatus, useReblogStatus, useUnfavouriteStatus, useUnreblogStatus, } from '@/queries/statuses/use-status-interactions'; -import { RootState } from '@/store'; +import { useContextStore, useThread } from '@/stores/contexts'; import { useModalsActions } from '@/stores/modals'; import { useSettings } from '@/stores/settings'; import { useStatusMetaActions } from '@/stores/status-meta'; @@ -36,102 +34,26 @@ import type { SelectedStatus } from '@/selectors'; import type { Account } from 'pl-api'; import type { VirtuosoHandle } from 'react-virtuoso'; -const makeGetAncestorsIds = () => - createSelector( - [(_: RootState, statusId: string) => statusId, (state: RootState) => state.contexts.inReplyTos], - (statusId, inReplyTos) => { - let ancestorsIds: Array = []; - let id: string = statusId; +const getLinearThreadStatusesIds = ( + statusId: string, + inReplyTos: Record, + replies: Record, +) => { + let parentStatus: string = statusId; - while (id && !ancestorsIds.includes(id)) { - ancestorsIds = [id, ...ancestorsIds]; - id = inReplyTos[id]; - } - - return [...new Set(ancestorsIds)]; - }, - ); - -const makeGetDescendantsIds = () => - createSelector( - [(_: RootState, statusId: string) => statusId, (state: RootState) => state.contexts.replies], - (statusId, contextReplies) => { - let descendantsIds: Array = []; - const ids = [statusId]; - - while (ids.length > 0) { - const id = ids.shift(); - if (!id) break; - - const replies = contextReplies[id]; - - if (descendantsIds.includes(id)) { - break; - } - - if (statusId !== id) { - descendantsIds = [...descendantsIds, id]; - } - - if (replies) { - replies.toReversed().forEach((reply: string) => { - ids.unshift(reply); - }); - } - } - - return [...new Set(descendantsIds)]; - }, - ); - -const makeGetThreadStatusesIds = () => - createSelector( - [ - (_: RootState, statusId: string) => statusId, - (state: RootState) => state.contexts.inReplyTos, - (state: RootState) => state.contexts.replies, - ], - (statusId, inReplyTos, replies) => { - let parentStatus: string = statusId; - - while (inReplyTos[parentStatus]) { - parentStatus = inReplyTos[parentStatus]; - } - - const threadStatuses = [parentStatus]; - - for (let i = 0; i < threadStatuses.length; i++) { - for (const reply of replies[threadStatuses[i]] || []) { - if (!threadStatuses.includes(reply)) threadStatuses.push(reply); - } - } - - return threadStatuses.toSorted(); - }, - ); - -const makeGetThread = (linear = false) => { - if (linear) { - const getThreadStatusesIds = makeGetThreadStatusesIds(); - return (state: RootState, statusId: string) => getThreadStatusesIds(state, statusId); + while (inReplyTos[parentStatus]) { + parentStatus = inReplyTos[parentStatus]; } - const getAncestorsIds = makeGetAncestorsIds(); - const getDescendantsIds = makeGetDescendantsIds(); + const threadStatuses = [parentStatus]; - return createSelector( - [ - (state: RootState, statusId: string) => getAncestorsIds(state, statusId), - (state: RootState, statusId: string) => getDescendantsIds(state, statusId), - (_, statusId: string) => statusId, - ], - (ancestorsIds, descendantsIds, statusId) => { - ancestorsIds = ancestorsIds.filter((id) => id !== statusId && !descendantsIds.includes(id)); - descendantsIds = descendantsIds.filter((id) => id !== statusId && !ancestorsIds.includes(id)); + for (let i = 0; i < threadStatuses.length; i++) { + for (const reply of replies[threadStatuses[i]] || []) { + if (!threadStatuses.includes(reply)) threadStatuses.push(reply); + } + } - return [...ancestorsIds, statusId, ...descendantsIds]; - }, - ); + return threadStatuses.toSorted(); }; interface IThread { @@ -166,10 +88,14 @@ const Thread = ({ const { mutate: unreblogStatus } = useUnreblogStatus(status.id); const linear = displayMode === 'linear'; + const inReplyTos = useContextStore((state) => state.inReplyTos); + const replies = useContextStore((state) => state.replies); + const nestedThread = useThread(status.id); - const getThread = useCallback(makeGetThread(linear), [linear]); - - const thread = useAppSelector((state) => getThread(state, status.id)); + const thread = useMemo( + () => (linear ? getLinearThreadStatusesIds(status.id, inReplyTos, replies) : nestedThread), + [linear, status.id, inReplyTos, replies, nestedThread], + ); const statusIndex = thread.indexOf(status.id); const initialIndex = isModal && statusIndex !== 0 ? statusIndex + 1 : statusIndex; @@ -494,4 +420,4 @@ const Thread = ({ ); }; -export { makeGetDescendantsIds, Thread as default }; +export { Thread as default }; diff --git a/packages/pl-fe/src/pages/statuses/event-discussion.tsx b/packages/pl-fe/src/pages/statuses/event-discussion.tsx index 9fe4ffb54..dca9d28d0 100644 --- a/packages/pl-fe/src/pages/statuses/event-discussion.tsx +++ b/packages/pl-fe/src/pages/statuses/event-discussion.tsx @@ -8,7 +8,6 @@ import ScrollableList from '@/components/scrollable-list'; import Tombstone from '@/components/tombstone'; import Stack from '@/components/ui/stack'; import PlaceholderStatus from '@/features/placeholder/components/placeholder-status'; -import { makeGetDescendantsIds } from '@/features/status/components/thread'; import ThreadStatus from '@/features/status/components/thread-status'; import PendingStatus from '@/features/ui/components/pending-status'; import { eventDiscussionRoute } from '@/features/ui/router'; @@ -16,6 +15,7 @@ import { ComposeForm } from '@/features/ui/util/async-components'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useAppSelector } from '@/hooks/use-app-selector'; import { makeGetStatus } from '@/selectors'; +import { useDescendantsIds } from '@/stores/contexts'; import { selectChild } from '@/utils/scroll-utils'; import type { VirtuosoHandle } from 'react-virtuoso'; @@ -27,14 +27,11 @@ const EventDiscussionPage: React.FC = () => { const dispatch = useAppDispatch(); const getStatus = useCallback(makeGetStatus(), []); - const getDescendantsIds = useCallback(makeGetDescendantsIds(), []); const status = useAppSelector((state) => getStatus(state, { id: statusId })); const me = useAppSelector((state) => state.me); - const descendantsIds = useAppSelector((state) => - getDescendantsIds(state, statusId).filter((id) => id !== statusId), - ); + const descendantsIds = useDescendantsIds(statusId); const [isLoaded, setIsLoaded] = useState(!!status); diff --git a/packages/pl-fe/src/queries/accounts/use-relationship.ts b/packages/pl-fe/src/queries/accounts/use-relationship.ts index 97be4ac37..04552782b 100644 --- a/packages/pl-fe/src/queries/accounts/use-relationship.ts +++ b/packages/pl-fe/src/queries/accounts/use-relationship.ts @@ -9,6 +9,7 @@ import { batcher } from '@/api/batcher'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useClient } from '@/hooks/use-client'; import { useLoggedIn } from '@/hooks/use-logged-in'; +import { useContextsActions } from '@/stores/contexts'; import type { MinifiedSuggestion } from '../trends/use-suggested-accounts'; import type { @@ -122,6 +123,7 @@ const useBlockAccountMutation = (accountId: string) => { const client = useClient(); const queryClient = useQueryClient(); const dispatch = useAppDispatch(); + const { filterContexts } = useContextsActions(); return useMutation({ mutationKey: ['accountRelationships', accountId], @@ -155,13 +157,15 @@ const useBlockAccountMutation = (accountId: string) => { }); // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers - return dispatch((dispatch, getState) => - dispatch({ + return dispatch((dispatch, getState) => { + filterContexts(data, getState().statuses); + + return dispatch({ type: ACCOUNT_BLOCK_SUCCESS, relationship: data, statuses: getState().statuses, - }), - ); + }); + }); }, }); }; @@ -194,6 +198,7 @@ const useMuteAccountMutation = (accountId: string) => { const client = useClient(); const queryClient = useQueryClient(); const dispatch = useAppDispatch(); + const { filterContexts } = useContextsActions(); return useMutation({ mutationKey: ['accountRelationships', accountId], @@ -223,13 +228,15 @@ const useMuteAccountMutation = (accountId: string) => { }); // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers - return dispatch((dispatch, getState) => - dispatch({ + return dispatch((dispatch, getState) => { + filterContexts(data, getState().statuses); + + return dispatch({ type: ACCOUNT_MUTE_SUCCESS, relationship: data, statuses: getState().statuses, - }), - ); + }); + }); }, }); }; diff --git a/packages/pl-fe/src/reducers/contexts.ts b/packages/pl-fe/src/reducers/contexts.ts deleted file mode 100644 index 05ac730f3..000000000 --- a/packages/pl-fe/src/reducers/contexts.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { create } from 'mutative'; - -import { STATUS_IMPORT, STATUSES_IMPORT, type ImporterAction } from '@/actions/importer'; - -import { - ACCOUNT_BLOCK_SUCCESS, - ACCOUNT_MUTE_SUCCESS, - type AccountsAction, -} from '../actions/accounts'; -import { - CONTEXT_FETCH_SUCCESS, - STATUS_CREATE_REQUEST, - STATUS_CREATE_SUCCESS, - type StatusesAction, -} from '../actions/statuses'; -import { TIMELINE_DELETE, type TimelineAction } from '../actions/timelines'; - -import type { Status } from 'pl-api'; - -interface State { - inReplyTos: Record; - replies: Record>; -} - -const initialState: State = { - inReplyTos: {}, - replies: {}, -}; - -/** Import a single status into the reducer, setting replies and replyTos. */ -const importStatus = ( - state: State, - status: Pick, - idempotencyKey?: string, -) => { - const { id, in_reply_to_id: inReplyToId } = status; - if (!inReplyToId) return; - - const replies = state.replies[inReplyToId] || []; - const newReplies = [...new Set([...replies, id])].toSorted(); - - state.replies[inReplyToId] = newReplies; - state.inReplyTos[id] = inReplyToId; - - if (idempotencyKey) { - deletePendingStatus(state, status.in_reply_to_id, idempotencyKey); - } -}; - -/** Import multiple statuses into the state. */ -const importStatuses = (state: State, statuses: Array>) => { - statuses.forEach((status) => { - importStatus(state, status); - }); -}; - -/** Insert a fake status ID connecting descendant to ancestor. */ -const insertTombstone = (state: State, ancestorId: string, descendantId: string) => { - const tombstoneId = `${descendantId}-tombstone`; - - importStatus(state, { id: tombstoneId, in_reply_to_id: ancestorId }); - importStatus(state, { id: descendantId, in_reply_to_id: tombstoneId }); -}; - -/** Find the highest level status from this statusId. */ -const getRootNode = (state: State, statusId: string, initialId = statusId): string => { - const parent = state.inReplyTos[statusId]; - - if (!parent) { - return statusId; - } else if (parent === initialId) { - // Prevent cycles - return parent; - } else { - return getRootNode(state, parent, initialId); - } -}; - -/** Route fromId to toId by inserting tombstones. */ -const connectNodes = (state: State, fromId: string, toId: string) => { - const fromRoot = getRootNode(state, fromId); - const toRoot = getRootNode(state, toId); - - if (fromRoot !== toRoot) { - insertTombstone(state, toId, fromId); - return; - } else { - return state; - } -}; - -/** Import a branch of ancestors or descendants, in relation to statusId. */ -const importBranch = ( - state: State, - statuses: Array>, - statusId?: string, -) => { - statuses.forEach((status, i) => { - const prevId = statusId && i === 0 ? statusId : (statuses[i - 1] || {}).id; - - if (status.in_reply_to_id) { - importStatus(state, status); - - // On Mastodon, in_reply_to_id can refer to an unavailable status, - // so traverse the tree up and insert a connecting tombstone if needed. - if (statusId) { - connectNodes(state, status.id, statusId); - } - } else if (prevId) { - // On Pleroma, in_reply_to_id will be null if the parent is unavailable, - // so insert the tombstone now. - insertTombstone(state, prevId, status.id); - } - }); -}; - -/** Import a status's ancestors and descendants. */ -const normalizeContext = ( - state: State, - id: string, - ancestors: Array>, - descendants: Array>, -) => { - importBranch(state, ancestors); - importBranch(state, descendants, id); - - if (ancestors.length > 0 && !state.inReplyTos[id]) { - insertTombstone(state, ancestors[ancestors.length - 1].id, id); - } -}; - -/** Remove a status from the reducer. */ -const deleteStatus = (state: State, statusId: string) => { - // Delete from its parent's tree - const parentId = state.inReplyTos[statusId]; - if (parentId) { - const parentReplies = state.replies[parentId] || []; - const newParentReplies = parentReplies.filter((id) => id !== statusId); - state.replies[parentId] = newParentReplies; - } - - // Dereference children - const replies = (state.replies[statusId] = []); - replies.forEach((reply) => delete state.inReplyTos[reply]); - - delete state.inReplyTos[statusId]; - delete state.replies[statusId]; -}; - -/** Delete multiple statuses from the reducer. */ -const deleteStatuses = (state: State, statusIds: string[]) => { - statusIds.forEach((statusId) => { - deleteStatus(state, statusId); - }); -}; - -/** Delete statuses upon blocking or muting a user. */ -const filterContexts = ( - state: State, - relationship: { id: string }, - /** The entire statuses map from the store. */ - statuses: Record>, -) => { - const ownedStatusIds = Object.values(statuses) - .filter((status) => status.account.id === relationship.id) - .map((status) => status.id); - - deleteStatuses(state, ownedStatusIds); -}; - -/** Add a fake status ID for a pending status. */ -const importPendingStatus = ( - state: State, - inReplyToId: string | null | undefined, - idempotencyKey: string, -) => { - const id = `末pending-${idempotencyKey}`; - importStatus(state, { id, in_reply_to_id: inReplyToId ?? null }); -}; - -/** Delete a pending status from the reducer. */ -const deletePendingStatus = ( - state: State, - inReplyToId: string | null | undefined, - idempotencyKey: string, -) => { - const id = `末pending-${idempotencyKey}`; - - delete state.inReplyTos[id]; - - if (inReplyToId) { - const replies = state.replies[inReplyToId] || []; - const newReplies = replies.filter((replyId) => replyId !== id).toSorted(); - state.replies[inReplyToId] = newReplies; - } -}; - -/** Contexts reducer. Used for building a nested tree structure for threads. */ -const replies = ( - state = initialState, - action: AccountsAction | ImporterAction | StatusesAction | TimelineAction, -): State => { - switch (action.type) { - case ACCOUNT_BLOCK_SUCCESS: - case ACCOUNT_MUTE_SUCCESS: - return create(state, (draft) => { - filterContexts(draft, action.relationship, action.statuses); - }); - case CONTEXT_FETCH_SUCCESS: - return create(state, (draft) => { - normalizeContext(draft, action.statusId, action.ancestors, action.descendants); - }); - case TIMELINE_DELETE: - return create(state, (draft) => { - deleteStatuses(draft, [action.statusId]); - }); - case STATUS_CREATE_REQUEST: - return create(state, (draft) => { - importPendingStatus(draft, action.params.in_reply_to_id, action.idempotencyKey); - }); - case STATUS_CREATE_SUCCESS: - return create(state, (draft) => { - deletePendingStatus( - draft, - 'in_reply_to_id' in action.status ? action.status.in_reply_to_id : null, - action.idempotencyKey, - ); - }); - case STATUS_IMPORT: - return create(state, (draft) => { - importStatus(draft, action.status, action.idempotencyKey); - }); - case STATUSES_IMPORT: - return create(state, (draft) => { - importStatuses(draft, action.statuses); - }); - default: - return state; - } -}; - -export { replies as default }; diff --git a/packages/pl-fe/src/reducers/index.ts b/packages/pl-fe/src/reducers/index.ts index 68bb0fdaf..789bfdc92 100644 --- a/packages/pl-fe/src/reducers/index.ts +++ b/packages/pl-fe/src/reducers/index.ts @@ -7,7 +7,6 @@ import entities from '@/entity-store/reducer'; import admin from './admin'; import auth from './auth'; import compose from './compose'; -import contexts from './contexts'; import conversations from './conversations'; import filters from './filters'; import frontendConfig from './frontend-config'; @@ -23,7 +22,6 @@ const reducers = { admin, auth, compose, - contexts, conversations, entities, filters, diff --git a/packages/pl-fe/src/stores/contexts.ts b/packages/pl-fe/src/stores/contexts.ts new file mode 100644 index 000000000..b35301b34 --- /dev/null +++ b/packages/pl-fe/src/stores/contexts.ts @@ -0,0 +1,332 @@ +import { useMemo } from 'react'; +import { create } from 'zustand'; +import { mutative } from 'zustand-mutative'; + +import type { Context, Status } from 'pl-api'; + +/** Minimal status fields needed to process context. */ +type ContextStatus = Pick; + +/** Import a single status into the reducer, setting replies and replyTos. */ +const importStatus = (state: State, status: ContextStatus, idempotencyKey?: string) => { + const { id, in_reply_to_id: inReplyToId } = status; + if (!inReplyToId) return; + + const replies = state.replies[inReplyToId] || []; + const newReplies = [...new Set([...replies, id])].toSorted(); + + state.replies[inReplyToId] = newReplies; + state.inReplyTos[id] = inReplyToId; + + if (idempotencyKey) { + deletePendingStatus(state, status.in_reply_to_id, idempotencyKey); + } +}; + +const importStatuses = (state: State, statuses: ContextStatus[]) => { + statuses.forEach((status) => { + importStatus(state, status); + }); +}; + +/** Insert a fake status ID connecting descendant to ancestor. */ +const insertTombstone = (state: State, ancestorId: string, descendantId: string) => { + const tombstoneId = `${descendantId}-tombstone`; + importStatus(state, { id: tombstoneId, in_reply_to_id: ancestorId }); + importStatus(state, { id: descendantId, in_reply_to_id: tombstoneId }); +}; + +/** Find the highest level status from this statusId. */ +const getRootNode = (state: State, statusId: string, initialId = statusId): string => { + const parent = state.inReplyTos[statusId]; + + if (!parent) { + return statusId; + } else if (parent === initialId) { + // Prevent cycles + return parent; + } else { + return getRootNode(state, parent, initialId); + } +}; + +/** Route fromId to toId by inserting tombstones. */ +const connectNodes = (state: State, fromId: string, toId: string) => { + const fromRoot = getRootNode(state, fromId); + const toRoot = getRootNode(state, toId); + + if (fromRoot !== toRoot) { + insertTombstone(state, toId, fromId); + } +}; + +/** Import a branch of ancestors or descendants, in relation to statusId. */ +const importBranch = (state: State, statuses: ContextStatus[], statusId?: string) => { + statuses.forEach((status, i) => { + const prevId = statusId && i === 0 ? statusId : statuses[i - 1]?.id; + + if (status.in_reply_to_id) { + importStatus(state, status); + + // On Mastodon, in_reply_to_id can refer to an unavailable status, + // so traverse the tree up and insert a connecting tombstone if needed. + if (statusId) { + connectNodes(state, status.id, statusId); + } + } else if (prevId) { + // On Pleroma, in_reply_to_id will be null if the parent is unavailable, + // so insert the tombstone now. + insertTombstone(state, prevId, status.id); + } + }); +}; + +interface State { + inReplyTos: Record; + replies: Record>; + actions: { + /** Delete statuses upon blocking or muting a user. */ + filterContexts: ( + relationship: { id: string }, + statuses: Record, + ) => void; + /** Import a status's ancestors and descendants. */ + importContext: (statusId: string, context: Context) => void; + /** Add a fake status ID for a pending status. */ + importPendingStatus: (inReplyToId: string | null | undefined, idempotencyKey: string) => void; + /** Delete a pending status from the reducer. */ + deletePendingStatus: (inReplyToId: string | null | undefined, idempotencyKey: string) => void; + /** Import a single status into the reducer, setting replies and replyTos. */ + importStatus: (status: ContextStatus, idempotencyKey?: string) => void; + /** Import multiple statuses into the state. */ + importStatuses: (statuses: Array) => void; + /** Delete multiple statuses from the reducer. */ + deleteStatuses: (statusIds: Array) => void; + }; +} + +interface ContextOwnedStatus { + id: string; + account?: { id: string } | null; + account_id?: string | null; +} + +/** Remove a status from the reducer. */ +const deleteStatus = (state: State, statusId: string) => { + const parentId = state.inReplyTos[statusId]; + if (parentId) { + const parentReplies = state.replies[parentId] || []; + const newParentReplies = parentReplies.filter((id) => id !== statusId); + state.replies[parentId] = newParentReplies; + } + + const replies = (state.replies[statusId] = []); + replies.forEach((reply) => delete state.inReplyTos[reply]); + + delete state.inReplyTos[statusId]; + delete state.replies[statusId]; +}; + +/** Delete multiple statuses from the reducer. */ +const deleteStatuses = (state: State, statusIds: string[]) => { + statusIds.forEach((statusId) => { + deleteStatus(state, statusId); + }); +}; + +const getStatusAccountId = (status: ContextOwnedStatus) => status.account_id ?? status.account?.id; + +/** Delete a pending status from the reducer. */ +const deletePendingStatus = ( + state: State, + inReplyToId: string | null | undefined, + idempotencyKey: string, +) => { + const id = `末pending-${idempotencyKey}`; + + delete state.inReplyTos[id]; + + if (inReplyToId) { + const replies = state.replies[inReplyToId] || []; + const newReplies = replies.filter((replyId) => replyId !== id).toSorted(); + state.replies[inReplyToId] = newReplies; + } +}; + +const useContextStore = create()( + mutative((set) => ({ + inReplyTos: {}, + replies: {}, + actions: { + filterContexts: (relationship, statuses) => + set((state) => { + const ownedStatusIds = Object.values(statuses) + .filter((status) => getStatusAccountId(status) === relationship.id) + .map((status) => status.id); + + deleteStatuses(state, ownedStatusIds); + }), + importContext: (statusId: string, { ancestors, descendants }: Context) => + set((state) => { + importBranch(state, ancestors); + importBranch(state, descendants, statusId); + + if (ancestors.length > 0 && !state.inReplyTos[statusId]) { + insertTombstone(state, ancestors[ancestors.length - 1].id, statusId); + } + }), + importPendingStatus: (inReplyToId, idempotencyKey) => + set((state) => { + const id = `末pending-${idempotencyKey}`; + importStatus(state, { id, in_reply_to_id: inReplyToId ?? null }); + }), + deletePendingStatus: (inReplyToId, idempotencyKey) => + set((state) => { + const id = `末pending-${idempotencyKey}`; + + delete state.inReplyTos[id]; + + if (inReplyToId) { + const replies = state.replies[inReplyToId] || []; + const newReplies = replies.filter((replyId) => replyId !== id).toSorted(); + state.replies[inReplyToId] = newReplies; + } + }), + importStatus: (status, idempotencyKey) => + set((state) => { + importStatus(state, status, idempotencyKey); + }), + importStatuses: (statuses) => + set((state) => { + importStatuses(state, statuses); + }), + deleteStatuses: (statusIds) => + set((state) => { + deleteStatuses(state, statusIds); + }), + }, + })), +); + +const getAncestorsIds = (statusId: string, inReplyTos: Record): Array => { + let ancestorsIds: Array = []; + let id: string = statusId; + + while (id && !ancestorsIds.includes(id)) { + ancestorsIds = [id, ...ancestorsIds]; + id = inReplyTos[id]; + } + + return [...new Set(ancestorsIds)]; +}; + +const getDescendantsIds = (statusId: string, contextReplies: Record) => { + let descendantsIds: Array = []; + const ids = [statusId]; + + while (ids.length > 0) { + const id = ids.shift(); + if (!id) break; + + const replies = contextReplies[id]; + + if (descendantsIds.includes(id)) { + break; + } + + if (statusId !== id) { + descendantsIds = [...descendantsIds, id]; + } + + if (replies) { + replies.toReversed().forEach((reply: string) => { + ids.unshift(reply); + }); + } + } + + return [...new Set(descendantsIds)]; +}; + +const useAncestorsIds = (statusId?: string) => { + const inReplyTos = useContextStore((state) => state.inReplyTos); + + return useMemo( + () => (statusId ? getAncestorsIds(statusId, inReplyTos).filter((id) => id !== statusId) : []), + [inReplyTos, statusId], + ); +}; + +const useDescendantsIds = (statusId?: string) => { + const replies = useContextStore((state) => state.replies); + + return useMemo( + () => (statusId ? getDescendantsIds(statusId, replies).filter((id) => id !== statusId) : []), + [replies, statusId], + ); +}; + +const useThread = (statusId?: string, linear?: boolean) => { + const inReplyTos = useContextStore((state) => state.inReplyTos); + const replies = useContextStore((state) => state.replies); + + return useMemo(() => { + if (!statusId) return []; + + if (linear) { + let parentStatus: string = statusId; + + while (inReplyTos[parentStatus]) { + parentStatus = inReplyTos[parentStatus]; + } + + const threadStatuses = [parentStatus]; + + for (let i = 0; i < threadStatuses.length; i++) { + for (const reply of replies[threadStatuses[i]] || []) { + if (!threadStatuses.includes(reply)) threadStatuses.push(reply); + } + } + + return threadStatuses.toSorted(); + } + + let ancestorsIds = getAncestorsIds(statusId, inReplyTos); + let descendantsIds = getDescendantsIds(statusId, replies); + + ancestorsIds = ancestorsIds.filter((id) => id !== statusId && !descendantsIds.includes(id)); + descendantsIds = descendantsIds.filter((id) => id !== statusId && !ancestorsIds.includes(id)); + + return [...ancestorsIds, statusId, ...descendantsIds]; + }, [inReplyTos, replies, statusId, linear]); +}; + +const useReplyToId = (statusId?: string) => { + const inReplyTos = useContextStore((state) => state.inReplyTos); + + return useMemo(() => { + if (!statusId) return undefined; + return inReplyTos[statusId]; + }, [inReplyTos, statusId]); +}; + +const useReplyCount = (statusId?: string) => { + const replies = useContextStore((state) => state.replies); + + return useMemo(() => { + if (!statusId) return 0; + return replies[statusId]?.length || 0; + }, [replies, statusId]); +}; + +const useContextsActions = () => useContextStore((state) => state.actions); + +export { + useContextStore, + useAncestorsIds, + useDescendantsIds, + useThread, + useReplyToId, + useReplyCount, + useContextsActions, +};