nicolium: migrate contexts reducer to zustand

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-02-23 16:40:36 +01:00
parent 3735f53e91
commit a6208f194f
10 changed files with 397 additions and 357 deletions

View File

@ -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<ImportStatusAction>({
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<ImportStatusesAction>({ type: STATUSES_IMPORT, statuses: Object.values(statuses) });
};

View File

@ -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<StatusesAction>({
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<StatusesAction>({
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<StatusesAction>({ type: CONTEXT_FETCH_SUCCESS, statusId, ancestors, descendants });
return context;
})

View File

@ -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<TimelineDeleteAction>({
type: TIMELINE_DELETE,
statusId,

View File

@ -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<IThreadStatus> = (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));

View File

@ -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<string> = [];
let id: string = statusId;
const getLinearThreadStatusesIds = (
statusId: string,
inReplyTos: Record<string, string>,
replies: Record<string, string[]>,
) => {
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<string> = [];
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 };

View File

@ -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<boolean>(!!status);

View File

@ -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<AccountsAction>((dispatch, getState) =>
dispatch({
return dispatch<AccountsAction>((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<AccountsAction>((dispatch, getState) =>
dispatch({
return dispatch<AccountsAction>((dispatch, getState) => {
filterContexts(data, getState().statuses);
return dispatch({
type: ACCOUNT_MUTE_SUCCESS,
relationship: data,
statuses: getState().statuses,
}),
);
});
});
},
});
};

View File

@ -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<string, string>;
replies: Record<string, Array<string>>;
}
const initialState: State = {
inReplyTos: {},
replies: {},
};
/** Import a single status into the reducer, setting replies and replyTos. */
const importStatus = (
state: State,
status: Pick<Status, 'id' | 'in_reply_to_id'>,
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<Pick<Status, 'id' | 'in_reply_to_id'>>) => {
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<Pick<Status, 'id' | 'in_reply_to_id'>>,
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<Pick<Status, 'id' | 'in_reply_to_id'>>,
descendants: Array<Pick<Status, 'id' | 'in_reply_to_id'>>,
) => {
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<string, Pick<Status, 'account' | 'id'>>,
) => {
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 };

View File

@ -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,

View File

@ -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<Status, 'id' | 'in_reply_to_id'>;
/** 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<string, string>;
replies: Record<string, Array<string>>;
actions: {
/** Delete statuses upon blocking or muting a user. */
filterContexts: (
relationship: { id: string },
statuses: Record<string, ContextOwnedStatus>,
) => 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<ContextStatus>) => void;
/** Delete multiple statuses from the reducer. */
deleteStatuses: (statusIds: Array<string>) => 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<State>()(
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<string, string>): Array<string> => {
let ancestorsIds: Array<string> = [];
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<string, string[]>) => {
let descendantsIds: Array<string> = [];
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,
};