nicolium: experimental timeline: hotkey navigation, queue

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-03-05 10:46:29 +01:00
parent 1deb2f9ba1
commit d4a92abf9d
7 changed files with 131 additions and 30 deletions

View File

@ -1,10 +1,13 @@
import clsx from 'clsx';
import React from 'react';
import React, { useRef } from 'react';
import { defineMessages } from 'react-intl';
import LoadMore from '@/components/load-more';
import ScrollTopButton from '@/components/scroll-top-button';
import ScrollableList from '@/components/scrollable-list';
import Status from '@/components/statuses/status';
import Tombstone from '@/components/statuses/tombstone';
import Portal from '@/components/ui/portal';
import PlaceholderStatus from '@/features/placeholder/components/placeholder-status';
import { useStatus } from '@/queries/statuses/use-status';
import {
@ -18,9 +21,22 @@ import {
usePublicTimeline,
useWrenchedTimeline,
} from '@/queries/timelines/use-timelines';
import { selectChild } from '@/utils/scroll-utils';
import type { FilterContextType } from '@/queries/settings/use-filters';
import type { TimelineEntry } from '@/stores/timelines';
import type { VirtuosoHandle } from 'react-virtuoso';
const messages = defineMessages({
queue: {
id: 'status_list.queue_label',
defaultMessage: 'Click to see {count} new {count, plural, one {post} other {posts}}',
},
queueLiveRegion: {
id: 'status_list.queue_label.live_region',
defaultMessage: '{count} new {count, plural, one {post} other {posts}}.',
},
});
interface ITimelineStatus {
id: string;
@ -86,9 +102,24 @@ interface ITimeline {
}
const Timeline: React.FC<ITimeline> = ({ query, contextType = 'public' }) => {
const { entries, fetchNextPage, isFetching, isPending } = query;
const node = useRef<VirtuosoHandle | null>(null);
const renderEntry = (entry: TimelineEntry) => {
const { entries, queuedCount, fetchNextPage, dequeueEntries, isFetching, isPending } = query;
const handleMoveUp = (index: number) => {
selectChild(index - 1, node, document.getElementById('status-list') ?? undefined);
};
const handleMoveDown = (index: number) => {
selectChild(
index + 1,
node,
document.getElementById('status-list') ?? undefined,
entries.length,
);
};
const renderEntry = (entry: TimelineEntry, index: number) => {
if (entry.type === 'status') {
return (
<TimelineStatus
@ -97,8 +128,8 @@ const Timeline: React.FC<ITimeline> = ({ query, contextType = 'public' }) => {
isConnectedTop={entry.isConnectedTop}
isConnectedBottom={entry.isConnectedBottom}
contextType={contextType}
// onMoveUp={handleMoveUp}
// onMoveDown={handleMoveDown}
onMoveUp={() => handleMoveUp(index)}
onMoveDown={() => handleMoveDown(index)}
// contextType={timelineId}
// showGroup={showGroup}
// variant={divideType === 'border' ? 'slim' : 'rounded'}
@ -115,24 +146,35 @@ const Timeline: React.FC<ITimeline> = ({ query, contextType = 'public' }) => {
};
return (
<ScrollableList
id='status-list'
key='scrollable-list'
isLoading={isFetching}
showLoading={isPending}
placeholderComponent={() => <PlaceholderStatus variant={'slim'} />}
placeholderCount={20}
// className={className}
// listClassName={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', {
// 'divide-none': divideType !== 'border',
// })}
// itemClassName={clsx({
// 'pb-3': divideType !== 'border',
// })}
// {...other}
>
{(entries || []).map(renderEntry)}
</ScrollableList>
<>
<Portal>
<ScrollTopButton
onClick={dequeueEntries}
count={queuedCount}
message={messages.queue}
liveRegionMessage={messages.queueLiveRegion}
/>
</Portal>
<ScrollableList
id='status-list'
key='scrollable-list'
isLoading={isFetching}
showLoading={isPending}
placeholderComponent={() => <PlaceholderStatus variant={'slim'} />}
placeholderCount={20}
ref={node}
// className={className}
// listClassName={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', {
// 'divide-none': divideType !== 'border',
// })}
// itemClassName={clsx({
// 'pb-3': divideType !== 'border',
// })}
// {...other}
>
{(entries || []).map(renderEntry)}
</ScrollableList>
</>
);
};

View File

@ -167,7 +167,7 @@ const hotkeyMatcherMap = {
type HotkeyName = keyof typeof hotkeyMatcherMap;
type HandlerMap = Partial<Record<HotkeyName, (event: KeyboardEvent) => void>>;
type HandlerMap = Partial<Record<HotkeyName, (event: KeyboardEvent) => void | boolean>>;
function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
const ref = useRef<T>(null);

View File

@ -68,9 +68,8 @@ const CirclePage: React.FC = () => {
progress: number;
}>({ state: 'unrequested', progress: 0 });
const [expanded, setExpanded] = useState(false);
const [users, setUsers] = useState<
Array<{ id: string; avatar?: string; avatar_description?: string; acct: string }>
>();
const [users, setUsers] =
useState<Array<{ id: string; avatar?: string; avatar_description?: string; acct: string }>>();
const intl = useIntl();
const dispatch = useAppDispatch();

View File

@ -52,7 +52,14 @@ const useTimeline = (timelineId: string, fetcher: TimelineFetcher, streamConfig?
}
}, [timelineId, timeline.entries]);
return useMemo(() => ({ ...timeline, fetchNextPage }), [timeline, fetchNextPage]);
const dequeueEntries = useCallback(() => {
timelineActions.dequeueEntries(timelineId);
}, [timelineId]);
return useMemo(
() => ({ ...timeline, fetchNextPage, dequeueEntries }),
[timeline, fetchNextPage, dequeueEntries],
);
};
export { useTimeline };

View File

@ -31,6 +31,8 @@ type TimelineEntry =
interface TimelineData {
entries: Array<TimelineEntry>;
queuedEntries: Array<TimelineEntry>;
queuedCount: number;
isFetching: boolean;
isPending: boolean;
}
@ -45,6 +47,7 @@ interface State {
initialFetch: boolean,
) => void;
setLoading: (timelineId: string, isFetching: boolean) => void;
dequeueEntries: (timelineId: string) => void;
};
}
@ -137,12 +140,24 @@ const useTimelinesStore = create<State>()(
timeline.isFetching = isFetching;
if (!isFetching) timeline.isPending = false;
}),
dequeueEntries: (timelineId) =>
set((state) => {
const timeline = state.timelines[timelineId];
if (!timeline || timeline.queuedEntries.length === 0) return;
timeline.entries.unshift(...timeline.queuedEntries);
timeline.queuedEntries = [];
timeline.queuedCount = 0;
}),
},
})),
);
const createEmptyTimeline = (): TimelineData => ({
entries: [],
queuedEntries: [],
queuedCount: 0,
isFetching: false,
isPending: true,
});

