From 2c53f56ff7dfe3aa5490523421fbf916e7879791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Thu, 5 Mar 2026 15:17:39 +0100 Subject: [PATCH] nicolium: experimental timelines: block/mute side-effects, pending statuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- packages/nicolium/src/actions/statuses.ts | 11 +++ packages/nicolium/src/columns/timeline.tsx | 15 +++ .../features/ui/components/pending-status.tsx | 10 +- .../src/queries/accounts/use-relationship.ts | 3 + packages/nicolium/src/stores/timelines.ts | 97 ++++++++++++++++++- 5 files changed, 133 insertions(+), 3 deletions(-) diff --git a/packages/nicolium/src/actions/statuses.ts b/packages/nicolium/src/actions/statuses.ts index 9da991396..541247ea5 100644 --- a/packages/nicolium/src/actions/statuses.ts +++ b/packages/nicolium/src/actions/statuses.ts @@ -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) => { diff --git a/packages/nicolium/src/columns/timeline.tsx b/packages/nicolium/src/columns/timeline.tsx index 1089357d3..0d2d4596d 100644 --- a/packages/nicolium/src/columns/timeline.tsx +++ b/packages/nicolium/src/columns/timeline.tsx @@ -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 = () => ( ); +interface ITimelinePendingStatus { + idempotencyKey: string; +} + +const TimelinePendingStatus: React.FC = ({ idempotencyKey }) => { + return ( +
+ +
+ ); +}; + interface ITimelineStatusInfo { status: SelectedStatus; rebloggedBy: Array; @@ -245,6 +258,8 @@ const Timeline: React.FC = ({ query, contextType = 'public' }) => { // variant={divideType === 'border' ? 'slim' : 'rounded'} /> ); + } else if (entry.type === 'pending-status') { + return ; } }; diff --git a/packages/nicolium/src/features/ui/components/pending-status.tsx b/packages/nicolium/src/features/ui/components/pending-status.tsx index 1bf62e810..284185d9a 100644 --- a/packages/nicolium/src/features/ui/components/pending-status.tsx +++ b/packages/nicolium/src/features/ui/components/pending-status.tsx @@ -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 = ({ const status = pendingStatus && ownAccount ? buildStatus(ownAccount, pendingStatus, idempotencyKey) : null; - const { data: poll } = usePollQuery(status?.poll_id ?? ''); + const { data: poll } = useQuery({ + queryKey: queryKeys.statuses.polls.show(status?.poll_id ?? ''), + queryFn: skipToken, + enabled: !!status?.poll_id, + }); if (!status) return null; if (!ownAccount) return null; diff --git a/packages/nicolium/src/queries/accounts/use-relationship.ts b/packages/nicolium/src/queries/accounts/use-relationship.ts index 6a031de49..946384340 100644 --- a/packages/nicolium/src/queries/accounts/use-relationship.ts +++ b/packages/nicolium/src/queries/accounts/use-relationship.ts @@ -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({ 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({ type: ACCOUNT_MUTE_SUCCESS, diff --git a/packages/nicolium/src/stores/timelines.ts b/packages/nicolium/src/stores/timelines.ts index 11125161d..e887f366e 100644 --- a/packages/nicolium/src/stores/timelines.ts +++ b/packages/nicolium/src/stores/timelines.ts @@ -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): Array => { return timelinePage; }; +const getTimelinesForStatus = ( + status: Pick | Pick, +): Array => { + 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()( mutative((set) => ({ timelines: {} as Record, @@ -179,6 +201,79 @@ const useTimelinesStore = create()( 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(); + + 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; + } + }), }, })), );