From 1deb2f9ba152ee453e94e8ca935be3ed3d4f157d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 4 Mar 2026 23:52:48 +0100 Subject: [PATCH] nicolium: move new timelines state to zustand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- packages/nicolium/src/columns/timeline.tsx | 13 +- packages/nicolium/src/queries/keys.ts | 52 ----- .../src/queries/timelines/use-timeline.ts | 178 ++++-------------- .../src/queries/timelines/use-timelines.ts | 20 +- packages/nicolium/src/stores/timelines.ts | 157 +++++++++++++++ 5 files changed, 205 insertions(+), 215 deletions(-) create mode 100644 packages/nicolium/src/stores/timelines.ts diff --git a/packages/nicolium/src/columns/timeline.tsx b/packages/nicolium/src/columns/timeline.tsx index 2dfcd6c3a..df13c14e6 100644 --- a/packages/nicolium/src/columns/timeline.tsx +++ b/packages/nicolium/src/columns/timeline.tsx @@ -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 = ({ 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 = ({ 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 (
- handleLoadMore(entry)} disabled={isLoading} /> +
); } @@ -119,8 +118,8 @@ const Timeline: React.FC = ({ query, contextType = 'public' }) => { } placeholderCount={20} // className={className} @@ -132,7 +131,7 @@ const Timeline: React.FC = ({ query, contextType = 'public' }) => { // })} // {...other} > - {(data || []).map(renderEntry)} + {(entries || []).map(renderEntry)} ); }; diff --git a/packages/nicolium/src/queries/keys.ts b/packages/nicolium/src/queries/keys.ts index c63aff3a8..b8526882a 100644 --- a/packages/nicolium/src/queries/keys.ts +++ b/packages/nicolium/src/queries/keys.ts @@ -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 = DataTag; @@ -429,46 +418,6 @@ const suggestions = { all: ['suggestions'] as TaggedKey<['suggestions'], Array>, }; -const timelines = { - root: ['timelines'] as const, - home: (params?: Omit) => { - const key = ['timelines', 'home', params] as const; - return key as TaggedKey>; - }, - public: (local?: boolean, params?: Omit) => { - const key = ['timelines', 'public', { local, ...params }] as const; - return key as TaggedKey>; - }, - hashtag: (hashtag: string, params?: Omit) => { - const key = ['timelines', 'hashtag', hashtag, params] as const; - return key as TaggedKey>; - }, - link: (url: string, params?: Omit) => { - const key = ['timelines', 'link', url, params] as const; - return key as TaggedKey>; - }, - list: (listId: string, params?: Omit) => { - const key = ['timelines', 'list', listId, params] as const; - return key as TaggedKey>; - }, - group: (groupId: string, params?: Omit) => { - const key = ['timelines', 'group', groupId, params] as const; - return key as TaggedKey>; - }, - bubble: (params?: Omit) => { - const key = ['timelines', 'bubble', params] as const; - return key as TaggedKey>; - }, - antenna: (antennaId: string, params?: Omit) => { - const key = ['timelines', 'antenna', antennaId, params] as const; - return key as TaggedKey>; - }, - wrenched: (params?: Omit) => { - const key = ['timelines', 'wrenched', params] as const; - return key as TaggedKey>; - }, -}; - const timelineIds = { root: ['timelineIds'] as const, accountMedia: (accountId: string) => { @@ -691,7 +640,6 @@ const queryKeys = { search, trends, suggestions, - timelines, timelineIds, settings, interactionPolicies, diff --git a/packages/nicolium/src/queries/timelines/use-timeline.ts b/packages/nicolium/src/queries/timelines/use-timeline.ts index 69a08cdb6..8854a7544 100644 --- a/packages/nicolium/src/queries/timelines/use-timeline.ts +++ b/packages/nicolium/src/queries/timelines/use-timeline.ts @@ -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; - 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): Array => { - const timelinePage: Array = []; - - 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>; @@ -106,65 +14,45 @@ interface StreamConfig { params?: StreamingParams; } -type TimelineQueryKey = DataTag>; - -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 }; diff --git a/packages/nicolium/src/queries/timelines/use-timelines.ts b/packages/nicolium/src/queries/timelines/use-timelines.ts index d20df8a87..ce28451d5 100644 --- a/packages/nicolium/src/queries/timelines/use-timelines.ts +++ b/packages/nicolium/src/queries/timelines/use-timelines.ts @@ -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 client.timelines.homeTimeline({ ...params, ...paginationParams }), { stream }, ); @@ -33,7 +31,7 @@ const usePublicTimeline = (params?: Omit 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 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) => { const client = useClient(); - return useTimeline(queryKeys.timelines.wrenched(params), (paginationParams) => + return useTimeline(`wrenched`, (paginationParams) => client.timelines.wrenchedTimeline({ ...params, ...paginationParams }), ); }; diff --git a/packages/nicolium/src/stores/timelines.ts b/packages/nicolium/src/stores/timelines.ts new file mode 100644 index 000000000..eb06bcecf --- /dev/null +++ b/packages/nicolium/src/stores/timelines.ts @@ -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; + 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; + isFetching: boolean; + isPending: boolean; +} + +interface State { + timelines: Record; + actions: { + expandTimeline: ( + timelineId: string, + statuses: Array, + hasMore: boolean, + initialFetch: boolean, + ) => void; + setLoading: (timelineId: string, isFetching: boolean) => void; + }; +} + +const processPage = (statuses: Array, hasMore: boolean): Array => { + const timelinePage: Array = []; + + 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()( + mutative((set) => ({ + timelines: {} as Record, + 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 };