nicolium: experimental timelines: ux improvements

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-03-05 14:30:12 +01:00
parent 6c037c1a1c
commit 420f42d16d
4 changed files with 45 additions and 42 deletions

View File

@ -3,7 +3,6 @@ import clsx from 'clsx';
import React, { useRef } from 'react';
import { defineMessages, FormattedList, FormattedMessage } from 'react-intl';
import LoadMore from '@/components/load-more';
import ScrollTopButton from '@/components/scroll-top-button';
import ScrollableList from '@/components/scrollable-list';
import Status, { StatusFollowedTagInfo } from '@/components/statuses/status';
@ -177,7 +176,6 @@ const TimelineStatus: React.FC<ITimelineStatus> = (props): React.JSX.Element =>
<div
className={clsx('⁂-timeline-status relative', {
'⁂-timeline-status--connected-bottom': isConnectedBottom,
'border-b border-solid border-gray-200 dark:border-gray-800': !isConnectedBottom,
'⁂-timeline-status--connected-top': isConnectedTop,
})}
>
@ -206,8 +204,16 @@ interface ITimeline {
const Timeline: React.FC<ITimeline> = ({ query, contextType = 'public' }) => {
const node = useRef<VirtuosoHandle | null>(null);
const { timelineId, entries, queuedCount, fetchNextPage, dequeueEntries, isFetching, isPending } =
query;
const {
timelineId,
entries,
queuedCount,
fetchNextPage,
dequeueEntries,
isFetching,
isPending,
hasNextPage,
} = query;
const handleMoveUp = (index: number) =>
selectChild(index - 1, node, document.getElementById('status-list') ?? undefined);
@ -239,13 +245,6 @@ const Timeline: React.FC<ITimeline> = ({ query, contextType = 'public' }) => {
/>
);
}
if ((entry.type === 'page-end' || entry.type === 'page-start') && !isFetching) {
return (
<div className='m-4'>
<LoadMore key='load-more' onClick={fetchNextPage} />
</div>
);
}
};
return (
@ -266,7 +265,8 @@ const Timeline: React.FC<ITimeline> = ({ query, contextType = 'public' }) => {
placeholderComponent={() => <PlaceholderTimelineStatus />}
placeholderCount={20}
ref={node}
hasMore
hasMore={hasNextPage}
onLoadMore={fetchNextPage}
>
{(entries || []).map(renderEntry)}
</ScrollableList>

View File

@ -38,11 +38,9 @@ const useTimeline = (timelineId: string, fetcher: TimelineFetcher, streamConfig?
const fetchNextPage = useCallback(async () => {
timelineActions.setLoading(timelineId, true);
const lastEntry = timeline.entries.at(-1);
if (!lastEntry || lastEntry.type !== 'page-end') return;
try {
const response = await fetcher({ max_id: lastEntry.minId });
const response = await fetcher({ max_id: timeline.oldestStatusId });
importEntities({ statuses: response.items });
@ -50,7 +48,7 @@ const useTimeline = (timelineId: string, fetcher: TimelineFetcher, streamConfig?
} catch (error) {
//
}
}, [timelineId, timeline.entries]);
}, [timelineId, timeline.oldestStatusId]);
const dequeueEntries = useCallback(() => {
timelineActions.dequeueEntries(timelineId);

View File

@ -19,14 +19,6 @@ type TimelineEntry =
type: 'gap';
sinceId: string;
maxId: string;
}
| {
type: 'page-start';
maxId?: string;
}
| {
type: 'page-end';
minId?: string;
};
interface TimelineData {
@ -35,6 +27,8 @@ interface TimelineData {
queuedCount: number;
isFetching: boolean;
isPending: boolean;
hasNextPage: boolean;
oldestStatusId?: string;
}
interface State {
@ -43,8 +37,8 @@ interface State {
expandTimeline: (
timelineId: string,
statuses: Array<Status>,
hasMore: boolean,
initialFetch: boolean,
hasMore?: boolean,
initialFetch?: boolean,
) => void;
receiveStreamingStatus: (timelineId: string, status: Status) => void;
deleteStatus: (statusId: string) => void;
@ -53,7 +47,7 @@ interface State {
};
}
const processPage = (statuses: Array<Status>, hasMore: boolean): Array<TimelineEntry> => {
const processPage = (statuses: Array<Status>): Array<TimelineEntry> => {
const timelinePage: Array<TimelineEntry> = [];
const processStatus = (status: Status): boolean => {
@ -111,12 +105,6 @@ const processPage = (statuses: Array<Status>, hasMore: boolean): Array<TimelineE
processStatus(status);
}
if (hasMore)
timelinePage.push({
type: 'page-end',
minId: statuses.at(-1)?.id,
});
return timelinePage;
};
@ -124,16 +112,20 @@ const useTimelinesStore = create<State>()(
mutative((set) => ({
timelines: {} as Record<string, TimelineData>,
actions: {
expandTimeline: (timelineId, statuses, hasMore, initialFetch) =>
expandTimeline: (timelineId, statuses, hasMore, initialFetch = false) =>
set((state) => {
const timeline = state.timelines[timelineId] ?? createEmptyTimeline();
const entries = processPage(statuses, hasMore);
const entries = processPage(statuses);
if (initialFetch) timeline.entries = [];
else if (timeline.entries.at(-1)?.type === 'page-end') timeline.entries.pop();
timeline.entries.push(...entries);
if (initialFetch) timeline.entries = entries;
else timeline.entries.push(...entries);
timeline.isPending = false;
timeline.isFetching = false;
if (typeof hasMore === 'boolean') {
timeline.hasNextPage = hasMore;
const oldestStatus = statuses.at(-1);
if (oldestStatus) timeline.oldestStatusId = oldestStatus.id;
}
state.timelines[timelineId] = timeline;
}),
receiveStreamingStatus: (timelineId, status) => {
@ -169,10 +161,11 @@ const useTimelinesStore = create<State>()(
},
setLoading: (timelineId, isFetching) =>
set((state) => {
const timeline = (state.timelines[timelineId] ??= createEmptyTimeline());
const timeline = state.timelines[timelineId] ?? createEmptyTimeline();
timeline.isFetching = isFetching;
if (!isFetching) timeline.isPending = false;
state.timelines[timelineId] = timeline;
}),
dequeueEntries: (timelineId) =>
set((state) => {
@ -180,7 +173,7 @@ const useTimelinesStore = create<State>()(
if (!timeline || timeline.queuedEntries.length === 0) return;
const processedEntries = processPage(timeline.queuedEntries, false);
const processedEntries = processPage(timeline.queuedEntries);
timeline.entries.unshift(...processedEntries);
timeline.queuedEntries = [];
@ -196,6 +189,8 @@ const createEmptyTimeline = (): TimelineData => ({
queuedCount: 0,
isFetching: false,
isPending: true,
hasNextPage: true,
oldestStatusId: undefined,
});
const emptyTimeline = createEmptyTimeline();

View File

@ -307,6 +307,16 @@
}
}
.-timeline-status--connected-bottom .status__content-wrapper {
padding-left: 54px;
.-timeline-status {
&--connected-bottom .status__content-wrapper {
padding-left: 54px;
}
&:not(.-timeline-status--connected-bottom) {
@apply border-b border-solid border-gray-200 dark:border-gray-800;
}
}
div:last-child > .-timeline-status {
@apply border-b-0;
}