nicolium: move new timelines state to zustand

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-03-04 23:52:48 +01:00
parent 53a2517675
commit 1deb2f9ba1
5 changed files with 205 additions and 215 deletions

View File

@ -20,7 +20,7 @@ import {
} from '@/queries/timelines/use-timelines';
import type { FilterContextType } from '@/queries/settings/use-filters';
import type { TimelineEntry } from '@/queries/timelines/use-timeline';
import type { TimelineEntry } from '@/stores/timelines';
interface ITimelineStatus {
id: string;
@ -86,7 +86,7 @@ interface ITimeline {
}
const Timeline: React.FC<ITimeline> = ({ query, contextType = 'public' }) => {
const { data, handleLoadMore, isLoading } = query;
const { entries, fetchNextPage, isFetching, isPending } = query;
const renderEntry = (entry: TimelineEntry) => {
if (entry.type === 'status') {
@ -102,14 +102,13 @@ const Timeline: React.FC<ITimeline> = ({ query, contextType = 'public' }) => {
// contextType={timelineId}
// showGroup={showGroup}
// variant={divideType === 'border' ? 'slim' : 'rounded'}
// fromBookmarks={other.scrollKey === 'bookmarked_statuses'}
/>
);
}
if (entry.type === 'page-end' || entry.type === 'page-start') {
return (
<div className='m-4'>
<LoadMore key='load-more' onClick={() => handleLoadMore(entry)} disabled={isLoading} />
<LoadMore key='load-more' onClick={fetchNextPage} disabled={isFetching} />
</div>
);
}
@ -119,8 +118,8 @@ const Timeline: React.FC<ITimeline> = ({ query, contextType = 'public' }) => {
<ScrollableList
id='status-list'
key='scrollable-list'
isLoading={isLoading}
showLoading={isLoading && !data}
isLoading={isFetching}
showLoading={isPending}
placeholderComponent={() => <PlaceholderStatus variant={'slim'} />}
placeholderCount={20}
// className={className}
@ -132,7 +131,7 @@ const Timeline: React.FC<ITimeline> = ({ query, contextType = 'public' }) => {
// })}
// {...other}
>
{(data || []).map(renderEntry)}
{(entries || []).map(renderEntry)}
</ScrollableList>
);
};

View File

@ -7,7 +7,6 @@ import type { MinifiedInteractionRequest } from './statuses/use-interaction-requ
import type { MinifiedContext } from './statuses/use-status';
import type { MinifiedStatusEdit } from './statuses/use-status-history';
import type { MinifiedEmojiReaction } from './statuses/use-status-interactions';
import type { TimelineEntry } from './timelines/use-timeline';
import type { MinifiedSuggestion } from './trends/use-suggested-accounts';
import type {
MinifiedAdminAccount,
@ -34,10 +33,8 @@ import type {
AdminRule,
Announcement,
Antenna,
AntennaTimelineParams,
Backup,
BookmarkFolder,
BubbleTimelineParams,
Chat,
Circle,
CredentialAccount,
@ -48,29 +45,21 @@ import type {
Group,
GroupRelationship,
GroupRole,
GroupTimelineParams,
HashtagTimelineParams,
HomeTimelineParams,
InteractionPolicies,
LinkTimelineParams,
List,
ListTimelineParams,
Location,
Marker,
NotificationGroup,
OauthToken,
PaginatedResponse,
PaginationParams,
PlApiClient,
Poll,
PublicTimelineParams,
Relationship,
RssFeed,
ScheduledStatus,
Tag,
Translation,
TrendsLink,
WrenchedTimelineParams,
} from 'pl-api';
type TaggedKey<TKey extends readonly unknown[], TData> = DataTag<TKey, TData>;
@ -429,46 +418,6 @@ const suggestions = {
all: ['suggestions'] as TaggedKey<['suggestions'], Array<MinifiedSuggestion>>,
};
const timelines = {
root: ['timelines'] as const,
home: (params?: Omit<HomeTimelineParams, keyof PaginationParams>) => {
const key = ['timelines', 'home', params] as const;
return key as TaggedKey<typeof key, Array<TimelineEntry>>;
},
public: (local?: boolean, params?: Omit<PublicTimelineParams, keyof PaginationParams>) => {
const key = ['timelines', 'public', { local, ...params }] as const;
return key as TaggedKey<typeof key, Array<TimelineEntry>>;
},
hashtag: (hashtag: string, params?: Omit<HashtagTimelineParams, keyof PaginationParams>) => {
const key = ['timelines', 'hashtag', hashtag, params] as const;
return key as TaggedKey<typeof key, Array<TimelineEntry>>;
},
link: (url: string, params?: Omit<LinkTimelineParams, keyof PaginationParams>) => {
const key = ['timelines', 'link', url, params] as const;
return key as TaggedKey<typeof key, Array<TimelineEntry>>;
},
list: (listId: string, params?: Omit<ListTimelineParams, keyof PaginationParams>) => {
const key = ['timelines', 'list', listId, params] as const;
return key as TaggedKey<typeof key, Array<TimelineEntry>>;
},
group: (groupId: string, params?: Omit<GroupTimelineParams, keyof PaginationParams>) => {
const key = ['timelines', 'group', groupId, params] as const;
return key as TaggedKey<typeof key, Array<TimelineEntry>>;
},
bubble: (params?: Omit<BubbleTimelineParams, keyof PaginationParams>) => {
const key = ['timelines', 'bubble', params] as const;
return key as TaggedKey<typeof key, Array<TimelineEntry>>;
},
antenna: (antennaId: string, params?: Omit<AntennaTimelineParams, keyof PaginationParams>) => {
const key = ['timelines', 'antenna', antennaId, params] as const;
return key as TaggedKey<typeof key, Array<TimelineEntry>>;
},
wrenched: (params?: Omit<WrenchedTimelineParams, keyof PaginationParams>) => {
const key = ['timelines', 'wrenched', params] as const;
return key as TaggedKey<typeof key, Array<TimelineEntry>>;
},
};
const timelineIds = {
root: ['timelineIds'] as const,
accountMedia: (accountId: string) => {
@ -691,7 +640,6 @@ const queryKeys = {
search,
trends,
suggestions,
timelines,
timelineIds,
settings,
interactionPolicies,

View File

@ -1,103 +1,11 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { importEntities } from '@/actions/importer';
import { useTimelineStream } from '@/api/hooks/streaming/use-timeline-stream';
import { useTimeline as useStoreTimeline, useTimelinesActions } from '@/stores/timelines';
import type { DataTag } from '@tanstack/react-query';
import type { PaginatedResponse, Status, StreamingParams } from 'pl-api';
type TimelineEntry =
| {
type: 'status';
id: string;
rebloggedBy: Array<string>;
isConnectedTop?: boolean;
isConnectedBottom?: boolean;
}
| {
type: 'pending-status';
id: string;
}
| {
type: 'gap';
}
| {
type: 'page-start';
maxId?: string;
}
| {
type: 'page-end';
minId?: string;
};
const processPage = ({
items: statuses,
next,
}: PaginatedResponse<Status>): Array<TimelineEntry> => {
const timelinePage: Array<TimelineEntry> = [];
const processStatus = (status: Status): boolean => {
if (timelinePage.some((entry) => entry.type === 'status' && entry.id === status.id))
return false;
let isConnectedTop = false;
const inReplyToId = (status.reblog || status).in_reply_to_id;
if (inReplyToId) {
const foundStatus = statuses.find((s) => (s.reblog || s).id === inReplyToId);
if (foundStatus) {
if (processStatus(foundStatus)) {
const lastEntry = timelinePage.at(-1);
// it's always of type status but doing this to satisfy ts
if (lastEntry?.type === 'status') lastEntry.isConnectedBottom = true;
isConnectedTop = true;
}
}
}
if (status.reblog) {
const existingEntry = timelinePage.find(
(entry) => entry.type === 'status' && entry.id === status.reblog!.id,
);
if (existingEntry?.type === 'status') {
existingEntry.rebloggedBy.push(status.account.id);
} else {
timelinePage.push({
type: 'status',
id: status.reblog.id,
rebloggedBy: [status.account.id],
isConnectedTop,
});
}
return true;
}
timelinePage.push({
type: 'status',
id: status.id,
rebloggedBy: [],
isConnectedTop,
});
return true;
};
for (const status of statuses) {
processStatus(status);
}
if (next)
timelinePage.push({
type: 'page-end',
minId: statuses.at(-1)?.id,
});
return timelinePage;
};
type PaginationParams = { max_id?: string; min_id?: string };
type TimelineFetcher = (params?: PaginationParams) => Promise<PaginatedResponse<Status>>;
@ -106,65 +14,45 @@ interface StreamConfig {
params?: StreamingParams;
}
type TimelineQueryKey = DataTag<readonly unknown[], Array<TimelineEntry>>;
const useTimeline = (
queryKey: TimelineQueryKey,
fetcher: TimelineFetcher,
streamConfig?: StreamConfig,
) => {
const queryClient = useQueryClient();
const useTimeline = (timelineId: string, fetcher: TimelineFetcher, streamConfig?: StreamConfig) => {
const timeline = useStoreTimeline(timelineId);
const timelineActions = useTimelinesActions();
useTimelineStream(streamConfig?.stream ?? '', streamConfig?.params, !!streamConfig?.stream);
const query = useQuery({
queryKey,
queryFn: async () => {
setIsLoading(true);
try {
const response = await fetcher();
importEntities({ statuses: response.items });
return processPage(response);
} finally {
setIsLoading(false);
}
},
});
useEffect(() => {
if (!timeline.isPending) return;
fetchInitial();
}, []);
const [isLoading, setIsLoading] = useState(query.isPending);
const fetchInitial = useCallback(async () => {
timelineActions.setLoading(timelineId, true);
try {
const response = await fetcher();
importEntities({ statuses: response.items });
timelineActions.expandTimeline(timelineId, response.items, !!response.next, true);
} catch (error) {
//
}
}, [timelineId]);
const handleLoadMore = useCallback(
async (entry: TimelineEntry) => {
if (isLoading) return;
if (entry.type !== 'page-end' && entry.type !== 'page-start') return;
const fetchNextPage = useCallback(async () => {
timelineActions.setLoading(timelineId, true);
const lastEntry = timeline.entries.at(-1);
if (!lastEntry || lastEntry.type !== 'page-end') return;
setIsLoading(true);
try {
const response = await fetcher(
entry.type === 'page-end' ? { max_id: entry.minId } : { min_id: entry.maxId },
);
try {
const response = await fetcher({ max_id: lastEntry.minId });
importEntities({ statuses: response.items });
importEntities({ statuses: response.items });
const timelinePage = processPage(response);
timelineActions.expandTimeline(timelineId, response.items, !!response.next, false);
} catch (error) {
//
}
}, [timelineId, timeline.entries]);
queryClient.setQueryData(queryKey, (oldData) => {
if (!oldData) return timelinePage;
const index = oldData.indexOf(entry);
return oldData.toSpliced(index, 1, ...timelinePage);
});
} catch (error) {
//
}
setIsLoading(false);
},
[isLoading, fetcher, queryKey, queryClient],
);
return useMemo(
() => ({ ...query, handleLoadMore, isLoading }),
[query, handleLoadMore, isLoading],
);
return useMemo(() => ({ ...timeline, fetchNextPage }), [timeline, fetchNextPage]);
};
export { useTimeline, type TimelineEntry };
export { useTimeline };

View File

@ -1,7 +1,5 @@
import { useClient } from '@/hooks/use-client';
import { queryKeys } from '../keys';
import { useTimeline } from './use-timeline';
import type {
@ -22,7 +20,7 @@ const useHomeTimeline = (params?: Omit<HomeTimelineParams, keyof PaginationParam
const stream = 'home';
return useTimeline(
queryKeys.timelines.home(params),
'home',
(paginationParams) => client.timelines.homeTimeline({ ...params, ...paginationParams }),
{ stream },
);
@ -33,7 +31,7 @@ const usePublicTimeline = (params?: Omit<PublicTimelineParams, keyof PaginationP
const stream = params?.local ? 'public:local' : params?.instance ? `public:remote` : 'public';
return useTimeline(
queryKeys.timelines.public(params?.local, params),
`public${params?.local ? ':local' : params?.instance ? `:remote:` + params.instance : ''}`,
(paginationParams) => client.timelines.publicTimeline({ ...params, ...paginationParams }),
{ stream },
);
@ -46,7 +44,7 @@ const useHashtagTimeline = (
const client = useClient();
return useTimeline(
queryKeys.timelines.hashtag(hashtag, params),
`hashtag:${hashtag}`,
(paginationParams) =>
client.timelines.hashtagTimeline(hashtag, { ...params, ...paginationParams }),
{ stream: 'hashtag', params: { tag: hashtag } },
@ -59,7 +57,7 @@ const useLinkTimeline = (
) => {
const client = useClient();
return useTimeline(queryKeys.timelines.link(url, params), (paginationParams) =>
return useTimeline(`link:${url}`, (paginationParams) =>
client.timelines.linkTimeline(url, { ...params, ...paginationParams }),
);
};
@ -71,7 +69,7 @@ const useListTimeline = (
const client = useClient();
return useTimeline(
queryKeys.timelines.list(listId, params),
`list:${listId}`,
(paginationParams) => client.timelines.listTimeline(listId, { ...params, ...paginationParams }),
{ stream: 'list', params: { list: listId } },
);
@ -84,7 +82,7 @@ const useGroupTimeline = (
const client = useClient();
return useTimeline(
queryKeys.timelines.group(groupId, params),
`group:${groupId}`,
(paginationParams) =>
client.timelines.groupTimeline(groupId, { ...params, ...paginationParams }),
{ stream: 'group', params: { group: groupId } },
@ -95,7 +93,7 @@ const useBubbleTimeline = (params?: Omit<BubbleTimelineParams, keyof PaginationP
const client = useClient();
return useTimeline(
queryKeys.timelines.bubble(params),
`bubble`,
(paginationParams) => client.timelines.bubbleTimeline({ ...params, ...paginationParams }),
{ stream: 'bubble' },
);
@ -107,7 +105,7 @@ const useAntennaTimeline = (
) => {
const client = useClient();
return useTimeline(queryKeys.timelines.antenna(antennaId, params), (paginationParams) =>
return useTimeline(`antenna:${antennaId}`, (paginationParams) =>
client.timelines.antennaTimeline(antennaId, { ...params, ...paginationParams }),
);
};
@ -115,7 +113,7 @@ const useAntennaTimeline = (
const useWrenchedTimeline = (params?: Omit<WrenchedTimelineParams, keyof PaginationParams>) => {
const client = useClient();
return useTimeline(queryKeys.timelines.wrenched(params), (paginationParams) =>
return useTimeline(`wrenched`, (paginationParams) =>
client.timelines.wrenchedTimeline({ ...params, ...paginationParams }),
);
};

View File

@ -0,0 +1,157 @@
import { create } from 'zustand';
import { mutative } from 'zustand-mutative';
import type { Status } from 'pl-api';
type TimelineEntry =
| {
type: 'status';
id: string;
rebloggedBy: Array<string>;
isConnectedTop?: boolean;
isConnectedBottom?: boolean;
}
| {
type: 'pending-status';
id: string;
}
| {
type: 'gap';
sinceId: string;
maxId: string;
}
| {
type: 'page-start';
maxId?: string;
}
| {
type: 'page-end';
minId?: string;
};
interface TimelineData {
entries: Array<TimelineEntry>;
isFetching: boolean;
isPending: boolean;
}
interface State {
timelines: Record<string, TimelineData>;
actions: {
expandTimeline: (
timelineId: string,
statuses: Array<Status>,
hasMore: boolean,
initialFetch: boolean,
) => void;
setLoading: (timelineId: string, isFetching: boolean) => void;
};
}
const processPage = (statuses: Array<Status>, hasMore: boolean): Array<TimelineEntry> => {
const timelinePage: Array<TimelineEntry> = [];
const processStatus = (status: Status): boolean => {
if (timelinePage.some((entry) => entry.type === 'status' && entry.id === status.id))
return false;
let isConnectedTop = false;
const inReplyToId = (status.reblog || status).in_reply_to_id;
if (inReplyToId) {
const foundStatus = statuses.find((s) => (s.reblog || s).id === inReplyToId);
if (foundStatus) {
if (processStatus(foundStatus)) {
const lastEntry = timelinePage.at(-1);
// it's always of type status but doing this to satisfy ts
if (lastEntry?.type === 'status') lastEntry.isConnectedBottom = true;
isConnectedTop = true;
}
}
}
if (status.reblog) {
const existingEntry = timelinePage.find(
(entry) => entry.type === 'status' && entry.id === status.reblog!.id,
);
if (existingEntry?.type === 'status') {
existingEntry.rebloggedBy.push(status.account.id);
} else {
timelinePage.push({
type: 'status',
id: status.reblog.id,
rebloggedBy: [status.account.id],
isConnectedTop,
});
}
return true;
}
timelinePage.push({
type: 'status',
id: status.id,
rebloggedBy: [],
isConnectedTop,
});
return true;
};
for (const status of statuses) {
processStatus(status);
}
if (hasMore)
timelinePage.push({
type: 'page-end',
minId: statuses.at(-1)?.id,
});
return timelinePage;
};
const useTimelinesStore = create<State>()(
mutative((set) => ({
timelines: {} as Record<string, TimelineData>,
actions: {
expandTimeline: (timelineId, statuses, hasMore, initialFetch) =>
set((state) => {
const timeline = state.timelines[timelineId] ?? createEmptyTimeline();
const entries = processPage(statuses, hasMore);
if (initialFetch) timeline.entries = [];
else if (timeline.entries.at(-1)?.type === 'page-end') timeline.entries.pop();
timeline.entries.push(...entries);
timeline.isPending = false;
timeline.isFetching = false;
state.timelines[timelineId] = timeline;
}),
setLoading: (timelineId, isFetching) =>
set((state) => {
const timeline = state.timelines[timelineId];
if (!timeline) return;
timeline.isFetching = isFetching;
if (!isFetching) timeline.isPending = false;
}),
},
})),
);
const createEmptyTimeline = (): TimelineData => ({
entries: [],
isFetching: false,
isPending: true,
});
const emptyTimeline = createEmptyTimeline();
const useTimelinesActions = () => useTimelinesStore((state) => state.actions);
const useTimeline = (timelineId: string) =>
useTimelinesStore((state) => state.timelines[timelineId] ?? emptyTimeline);
export { useTimelinesStore, useTimelinesActions, useTimeline, type TimelineEntry };