View File

@ -1,4 +1,42 @@
export { default as PlApiClient } from '@/client';
export {
default as PlApiClient,
accounts as accountsCategory,
admin as adminCategory,
announcements as announcementsCategory,
antennas as antennasCategory,
apps as appsCategory,
asyncRefreshes as asyncRefreshesCategory,
chats as chatsCategory,
circles as circlesCategory,
drive as driveCategory,
emails as emailsCategory,
events as eventsCategory,
experimental as experimentalCategory,
filtering as filteringCategory,
groupedNotifications as groupedNotificationsCategory,
instance as instanceCategory,
interactionRequests as interactionRequestsCategory,
lists as listsCategory,
media as mediaCategory,
myAccount as myAccountCategory,
notifications as notificationsCategory,
oauth as oauthCategory,
oembed as oembedCategory,
polls as pollsCategory,
pushNotifications as pushNotificationsCategory,
rssFeedSubscriptions as rssFeedSubscriptionsCategory,
scheduledStatuses as scheduledStatusesCategory,
search as searchCategory,
settings as settingsCategory,
shoutbox as shoutboxCategory,
statuses as statusesCategory,
stories as storiesCategory,
streaming as streamingCategory,
subscriptions as subscriptionsCategory,
timelines as timelinesCategory,
trends as trendsCategory,
utils as utilsCategory,
} from '@/client';
export { PlApiBaseClient } from '@/client-base';
export { PlApiDirectoryClient } from '@/directory-client';
export { type Response as PlApiResponse, type AsyncRefreshHeader } from '@/request';

View File

@ -1,6 +1,6 @@
{
"name": "pl-api",
"version": "1.0.0-rc.98",
"version": "1.0.0-rc.99",
"homepage": "https://codeberg.org/mkljczk/nicolium/src/branch/develop/packages/pl-api",
"bugs": {
"url": "https://codeberg.org/mkljczk/nicolium/issues"