nicolium: remove timelines actions/reducer

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-03-05 15:47:34 +01:00
parent 63c70d22d9
commit 324a625af9
10 changed files with 7 additions and 843 deletions

View File

@ -4,10 +4,9 @@ import { queryClient } from '@/queries/client';
import { queryKeys } from '@/queries/keys';
import { useComposeStore } from '@/stores/compose';
import { useModalsStore } from '@/stores/modals';
import { useTimelinesStore } from '@/stores/timelines';
import { filterBadges, getTagDiff } from '@/utils/badges';
import { deleteFromTimelines } from './timelines';
import type { AppDispatch, RootState } from '@/store';
import type { PleromaConfig } from 'pl-api';
@ -73,7 +72,7 @@ const deleteStatus = (statusId: string) => (dispatch: AppDispatch, getState: ()
getClient(getState)
.admin.statuses.deleteStatus(statusId)
.then(() => {
dispatch(deleteFromTimelines(statusId));
useTimelinesStore.getState().actions.deleteStatus(statusId);
return { statusId };
});

View File

@ -13,7 +13,6 @@ import { isLoggedIn } from '@/utils/auth';
import { shouldHaveCard } from '@/utils/status';
import { importEntities } from './importer';
import { deleteFromTimelines } from './timelines';
import type { NormalizedStatus as Status } from '@/normalizers/status';
import type { AppDispatch, RootState } from '@/store';

View File

@ -1,512 +0,0 @@
import { getLocale } from '@/actions/settings';
import { getClient } from '@/api';
import { queryClient } from '@/queries/client';
import { queryKeys } from '@/queries/keys';
import { findStatuses } from '@/queries/statuses/use-status';
import { useComposeStore } from '@/stores/compose';
import { useContextStore } from '@/stores/contexts';
import { usePendingStatusesStore } from '@/stores/pending-statuses';
import { useSettingsStore } from '@/stores/settings';
import { shouldFilter } from '@/utils/timelines';
import { importEntities } from './importer';
import type { AppDispatch, RootState } from '@/store';
import type {
Account as BaseAccount,
AntennaTimelineParams,
GetAccountStatusesParams,
GetCircleStatusesParams,
GroupTimelineParams,
HashtagTimelineParams,
HomeTimelineParams,
ListTimelineParams,
PaginatedResponse,
PublicTimelineParams,
Status as BaseStatus,
LinkTimelineParams,
} from 'pl-api';
const TIMELINE_UPDATE = 'TIMELINE_UPDATE' as const;
const TIMELINE_DELETE = 'TIMELINE_DELETE' as const;
const TIMELINE_CLEAR = 'TIMELINE_CLEAR' as const;
const TIMELINE_UPDATE_QUEUE = 'TIMELINE_UPDATE_QUEUE' as const;
const TIMELINE_DEQUEUE = 'TIMELINE_DEQUEUE' as const;
const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP' as const;
const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST' as const;
const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS' as const;
const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL' as const;
const MAX_QUEUED_ITEMS = 40;
const processTimelineUpdate =
(timeline: string, status: BaseStatus) => (dispatch: AppDispatch, getState: () => RootState) => {
const me = getState().me;
const ownStatus = status.account?.id === me;
const hasPendingStatuses = Object.keys(usePendingStatusesStore.getState().statuses).length > 0;
const columnSettings = useSettingsStore.getState().settings.timelines[timeline];
const shouldSkipQueue = shouldFilter(
{
in_reply_to_id: status.in_reply_to_id,
visibility: status.visibility,
reblog_id: status.reblog?.id ?? null,
},
columnSettings,
);
if (ownStatus && hasPendingStatuses) {
// WebSockets push statuses without the Idempotency-Key,
// so if we have pending statuses, don't import it from here.
// We implement optimistic non-blocking statuses.
return;
}
if (shouldSkipQueue) {
dispatch(updateTimeline(timeline, status.id));
} else {
dispatch(updateTimelineQueue(timeline, status.id));
}
};
const updateTimeline = (timeline: string, statusId: string) => ({
type: TIMELINE_UPDATE,
timeline,
statusId,
});
const updateTimelineQueue = (timeline: string, statusId: string) => ({
// if (typeof accept === 'function' && !accept(status)) {
// return;
// }
type: TIMELINE_UPDATE_QUEUE,
timeline,
statusId,
});
interface TimelineDequeueAction {
type: typeof TIMELINE_DEQUEUE;
timeline: string;
}
const dequeueTimeline =
(timelineId: string, expandFunc?: () => void) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const queuedCount = state.timelines[timelineId]?.totalQueuedItemsCount || 0;
if (queuedCount <= 0) return;
if (queuedCount <= MAX_QUEUED_ITEMS) {
dispatch<TimelineDequeueAction>({ type: TIMELINE_DEQUEUE, timeline: timelineId });
return;
}
if (typeof expandFunc === 'function') {
dispatch(clearTimeline(timelineId));
expandFunc();
} else if (timelineId === 'home') {
dispatch(clearTimeline(timelineId));
dispatch(fetchHomeTimeline());
} else if (timelineId === 'public:local') {
dispatch(clearTimeline(timelineId));
dispatch(fetchPublicTimeline({ local: true }));
}
};
interface TimelineDeleteAction {
type: typeof TIMELINE_DELETE;
statusId: string;
accountId: string;
references: Array<[string, string]>;
reblogOf: string | null;
}
const deleteFromTimelines = (statusId: string) => (dispatch: AppDispatch) => {
const status = queryClient.getQueryData(queryKeys.statuses.show(statusId));
const accountId = status?.account_id ?? '';
const references: Array<[string, string]> = findStatuses((s) => s.reblog_id === statusId).map(
([id, s]) => [id, s.account_id],
);
const reblogOf = status?.reblog_id ?? null;
useContextStore.getState().actions.deleteStatuses([statusId]);
useComposeStore.getState().actions.handleTimelineDelete(statusId);
// Remove statuses from RQ cache
references.forEach(([refId]) =>
queryClient.removeQueries({ queryKey: queryKeys.statuses.show(refId) }),
);
queryClient.removeQueries({ queryKey: queryKeys.statuses.show(statusId) });
dispatch<TimelineDeleteAction>({
type: TIMELINE_DELETE,
statusId,
accountId,
references,
reblogOf,
});
};
const clearTimeline = (timeline: string) => ({ type: TIMELINE_CLEAR, timeline });
const noOp = () => {};
const parseTags = (tags: Record<string, any[]> = {}, mode: 'any' | 'all' | 'none') =>
(tags[mode] || []).map((tag) => tag.value);
const deduplicateStatuses = (statuses: Array<BaseStatus>) => {
const deduplicatedStatuses: Array<BaseStatus & { accounts: Array<BaseAccount> }> = [];
for (const status of statuses) {
const reblogged =
status.reblog &&
deduplicatedStatuses.find(
(deduplicatedStatus) => deduplicatedStatus.reblog?.id === status.reblog?.id,
);
if (reblogged) {
reblogged.accounts.push(status.account);
reblogged.id += ':' + status.id;
} else if (
!deduplicatedStatuses.some(
(deduplicatedStatus) => deduplicatedStatus.reblog?.id === status.id,
)
) {
deduplicatedStatuses.push({ accounts: [status.account], ...status });
}
}
return deduplicatedStatuses;
};
const handleTimelineExpand =
(
timelineId: string,
fn: Promise<PaginatedResponse<BaseStatus>>,
done = noOp,
onError?: (error: any) => void,
) =>
async (dispatch: AppDispatch) => {
dispatch(expandTimelineRequest(timelineId));
try {
const response = await fn;
importEntities({ statuses: response.items });
const statuses = deduplicateStatuses(response.items);
importEntities({ statuses: statuses.filter((status) => status.accounts) });
dispatch(
expandTimelineSuccess(
timelineId,
statuses,
response.next,
response.previous,
response.partial,
),
);
done();
} catch (error) {
dispatch(expandTimelineFail(timelineId, error));
done();
onError?.(error);
}
};
const fetchHomeTimeline =
(expand = false, done = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const params: HomeTimelineParams = {};
if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale();
if (expand && state.timelines.home?.isLoading) return;
const fn =
(expand && state.timelines.home?.next?.()) || getClient(state).timelines.homeTimeline(params);
return dispatch(handleTimelineExpand('home', fn, done));
};
const fetchPublicTimeline =
(
{ onlyMedia, local, instance }: Record<string, any> = {},
expand = false,
done = noOp,
onError = noOp,
) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const timelineId = `${instance ? 'remote' : 'public'}${local ? ':local' : ''}${onlyMedia ? ':media' : ''}${instance ? `:${instance}` : ''}`;
const params: PublicTimelineParams = {
only_media: onlyMedia,
local: instance ? false : local,
instance,
};
if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale();
if (expand && state.timelines[timelineId]?.isLoading) return;
const fn =
(expand && state.timelines[timelineId]?.next?.()) ||
getClient(state).timelines.publicTimeline(params);
return dispatch(handleTimelineExpand(timelineId, fn, done, onError));
};
const fetchBubbleTimeline =
({ onlyMedia }: Record<string, any> = {}, expand = false, done = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const timelineId = `bubble${onlyMedia ? ':media' : ''}`;
const params: PublicTimelineParams = { only_media: onlyMedia };
if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale();
if (expand && state.timelines[timelineId]?.isLoading) return;
const fn =
(expand && state.timelines[timelineId]?.next?.()) ||
getClient(state).timelines.bubbleTimeline(params);
return dispatch(handleTimelineExpand(timelineId, fn, done));
};
const fetchWrenchedTimeline =
({ onlyMedia }: Record<string, any> = {}, expand = false, done = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const timelineId = `wrenched${onlyMedia ? ':media' : ''}`;
const params: PublicTimelineParams = { only_media: onlyMedia };
if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale();
if (expand && state.timelines[timelineId]?.isLoading) return;
const fn =
(expand && state.timelines[timelineId]?.next?.()) ||
getClient(state).timelines.wrenchedTimeline(params);
return dispatch(handleTimelineExpand(timelineId, fn, done));
};
const fetchAccountTimeline =
(
accountId: string,
{ exclude_replies, pinned, only_media, limit }: Record<string, any> = {},
expand = false,
done = noOp,
) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const timelineId = `account:${accountId}${!exclude_replies ? ':with_replies' : ''}${pinned ? ':pinned' : only_media ? ':media' : ''}`;
const params: GetAccountStatusesParams = { exclude_replies, pinned, only_media, limit };
if (pinned || only_media) params.with_muted = true;
if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale();
if (!expand && state.timelines[timelineId]?.loaded) return;
if (expand && state.timelines[timelineId]?.isLoading) return;
const fn =
(expand && state.timelines[timelineId]?.next?.()) ||
getClient(state).accounts.getAccountStatuses(accountId, params);
return dispatch(handleTimelineExpand(timelineId, fn, done));
};
const fetchListTimeline =
(listId: string, expand = false, done = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const timelineId = `list:${listId}`;
const params: ListTimelineParams = {};
if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale();
if (expand && state.timelines[timelineId]?.isLoading) return;
const fn =
(expand && state.timelines[timelineId]?.next?.()) ||
getClient(state).timelines.listTimeline(listId, params);
return dispatch(handleTimelineExpand(timelineId, fn, done));
};
const fetchCircleTimeline =
(circleId: string, expand = false, done = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const timelineId = `circle:${circleId}`;
const params: GetCircleStatusesParams = {};
// if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale();
if (expand && state.timelines[timelineId]?.isLoading) return;
const fn =
(expand && state.timelines[timelineId]?.next?.()) ||
getClient(state).circles.getCircleStatuses(circleId, params);
return dispatch(handleTimelineExpand(timelineId, fn, done));
};
const fetchAntennaTimeline =
(antennaId: string, expand = false, done = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const timelineId = `antenna:${antennaId}`;
const params: AntennaTimelineParams = {};
// if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale();
if (expand && state.timelines[timelineId]?.isLoading) return;
const fn =
(expand && state.timelines[timelineId]?.next?.()) ||
getClient(state).timelines.antennaTimeline(antennaId, params);
return dispatch(handleTimelineExpand(timelineId, fn, done));
};
const fetchGroupTimeline =
(groupId: string, { only_media, limit }: Record<string, any> = {}, expand = false, done = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const timelineId = `group:${groupId}${only_media ? ':media' : ''}`;
const params: GroupTimelineParams = { only_media, limit };
if (only_media) params.with_muted = true;
if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale();
if (expand && state.timelines[timelineId]?.isLoading) return;
const fn =
(expand && state.timelines[timelineId]?.next?.()) ||
getClient(state).timelines.groupTimeline(groupId, params);
return dispatch(handleTimelineExpand(timelineId, fn, done));
};
const fetchHashtagTimeline =
(hashtag: string, { tags }: Record<string, any> = {}, expand = false, done = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const timelineId = `hashtag:${hashtag}`;
const params: HashtagTimelineParams = {
any: parseTags(tags, 'any'),
all: parseTags(tags, 'all'),
none: parseTags(tags, 'none'),
};
if (expand && state.timelines[timelineId]?.isLoading) return;
if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale();
const fn =
(expand && state.timelines[timelineId]?.next?.()) ||
getClient(state).timelines.hashtagTimeline(hashtag, params);
return dispatch(handleTimelineExpand(timelineId, fn, done));
};
const fetchLinkTimeline =
(url: string, expand = false, done = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const timelineId = `link:${url}`;
const params: LinkTimelineParams = {};
if (expand && state.timelines[timelineId]?.isLoading) return;
if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale();
const fn =
(expand && state.timelines[timelineId]?.next?.()) ||
getClient(state).timelines.linkTimeline(url, params);
return dispatch(handleTimelineExpand(timelineId, fn, done));
};
const expandTimelineRequest = (timeline: string) => ({
type: TIMELINE_EXPAND_REQUEST,
timeline,
});
const expandTimelineSuccess = (
timeline: string,
statuses: Array<BaseStatus>,
next: (() => Promise<PaginatedResponse<BaseStatus>>) | null,
prev: (() => Promise<PaginatedResponse<BaseStatus>>) | null,
partial: boolean,
) => ({
type: TIMELINE_EXPAND_SUCCESS,
timeline,
statuses,
next,
prev,
partial,
});
const expandTimelineFail = (timeline: string, error: unknown) => ({
type: TIMELINE_EXPAND_FAIL,
timeline,
error,
});
const scrollTopTimeline = (timeline: string, top: boolean) => ({
type: TIMELINE_SCROLL_TOP,
timeline,
top,
});
// TODO: other actions
type TimelineAction =
| ReturnType<typeof updateTimeline>
| TimelineDeleteAction
| ReturnType<typeof clearTimeline>
| ReturnType<typeof updateTimelineQueue>
| TimelineDequeueAction
| ReturnType<typeof scrollTopTimeline>
| ReturnType<typeof expandTimelineRequest>
| ReturnType<typeof expandTimelineSuccess>
| ReturnType<typeof expandTimelineFail>;
export {
TIMELINE_UPDATE,
TIMELINE_DELETE,
TIMELINE_CLEAR,
TIMELINE_UPDATE_QUEUE,
TIMELINE_DEQUEUE,
TIMELINE_SCROLL_TOP,
TIMELINE_EXPAND_REQUEST,
TIMELINE_EXPAND_SUCCESS,
TIMELINE_EXPAND_FAIL,
MAX_QUEUED_ITEMS,
processTimelineUpdate,
dequeueTimeline,
deleteFromTimelines,
clearTimeline,
fetchHomeTimeline,
fetchPublicTimeline,
fetchBubbleTimeline,
fetchWrenchedTimeline,
fetchAccountTimeline,
fetchListTimeline,
fetchCircleTimeline,
fetchAntennaTimeline,
fetchGroupTimeline,
fetchHashtagTimeline,
fetchLinkTimeline,
expandTimelineSuccess,
scrollTopTimeline,
type TimelineAction,
};

