nicolium: experimental timelines: block/mute side-effects, pending statuses

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-03-05 15:17:39 +01:00
parent ed53f015d6
commit 2c53f56ff7
5 changed files with 133 additions and 3 deletions

View File

@ -8,6 +8,7 @@ import { useContextStore } from '@/stores/contexts';
import { useModalsStore } from '@/stores/modals';
import { usePendingStatusesStore } from '@/stores/pending-statuses';
import { useSettingsStore } from '@/stores/settings';
import { useTimelinesStore } from '@/stores/timelines';
import { isLoggedIn } from '@/utils/auth';
import { shouldHaveCard } from '@/utils/status';
@ -86,6 +87,7 @@ const createStatus =
if (!params.preview) {
usePendingStatusesStore.getState().actions.importStatus(params, idempotencyKey);
useContextStore.getState().actions.importPendingStatus(params.in_reply_to_id, idempotencyKey);
useTimelinesStore.getState().actions.importPendingStatus(params, idempotencyKey);
if (!editedId) {
incrementReplyCount(params);
}
@ -137,6 +139,12 @@ const createStatus =
idempotencyKey,
);
if (status.scheduled_at === null) {
useTimelinesStore.getState().actions.replacePendingStatus(idempotencyKey, status.id);
} else {
useTimelinesStore.getState().actions.deletePendingStatus(idempotencyKey);
}
// Poll the backend for the updated card
if (expectsCard) {
const delay = 1000;
@ -161,6 +169,7 @@ const createStatus =
})
.catch((error) => {
usePendingStatusesStore.getState().actions.deleteStatus(idempotencyKey);
useTimelinesStore.getState().actions.deletePendingStatus(idempotencyKey);
useContextStore
.getState()
.actions.deletePendingStatus(params.in_reply_to_id, idempotencyKey);
@ -235,6 +244,7 @@ const deleteStatus =
.statuses.deleteStatus(statusId)
.then((source) => {
usePendingStatusesStore.getState().actions.deleteStatus(statusId);
useTimelinesStore.getState().actions.deleteStatus(statusId);
updateStatus(
statusId,
(s) => {
@ -267,6 +277,7 @@ const deleteStatusFromGroup =
.experimental.groups.deleteGroupStatus(statusId, groupId)
.then(() => {
usePendingStatusesStore.getState().actions.deleteStatus(statusId);
useTimelinesStore.getState().actions.deleteStatus(statusId);
updateStatus(
statusId,
(s) => {

View File

@ -12,6 +12,7 @@ import Icon from '@/components/ui/icon';
import Portal from '@/components/ui/portal';
import Emojify from '@/features/emoji/emojify';
import PlaceholderStatus from '@/features/placeholder/components/placeholder-status';
import PendingStatus from '@/features/ui/components/pending-status';
import { useFeatures } from '@/hooks/use-features';
import { useAccounts } from '@/queries/accounts/use-accounts';
import { type SelectedStatus, useStatus } from '@/queries/statuses/use-status';
@ -51,6 +52,18 @@ const PlaceholderTimelineStatus = () => (
</div>
);
interface ITimelinePendingStatus {
idempotencyKey: string;
}
const TimelinePendingStatus: React.FC<ITimelinePendingStatus> = ({ idempotencyKey }) => {
return (
<div className='⁂-timeline-status relative border-b border-solid border-gray-200 dark:border-gray-800'>
<PendingStatus idempotencyKey={idempotencyKey} variant='slim' />
</div>
);
};
interface ITimelineStatusInfo {
status: SelectedStatus;
rebloggedBy: Array<string>;
@ -245,6 +258,8 @@ const Timeline: React.FC<ITimeline> = ({ query, contextType = 'public' }) => {
// variant={divideType === 'border' ? 'slim' : 'rounded'}
/>
);
} else if (entry.type === 'pending-status') {
return <TimelinePendingStatus key={entry.id} idempotencyKey={entry.id} />;
}
};

View File

@ -1,3 +1,4 @@
import { skipToken, useQuery } from '@tanstack/react-query';
import clsx from 'clsx';
import React from 'react';
@ -11,7 +12,7 @@ import PlaceholderCard from '@/features/placeholder/components/placeholder-card'
import PlaceholderMediaGallery from '@/features/placeholder/components/placeholder-media-gallery';
import QuotedStatus from '@/features/status/containers/quoted-status-container';
import { useOwnAccount } from '@/hooks/use-own-account';
import { usePollQuery } from '@/queries/statuses/use-poll';
import { queryKeys } from '@/queries/keys';
import { usePendingStatus } from '@/stores/pending-statuses';
import { buildStatus } from '../util/pending-status-builder';
@ -19,6 +20,7 @@ import { buildStatus } from '../util/pending-status-builder';
import PollPreview from './poll-preview';
import type { NormalizedStatus as StatusEntity } from '@/normalizers/status';
import type { Poll } from 'pl-api';
const shouldHaveCard = (pendingStatus: StatusEntity) =>
Boolean(/https?:\/\/\S*/.test(pendingStatus.content));
@ -54,7 +56,11 @@ const PendingStatus: React.FC<IPendingStatus> = ({
const status =
pendingStatus && ownAccount ? buildStatus(ownAccount, pendingStatus, idempotencyKey) : null;
const { data: poll } = usePollQuery(status?.poll_id ?? '');
const { data: poll } = useQuery<Poll>({
queryKey: queryKeys.statuses.polls.show(status?.poll_id ?? ''),
queryFn: skipToken,
enabled: !!status?.poll_id,
});
if (!status) return null;
if (!ownAccount) return null;

View File

@ -14,6 +14,7 @@ import { useLoggedIn } from '@/hooks/use-logged-in';
import { useOwnAccount } from '@/hooks/use-own-account';
import { queryKeys } from '@/queries/keys';
import { useContextsActions } from '@/stores/contexts';
import { useTimelinesStore } from '@/stores/timelines';
import type {
BlockAccountParams,
@ -192,6 +193,7 @@ const useBlockAccountMutation = (accountId: string) => {
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
filterContexts(data);
useTimelinesStore.getState().actions.filterTimelines(data.id);
return dispatch<AccountsAction>({
type: ACCOUNT_BLOCK_SUCCESS,
@ -260,6 +262,7 @@ const useMuteAccountMutation = (accountId: string) => {
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
filterContexts(data);
useTimelinesStore.getState().actions.filterTimelines(data.id);
return dispatch<AccountsAction>({
type: ACCOUNT_MUTE_SUCCESS,

View File

@ -1,7 +1,10 @@
import { create } from 'zustand';
import { mutative } from 'zustand-mutative';
import type { Status } from 'pl-api';
import { findStatuses } from '@/queries/statuses/use-status';
import type { NormalizedStatus } from '@/normalizers/status';
import type { CreateStatusParams, Status } from 'pl-api';
type TimelineEntry =
| {
@ -44,6 +47,10 @@ interface State {
deleteStatus: (statusId: string) => void;
setLoading: (timelineId: string, isFetching: boolean) => void;
dequeueEntries: (timelineId: string) => void;
importPendingStatus: (params: CreateStatusParams, idempotencyKey: string) => void;
replacePendingStatus: (idempotencyKey: string, newId: string) => void;
deletePendingStatus: (idempotencyKey: string) => void;
filterTimelines: (accountId: string) => void;
};
}
@ -108,6 +115,21 @@ const processPage = (statuses: Array<Status>): Array<TimelineEntry> => {
return timelinePage;
};
const getTimelinesForStatus = (
status: Pick<Status, 'visibility' | 'group'> | Pick<CreateStatusParams, 'visibility'>,
): Array<string> => {
switch (status.visibility) {
case 'group':
return [`group:${'group' in status && status.group?.id}`];
case 'direct':
return [];
case 'public':
return ['home', 'public:local', 'public', 'bubble'];
default:
return ['home'];
}
};
const useTimelinesStore = create<State>()(
mutative((set) => ({
timelines: {} as Record<string, TimelineData>,
@ -179,6 +201,79 @@ const useTimelinesStore = create<State>()(
timeline.queuedEntries = [];
timeline.queuedCount = 0;
}),
importPendingStatus: (params, idempotencyKey) =>
set((state) => {
if (params.scheduled_at) return;
const timelineIds = getTimelinesForStatus(params);
for (const timelineId of timelineIds) {
const timeline = state.timelines[timelineId];
if (!timeline) continue;
if (
timeline.entries.some((e) => e.type === 'pending-status' && e.id === idempotencyKey)
)
continue;
timeline.entries.unshift({ type: 'pending-status', id: idempotencyKey });
}
}),
replacePendingStatus: (idempotencyKey, newId) =>
set((state) => {
for (const timeline of Object.values(state.timelines)) {
const idx = timeline.entries.findIndex(
(e) => e.type === 'pending-status' && e.id === idempotencyKey,
);
if (idx !== -1) {
timeline.entries[idx] = {
type: 'status',
id: newId,
rebloggedBy: [],
};
}
}
}),
deletePendingStatus: (idempotencyKey) =>
set((state) => {
for (const timeline of Object.values(state.timelines)) {
const idx = timeline.entries.findIndex(
(e) => e.type === 'pending-status' && e.id === idempotencyKey,
);
if (idx !== -1) {
timeline.entries.splice(idx, 1);
}
}
}),
filterTimelines: (accountId) =>
set((state) => {
const ownedStatuses = findStatuses(
(status: NormalizedStatus) => status.account_id === accountId,
);
const statusIdsToRemove = new Set<string>();
for (const [, status] of ownedStatuses) {
statusIdsToRemove.add(status.id);
}
for (const timeline of Object.values(state.timelines)) {
timeline.entries = timeline.entries.filter((entry) => {
if (entry.type !== 'status') return true;
if (statusIdsToRemove.has(entry.id)) return false;
const index = entry.rebloggedBy.indexOf(accountId);
if (index !== -1) entry.rebloggedBy.splice(index, 1);
return true;
});
timeline.queuedEntries = timeline.queuedEntries.filter(
(status) =>
status.account.id !== accountId && status.reblog?.account.id !== accountId,
);
timeline.queuedCount = timeline.queuedEntries.length;
}
}),
},
})),
);