nicolium: migrate contexts reducer to zustand
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
@ -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) });
|
||||
};
|
||||
|
||||
@ -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;
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
@ -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,
|
||||
|
||||
332
packages/pl-fe/src/stores/contexts.ts
Normal file
332
packages/pl-fe/src/stores/contexts.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user