View File

@ -1,7 +1,6 @@
import { useCallback } from 'react';
import { importEntities } from '@/actions/importer';
import { deleteFromTimelines, processTimelineUpdate } from '@/actions/timelines';
import { useStatContext } from '@/contexts/stat-context';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useLoggedIn } from '@/hooks/use-logged-in';
@ -113,7 +112,6 @@ const useUserStream = () => {
switch (event.event) {
case 'update': {
const timelineId = getTimelineFromStream(event.stream);
dispatch(processTimelineUpdate(getTimelineFromStream(event.stream), event.payload));
importEntities({ statuses: [event.payload] });
useTimelinesStore.getState().actions.receiveStreamingStatus(timelineId, event.payload);
break;
@ -122,7 +120,6 @@ const useUserStream = () => {
importEntities({ statuses: [event.payload] });
break;
case 'delete':
dispatch(deleteFromTimelines(event.payload));
useTimelinesStore.getState().actions.deleteStatus(event.payload);
break;
case 'notification':

View File

@ -5,7 +5,6 @@ import { Toaster } from 'react-hot-toast';
import { fetchConfig } from '@/actions/admin';
import { register as registerPushNotifications } from '@/actions/push-notifications/registerer';
import { fetchHomeTimeline } from '@/actions/timelines';
import { useUserStream } from '@/api/hooks/streaming/use-user-stream';
import SidebarNavigation from '@/components/navigation/sidebar-navigation';
import ThumbNavigation from '@/components/navigation/thumb-navigation';
@ -91,8 +90,6 @@ const UI: React.FC = React.memo(() => {
prefetchCustomEmojis(client);
dispatch(fetchHomeTimeline());
if (account.is_admin && features.pleromaAdminAccounts) {
dispatch(fetchConfig());
}

View File

@ -1,8 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { submitReport, ReportableEntities } from '@/actions/reports';
import { fetchAccountTimeline } from '@/actions/timelines';
import AttachmentThumbs from '@/components/media/attachment-thumbs';
import StatusContent from '@/components/statuses/status-content';
import Modal from '@/components/ui/modal';
@ -15,6 +14,7 @@ import { useInstance } from '@/hooks/use-instance';
import { useAccount } from '@/queries/accounts/use-account';
import { useBlockAccountMutation } from '@/queries/accounts/use-relationship';
import { useMinimalStatus } from '@/queries/statuses/use-status';
import { useAccountTimeline } from '@/queries/timelines/use-timelines';
import ConfirmationStep from './steps/confirmation-step';
import OtherActionsStep from './steps/other-actions-step';
@ -230,11 +230,7 @@ const ReportModal: React.FC<BaseModalProps & ReportModalProps> = ({
}
}, [currentStep]);
useEffect(() => {
if (account?.id) {
dispatch(fetchAccountTimeline(account.id, { exclude_replies: false }));
}
}, [account?.id]);
useAccountTimeline(accountId, { exclude_replies: false });
if (!account) {
return null;

View File

@ -1,14 +1,12 @@
import React, { useEffect } from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { fetchAccountTimeline } from '@/actions/timelines';
import { AccountTimelineColumn } from '@/columns/timeline';
import MissingIndicator from '@/components/missing-indicator';
import Card, { CardBody } from '@/components/ui/card';
import Spinner from '@/components/ui/spinner';
import Text from '@/components/ui/text';
import { profileRoute } from '@/features/ui/router';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useFeatures } from '@/hooks/use-features';
import { useAccountLookup } from '@/queries/accounts/use-account-lookup';
import { usePinnedStatuses } from '@/queries/status-lists/use-pinned-statuses';
@ -17,7 +15,6 @@ const AccountTimelinePage: React.FC = () => {
const { username } = profileRoute.useParams();
const { with_replies: withReplies = false } = profileRoute.useSearch();
const dispatch = useAppDispatch();
const features = useFeatures();
const { data: account, isPending } = useAccountLookup(username);
@ -28,16 +25,6 @@ const AccountTimelinePage: React.FC = () => {
const accountUsername = account?.username ?? username;
useEffect(() => {
if (account) {
dispatch(fetchAccountTimeline(account.id, { exclude_replies: !withReplies }));
if (!withReplies) {
dispatch(fetchAccountTimeline(account.id, { pinned: true }));
}
}
}, [account?.id, withReplies]);
if (!account && isPending) {
return <Spinner />;
} else if (!account) {

View File

@ -1,8 +1,7 @@
import { useNavigate } from '@tanstack/react-router';
import React, { useEffect } from 'react';
import React from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { fetchCircleTimeline } from '@/actions/timelines';
import { CircleTimelineColumn } from '@/columns/timeline';
import DropdownMenu from '@/components/dropdown-menu';
import MissingIndicator from '@/components/missing-indicator';
@ -10,7 +9,6 @@ import Button from '@/components/ui/button';
import Column from '@/components/ui/column';
import Spinner from '@/components/ui/spinner';
import { circleTimelineRoute } from '@/features/ui/router';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useCircle, useDeleteCircle } from '@/queries/accounts/use-circles';
import { useModalsActions } from '@/stores/modals';
@ -29,17 +27,12 @@ const CircleTimelinePage: React.FC = () => {
const { circleId } = circleTimelineRoute.useParams();
const intl = useIntl();
const dispatch = useAppDispatch();
const { openModal } = useModalsActions();
const navigate = useNavigate();
const { data: circle, isFetching } = useCircle(circleId);
const { mutate: deleteCircle } = useDeleteCircle();
useEffect(() => {
dispatch(fetchCircleTimeline(circleId));
}, [circleId]);
const handleEditClick = () => {
openModal('CIRCLE_EDITOR', { circleId });
};

View File

@ -1,266 +0,0 @@
import { create } from 'mutative';
import {
TIMELINE_UPDATE,
TIMELINE_DELETE,
TIMELINE_CLEAR,
TIMELINE_EXPAND_SUCCESS,
TIMELINE_EXPAND_REQUEST,
TIMELINE_EXPAND_FAIL,
TIMELINE_UPDATE_QUEUE,
TIMELINE_DEQUEUE,
MAX_QUEUED_ITEMS,
TIMELINE_SCROLL_TOP,
type TimelineAction,
} from '@/actions/timelines';
import type { PaginatedResponse, Status as BaseStatus } from 'pl-api';
type ImportPosition = 'start' | 'end';
const TRUNCATE_LIMIT = 40;
const TRUNCATE_SIZE = 20;
interface Timeline {
unread: number;
top: boolean;
isLoading: boolean;
hasMore: boolean;
next: (() => Promise<PaginatedResponse<BaseStatus>>) | null;
prev: (() => Promise<PaginatedResponse<BaseStatus>>) | null;
items: Array<string>;
queuedItems: Array<string>; //max= MAX_QUEUED_ITEMS
totalQueuedItemsCount: number; //used for queuedItems overflow for MAX_QUEUED_ITEMS+
loadingFailed: boolean;
isPartial: boolean;
loaded: boolean;
}
const newTimeline = (): Timeline => ({
unread: 0,
top: true,
isLoading: false,
hasMore: true,
next: null,
prev: null,
items: [],
queuedItems: [], //max= MAX_QUEUED_ITEMS
totalQueuedItemsCount: 0, //used for queuedItems overflow for MAX_QUEUED_ITEMS+
loadingFailed: false,
isPartial: false,
loaded: false,
});
const initialState: State = {};
type State = Record<string, Timeline>;
const getStatusIds = (statuses: Array<Pick<BaseStatus, 'id'>> = []) =>
statuses.map((status) => status.id);
const mergeStatusIds = (oldIds: Array<string>, newIds: Array<string>) => [
...new Set([...newIds, ...oldIds]),
];
const addStatusId = (oldIds = Array<string>(), newId: string) => mergeStatusIds(oldIds, [newId]);
// Like `take`, but only if the collection's size exceeds truncateLimit
const truncate = (items: Array<string>, truncateLimit: number, newSize: number) =>
items.length > truncateLimit ? items.slice(0, newSize) : items;
const truncateIds = (items: Array<string>) => truncate(items, TRUNCATE_LIMIT, TRUNCATE_SIZE);
const updateTimeline = (
state: State,
timelineId: string,
updater: (timeline: Timeline) => void,
) => {
state[timelineId] = state[timelineId] || newTimeline();
updater(state[timelineId]);
};
const setLoading = (state: State, timelineId: string, loading: boolean) => {
updateTimeline(state, timelineId, (timeline) => {
timeline.isLoading = loading;
});
};
// Keep track of when a timeline failed to load
const setFailed = (state: State, timelineId: string, failed: boolean) => {
updateTimeline(state, timelineId, (timeline) => {
timeline.loadingFailed = failed;
});
};
const expandNormalizedTimeline = (
state: State,
timelineId: string,
statuses: Array<BaseStatus>,
next: (() => Promise<PaginatedResponse<BaseStatus>>) | null,
prev: (() => Promise<PaginatedResponse<BaseStatus>>) | null,
isPartial: boolean,
pos: ImportPosition = 'end',
) => {
const newIds = getStatusIds(statuses);
updateTimeline(state, timelineId, (timeline) => {
timeline.isLoading = false;
timeline.loadingFailed = false;
timeline.isPartial = isPartial;
timeline.next = next;
timeline.prev = prev;
timeline.loaded = true;
if (!next) timeline.hasMore = false;
if (newIds.length) {
if (pos === 'end') {
timeline.items = mergeStatusIds(newIds, timeline.items);
} else {
timeline.items = mergeStatusIds(timeline.items, newIds);
}
}
});
};
const appendStatus = (state: State, timelineId: string, statusId: string) => {
const top = state[timelineId]?.top;
const oldIds = state[timelineId]?.items || [];
const unread = state[timelineId]?.unread || 0;
if (oldIds.includes(statusId) || state[timelineId]?.queuedItems.includes(statusId)) return state;
const newIds = addStatusId(oldIds, statusId);
updateTimeline(state, timelineId, (timeline) => {
if (top) {
// For performance, truncate items if user is scrolled to the top
timeline.items = truncateIds(newIds);
} else {
timeline.unread = unread + 1;
timeline.items = newIds;
}
});
};
const updateTimelineQueue = (state: State, timelineId: string, statusId: string) => {
updateTimeline(state, timelineId, (timeline) => {
const {
queuedItems: queuedIds,
items: listedIds,
totalQueuedItemsCount: queuedCount,
} = timeline;
if (queuedIds.includes(statusId)) return;
if (listedIds.includes(statusId)) return;
timeline.totalQueuedItemsCount = queuedCount + 1;
timeline.queuedItems = addStatusId(queuedIds, statusId).slice(0, MAX_QUEUED_ITEMS);
});
};
const shouldDelete = (timelineId: string, excludeAccount: string | null) => {
if (!excludeAccount) return true;
if (timelineId === `account:${excludeAccount}`) return false;
if (timelineId.startsWith(`account:${excludeAccount}:`)) return false;
return true;
};
const deleteStatus = (
state: State,
statusId: string,
references: Array<[string]> | Array<[string, string]>,
excludeAccount: string | null,
) => {
for (const timelineId in state) {
if (shouldDelete(timelineId, excludeAccount)) {
state[timelineId].items = state[timelineId].items.filter((id) => id !== statusId);
state[timelineId].queuedItems = state[timelineId].queuedItems.filter((id) => id !== statusId);
}
}
// Remove reblogs of deleted status
references.forEach((ref) => {
deleteStatus(state, ref[0], [], excludeAccount);
});
};
const clearTimeline = (state: State, timelineId: string) => {
state[timelineId] = newTimeline();
};
const updateTop = (state: State, timelineId: string, top: boolean) => {
updateTimeline(state, timelineId, (timeline) => {
if (top) timeline.unread = 0;
timeline.top = top;
});
};
const timelineDequeue = (state: State, timelineId: string) => {
updateTimeline(state, timelineId, (timeline) => {
const top = timeline.top;
const queuedIds = timeline.queuedItems;
const newIds = mergeStatusIds(timeline.items, queuedIds);
timeline.items = top ? truncateIds(newIds) : newIds;
timeline.queuedItems = [];
timeline.totalQueuedItemsCount = 0;
});
};
const handleExpandFail = (state: State, timelineId: string) => {
setLoading(state, timelineId, false);
setFailed(state, timelineId, true);
};
const timelines = (state: State = initialState, action: TimelineAction): State => {
switch (action.type) {
case TIMELINE_EXPAND_REQUEST:
return create(state, (draft) => {
setLoading(draft, action.timeline, true);
});
case TIMELINE_EXPAND_FAIL:
return create(state, (draft) => {
handleExpandFail(draft, action.timeline);
});
case TIMELINE_EXPAND_SUCCESS:
return create(state, (draft) => {
expandNormalizedTimeline(
draft,
action.timeline,
action.statuses,
action.next,
action.prev,
action.partial,
);
});
case TIMELINE_UPDATE:
return create(state, (draft) => appendStatus(draft, action.timeline, action.statusId));
case TIMELINE_UPDATE_QUEUE:
return create(state, (draft) => {
updateTimelineQueue(draft, action.timeline, action.statusId);
});
case TIMELINE_DEQUEUE:
return create(state, (draft) => {
timelineDequeue(draft, action.timeline);
});
case TIMELINE_DELETE:
return create(state, (draft) => {
deleteStatus(draft, action.statusId, action.references, action.reblogOf);
});
case TIMELINE_CLEAR:
return create(state, (draft) => {
clearTimeline(draft, action.timeline);
});
case TIMELINE_SCROLL_TOP:
return create(state, (draft) => {
updateTop(draft, action.timeline, action.top);
});
default:
return state;
}
};
export { timelines as default };

View File

@ -1,26 +0,0 @@
import type { NormalizedStatus as Status } from '@/normalizers/status';
import type { Settings } from '@/schemas/frontend-settings';
const shouldFilter = (
status: Pick<Status, 'in_reply_to_id' | 'visibility' | 'reblog_id'>,
columnSettings: Settings['timelines'][''],
) => {
const fallback = {
reblog: true,
reply: true,
direct: false,
};
const shows = {
reblog: status.reblog_id !== null,
reply: status.in_reply_to_id !== null,
direct: status.visibility === 'direct',
};
return Object.entries(shows).some(
([key, value]) =>
!(columnSettings?.shows || fallback)[key as 'reblog' | 'reply' | 'direct'] && value,
);
};
export { shouldFilter };