nicolium: Add a timeline picker to timeline column header

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-03-18 12:52:03 +01:00
parent 8fc99331db
commit 96cb8faf2d
17 changed files with 328 additions and 55 deletions

View File

@ -0,0 +1,213 @@
import React, { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useFeatures } from '@/hooks/use-features';
import { useLoggedIn } from '@/hooks/use-logged-in';
import { useAntennas } from '@/queries/accounts/use-antennas';
import { useCircles } from '@/queries/accounts/use-circles';
import { useLists } from '@/queries/accounts/use-lists';
import { useInstance } from '@/stores/instance';
import { useSettings } from '@/stores/settings';
import DropdownMenu, { type Menu } from './dropdown-menu';
import Icon from './ui/icon';
const messages = defineMessages({
homeTimeline: { id: 'column.home', defaultMessage: 'Home' },
localTimeline: { id: 'column.community', defaultMessage: 'Local timeline' },
bubbleTimeline: { id: 'column.bubble', defaultMessage: 'Bubble timeline' },
federatedTimeline: { id: 'column.public', defaultMessage: 'Fediverse timeline' },
wrenchedTimeline: { id: 'column.wrenched', defaultMessage: 'Wrenched timeline' },
lists: { id: 'column.lists', defaultMessage: 'Lists' },
circles: { id: 'column.circles', defaultMessage: 'Circles' },
antennas: { id: 'column.antennas', defaultMessage: 'Antennas' },
pinnedInstances: { id: 'timeline_picker.pinned_instances', defaultMessage: 'Pinned instances' },
});
interface ITimelinePicker {
active:
| 'home'
| 'local'
| 'bubble'
| 'federated'
| 'wrenched'
| `list:${string}`
| `circle:${string}`
| `antenna:${string}`
| `instance:${string}`;
}
const TimelinePicker: React.FC<ITimelinePicker> = ({ active }) => {
const intl = useIntl();
const features = useFeatures();
const { isLoggedIn } = useLoggedIn();
const timelineAccess = useInstance().configuration.timelines_access;
const pinnedHosts = useSettings().remote_timeline.pinnedHosts;
const { data: lists } = useLists();
const { data: circles } = useCircles();
const { data: antennas } = useAntennas();
const heading = useMemo(() => {
switch (active) {
case 'home':
return intl.formatMessage(messages.homeTimeline);
case 'local':
return intl.formatMessage(messages.localTimeline);
case 'bubble':
return intl.formatMessage(messages.bubbleTimeline);
case 'federated':
return intl.formatMessage(messages.federatedTimeline);
case 'wrenched':
return intl.formatMessage(messages.wrenchedTimeline);
default:
if (active.startsWith('list:')) {
const list = lists?.find((list) => `list:${list.id}` === active);
return list?.title ?? '';
}
if (active.startsWith('circle:')) {
const circle = circles?.find((circle) => `circle:${circle.id}` === active);
return circle?.title ?? '';
}
if (active.startsWith('antenna:')) {
const antenna = antennas?.find((antenna) => `antenna:${antenna.id}` === active);
return antenna?.title ?? '';
}
if (active.startsWith('instance:')) {
return active.replace('instance:', '');
}
return '';
}
}, [active]);
const items = useMemo(() => {
const items: Menu = [];
if (isLoggedIn) {
items.push({
to: '/',
text: intl.formatMessage(messages.homeTimeline),
icon: require('@phosphor-icons/core/regular/house.svg'),
active: active === 'home',
});
}
if (
isLoggedIn
? timelineAccess.live_feeds.local !== 'disabled'
: timelineAccess.live_feeds.local === 'public'
) {
items.push({
to: '/timeline/local',
text: intl.formatMessage(messages.localTimeline),
icon: require('@phosphor-icons/core/regular/planet.svg'),
active: active === 'local',
});
}
if (
features.bubbleTimeline && isLoggedIn
? timelineAccess.live_feeds.bubble !== 'disabled'
: timelineAccess.live_feeds.bubble === 'public'
) {
items.push({
to: '/timeline/bubble',
text: intl.formatMessage(messages.bubbleTimeline),
icon: require('@phosphor-icons/core/regular/graph.svg'),
active: active === 'bubble',
});
}
if (
features.bubbleTimeline && isLoggedIn
? timelineAccess.live_feeds.bubble !== 'disabled'
: timelineAccess.live_feeds.bubble === 'public'
) {
items.push({
to: '/timeline/fediverse',
text: intl.formatMessage(messages.federatedTimeline),
icon: require('@phosphor-icons/core/regular/fediverse-logo.svg'),
active: active === 'federated',
});
}
if (
features.wrenchedTimeline && isLoggedIn
? timelineAccess.live_feeds.wrenched !== 'disabled'
: timelineAccess.live_feeds.wrenched === 'public'
) {
items.push({
to: '/timeline/wrenched',
text: intl.formatMessage(messages.wrenchedTimeline),
icon: require('@phosphor-icons/core/regular/wrench.svg'),
active: active === 'wrenched',
});
}
if (lists?.length) {
items.push({
text: intl.formatMessage(messages.lists),
active: active.startsWith('list:'),
icon: require('@phosphor-icons/core/regular/list-dashes.svg'),
items: lists.map((list) => ({
to: '/list/$listId',
params: { listId: list.id },
text: list.title,
icon: require('@phosphor-icons/core/regular/list-dashes.svg'),
active: active === `list:${list.id}`,
})),
});
}
if (circles?.length) {
items.push({
text: intl.formatMessage(messages.circles),
active: active.startsWith('circle:'),
icon: require('@phosphor-icons/core/regular/circles-three.svg'),
items: circles.map((circle) => ({
to: '/circles/$circleId',
params: { circleId: circle.id },
text: circle.title,
icon: require('@phosphor-icons/core/regular/list-dashes.svg'),
active: active === `circle:${circle.id}`,
})),
});
}
if (antennas?.length) {
items.push({
text: intl.formatMessage(messages.antennas),
active: active.startsWith('antenna:'),
icon: require('@phosphor-icons/core/regular/broadcast.svg'),
items: antennas.map((antenna) => ({
to: '/antennas/$antennaId',
params: { antennaId: antenna.id },
text: antenna.title,
icon: require('@phosphor-icons/core/regular/list-dashes.svg'),
active: active === `antenna:${antenna.id}`,
})),
});
}
if (pinnedHosts.length) {
items.push({
text: intl.formatMessage(messages.pinnedInstances),
active: active.startsWith('instance:'),
icon: require('@phosphor-icons/core/regular/globe-simple.svg'),
items: pinnedHosts.map((instance) => ({
to: '/timeline/$instance',
params: { instance },
text: instance,
icon: require('@phosphor-icons/core/regular/globe-simple.svg'),
active: active === `instance:${instance}`,
})),
});
}
return items;
}, [active, lists, circles, antennas, features, isLoggedIn]);
return (
<DropdownMenu items={items} width='16rem' placement='bottom-start'>
<div className='⁂-timeline-picker'>
{heading}
<Icon src={require('@phosphor-icons/core/regular/caret-down.svg')} aria-hidden />
</div>
</DropdownMenu>
);
};
export { TimelinePicker };

View File

@ -8,17 +8,28 @@ import { useFrontendConfig } from '@/hooks/use-frontend-config';
import { Card, CardBody, CardHeader, CardTitle, type CardSizes } from './card';
interface IColumnHeader extends Pick<IColumn, 'backHref' | 'backParams' | 'className' | 'action'> {
label?: React.ReactNode;
}
type IColumnHeader = Pick<
IColumn,
| 'label'
| 'title'
| 'withBack'
| 'backHref'
| 'backParams'
| 'className'
| 'action'
| 'truncateTitle'
>;
/** Contains the column title with optional back button. */
const ColumnHeader: React.FC<IColumnHeader> = ({
label,
title,
withBack,
backHref,
backParams,
className,
action,
truncateTitle,
}) => {
const navigate = useNavigate();
const { history } = useRouter();
@ -37,8 +48,8 @@ const ColumnHeader: React.FC<IColumnHeader> = ({
};
return (
<CardHeader className={className} onBackClick={handleBackClick}>
<CardTitle title={label} />
<CardHeader className={className} onBackClick={withBack ? handleBackClick : undefined}>
<CardTitle title={title || label} truncate={truncateTitle} />
{action && <div className='⁂-column__header__action'>{action}</div>}
</CardHeader>
@ -47,11 +58,13 @@ const ColumnHeader: React.FC<IColumnHeader> = ({
interface IColumn {
/** Route the back button goes to. */
withBack?: boolean;
backHref?: LinkOptions['to'];
backParams?: LinkOptions['params'];
backSearch?: LinkOptions['search'];
/** Column title text. */
label?: string;
title?: React.ReactNode;
/** Whether this column should have a transparent background. */
transparent?: boolean;
/** Whether this column should have a title and back button. */
@ -66,20 +79,24 @@ interface IColumn {
action?: React.ReactNode;
/** Column size, inherited from Card. */
size?: CardSizes;
truncateTitle?: boolean;
}
/** A backdrop for the main section of the UI. */
const Column: React.FC<IColumn> = (props): React.JSX.Element => {
const {
withBack = true,
backHref,
children,
label,
title,
transparent = false,
withHeader = true,
className,
bodyClassName,
action,
size,
truncateTitle,
} = props;
const frontendConfig = useFrontendConfig();
const [isScrolled, setIsScrolled] = useState(false);
@ -120,11 +137,14 @@ const Column: React.FC<IColumn> = (props): React.JSX.Element => {
{withHeader && (
<ColumnHeader
label={label}
title={title}
withBack={withBack}
backHref={backHref}
className={clsx('⁂-column__header', {
'⁂-column__header--scrolled': isScrolled,
})}
action={action}
truncateTitle={truncateTitle}
/>
)}

View File

@ -1,34 +0,0 @@
import React from 'react';
import Button from '@/components/ui/button';
import { useSettings } from '@/stores/settings';
interface IPinnedHostsPicker {
/** The active host among pinned hosts. */
host?: string;
}
const PinnedHostsPicker: React.FC<IPinnedHostsPicker> = ({ host: activeHost }) => {
const settings = useSettings();
const pinnedHosts = settings.remote_timeline.pinnedHosts;
if (!pinnedHosts.length) return null;
return (
<div className='mb-4 flex gap-2 black:mx-2'>
{pinnedHosts.map((host) => (
<Button
key={host}
to='/timeline/$instance'
params={{ instance: host }}
size='sm'
theme={host === activeHost ? 'accent' : 'secondary'}
>
{host}
</Button>
))}
</div>
);
};
export { PinnedHostsPicker as default };

View File

@ -2024,6 +2024,7 @@
"timeline.gap.load_newer": "Load newer posts",
"timeline.gap.load_older": "Load older posts",
"timeline.gap.load_recent": "Load recent posts",
"timeline_picker.pinned_instances": "Pinned instances",
"toast.view": "View",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
"trends.no_accounts": "Try entering a search query or browsing the profile directory to find accounts to follow.",

View File

@ -5,6 +5,7 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { AntennaTimelineColumn } from '@/columns/timeline';
import DropdownMenu from '@/components/dropdown-menu';
import MissingIndicator from '@/components/missing-indicator';
import { TimelinePicker } from '@/components/timeline-picker';
// import Button from '@/components/ui/button';
import Column from '@/components/ui/column';
import Spinner from '@/components/ui/spinner';
@ -90,6 +91,8 @@ const AntennaTimelinePage: React.FC = () => {
src={require('@phosphor-icons/core/regular/dots-three-vertical.svg')}
/>
}
title={<TimelinePicker active={`antenna:${antennaId}`} />}
truncateTitle={false}
>
<AntennaTimelineColumn
antennaId={antennaId}

View File

@ -2,6 +2,7 @@ import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { BubbleTimelineColumn } from '@/columns/timeline';
import { TimelinePicker } from '@/components/timeline-picker';
import Column from '@/components/ui/column';
const messages = defineMessages({
@ -12,7 +13,12 @@ const BubbleTimelinePage = () => {
const intl = useIntl();
return (
<Column className='-mt-3 sm:mt-0' label={intl.formatMessage(messages.title)}>
<Column
className='-mt-3 sm:mt-0'
label={intl.formatMessage(messages.title)}
title={<TimelinePicker active='bubble' />}
truncateTitle={false}
>
<BubbleTimelineColumn
emptyMessageText={
<FormattedMessage

View File

@ -5,6 +5,7 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { CircleTimelineColumn } from '@/columns/timeline';
import DropdownMenu from '@/components/dropdown-menu';
import MissingIndicator from '@/components/missing-indicator';
import { TimelinePicker } from '@/components/timeline-picker';
import Button from '@/components/ui/button';
import Column from '@/components/ui/column';
import Spinner from '@/components/ui/spinner';
@ -90,6 +91,8 @@ const CircleTimelinePage: React.FC = () => {
src={require('@phosphor-icons/core/regular/dots-three-vertical.svg')}
/>
}
title={<TimelinePicker active={`cirlce:${circleId}`} />}
truncateTitle={false}
>
<CircleTimelineColumn
circleId={circleId}

View File

@ -2,6 +2,7 @@ import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { PublicTimelineColumn } from '@/columns/timeline';
import { TimelinePicker } from '@/components/timeline-picker';
import Column from '@/components/ui/column';
const messages = defineMessages({
@ -12,7 +13,12 @@ const CommunityTimelinePage = () => {
const intl = useIntl();
return (
<Column className='-mt-3 sm:mt-0' label={intl.formatMessage(messages.title)}>
<Column
className='-mt-3 sm:mt-0'
label={intl.formatMessage(messages.title)}
title={<TimelinePicker active='local' />}
truncateTitle={false}
>
<PublicTimelineColumn
local
emptyMessageText={

View File

@ -3,6 +3,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { HomeTimelineColumn } from '@/columns/timeline';
import { Link } from '@/components/link';
import { TimelinePicker } from '@/components/timeline-picker';
import Column from '@/components/ui/column';
import Text from '@/components/ui/text';
import { useFeatures } from '@/hooks/use-features';
@ -46,7 +47,13 @@ const HomeTimelinePage: React.FC = () => {
if (isSledzikRemoved) return null;
return (
<Column className='py-0' label={intl.formatMessage(messages.title)} withHeader={false}>
<Column
className='py-0'
label={intl.formatMessage(messages.title)}
title={<TimelinePicker active='home' />}
withBack={false}
truncateTitle={false}
>
<HomeTimelineColumn
emptyMessageText={
<div className='flex flex-col gap-1'>

View File

@ -5,6 +5,7 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { ListTimelineColumn } from '@/columns/timeline';
import DropdownMenu from '@/components/dropdown-menu';
import { EmptyMessage } from '@/components/empty-message';
import { TimelinePicker } from '@/components/timeline-picker';
import Button from '@/components/ui/button';
import Column from '@/components/ui/column';
import Spinner from '@/components/ui/spinner';
@ -67,7 +68,11 @@ const ListTimelinePage: React.FC = () => {
);
} else if (!list) {
return (
<Column label={intl.formatMessage(messages.notFound)}>
<Column
label={intl.formatMessage(messages.notFound)}
title={<TimelinePicker active={`list:${listId}`} />}
truncateTitle={false}
>
<EmptyMessage
heading={<FormattedMessage id='list.not_found_heading' defaultMessage='List not found' />}
text={

View File

@ -4,9 +4,9 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { changeSetting } from '@/actions/settings';
import { PublicTimelineColumn } from '@/columns/timeline';
import { TimelinePicker } from '@/components/timeline-picker';
import Accordion from '@/components/ui/accordion';
import Column from '@/components/ui/column';
import PinnedHostsPicker from '@/features/remote-timeline/components/pinned-hosts-picker';
import { useInstance } from '@/stores/instance';
import { useSettings } from '@/stores/settings';
@ -33,9 +33,12 @@ const PublicTimelinePage = () => {
};
return (
<Column className='-mt-3 sm:mt-0' label={intl.formatMessage(messages.title)}>
<PinnedHostsPicker />
<Column
className='-mt-3 sm:mt-0'
label={intl.formatMessage(messages.title)}
title={<TimelinePicker active='federated' />}
truncateTitle={false}
>
{showExplanationBox && (
<Accordion
headline={

View File

@ -3,10 +3,10 @@ import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { PublicTimelineColumn } from '@/columns/timeline';
import { TimelinePicker } from '@/components/timeline-picker';
import Column from '@/components/ui/column';
import IconButton from '@/components/ui/icon-button';
import Text from '@/components/ui/text';
import PinnedHostsPicker from '@/features/remote-timeline/components/pinned-hosts-picker';
import { remoteTimelineRoute } from '@/features/ui/router';
import { useSettings } from '@/stores/settings';
@ -30,9 +30,11 @@ const RemoteTimelinePage: React.FC = () => {
};
return (
<Column label={instance}>
{instance && <PinnedHostsPicker host={instance} />}
<Column
label={instance}
title={<TimelinePicker active={`instance:${instance}`} />}
truncateTitle={false}
>
{!pinned && (
<div className='mb-4 flex gap-2 px-2'>
<IconButton

View File

@ -2,6 +2,7 @@ import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { WrenchedTimelineColumn } from '@/columns/timeline';
import { TimelinePicker } from '@/components/timeline-picker';
import Column from '@/components/ui/column';
const messages = defineMessages({
@ -12,7 +13,12 @@ const WrenchedTimelinePage = () => {
const intl = useIntl();
return (
<Column className='-mt-3 sm:mt-0' label={intl.formatMessage(messages.title)}>
<Column
className='-mt-3 sm:mt-0'
label={intl.formatMessage(messages.title)}
title={<TimelinePicker active='wrenched' />}
truncateTitle={false}
>
<WrenchedTimelineColumn
emptyMessageText={
<FormattedMessage

View File

@ -2,6 +2,7 @@ import { useMutation, useQuery, type UseQueryResult } from '@tanstack/react-quer
import { useClient } from '@/hooks/use-client';
import { useFeatures } from '@/hooks/use-features';
import { useLoggedIn } from '@/hooks/use-logged-in';
import { queryKeys } from '@/queries/keys';
import { queryClient } from '../client';
@ -16,11 +17,12 @@ function useAntennas(): UseQueryResult<Array<Antenna>, Error>;
function useAntennas<T = Array<Antenna>>(select?: (data: Array<Antenna>) => T) {
const client = useClient();
const features = useFeatures();
const { isLoggedIn } = useLoggedIn();
return useQuery({
queryKey: queryKeys.antennas.all,
queryFn: () => client.antennas.fetchAntennas(),
enabled: features.antennas,
enabled: isLoggedIn && features.antennas,
select,
});
}

View File

@ -2,6 +2,7 @@ import { type UseQueryResult, useMutation, useQuery } from '@tanstack/react-quer
import { useClient } from '@/hooks/use-client';
import { useFeatures } from '@/hooks/use-features';
import { useLoggedIn } from '@/hooks/use-logged-in';
import { queryKeys } from '@/queries/keys';
import { queryClient } from '../client';
@ -16,11 +17,12 @@ function useCircles(): UseQueryResult<Array<Circle>, Error>;
function useCircles<T = Array<Circle>>(select?: (data: Array<Circle>) => T) {
const client = useClient();
const features = useFeatures();
const { isLoggedIn } = useLoggedIn();
return useQuery({
queryKey: queryKeys.circles.all,
queryFn: () => client.circles.fetchCircles(),
enabled: features.circles,
enabled: isLoggedIn && features.circles,
select,
});
}

View File

@ -2,6 +2,7 @@ import { useMutation, useQuery, type UseQueryResult } from '@tanstack/react-quer
import { useClient } from '@/hooks/use-client';
import { useFeatures } from '@/hooks/use-features';
import { useLoggedIn } from '@/hooks/use-logged-in';
import { queryKeys } from '@/queries/keys';
import { queryClient } from '../client';
@ -16,11 +17,12 @@ function useLists(): UseQueryResult<Array<List>, Error>;
function useLists<T = Array<List>>(select?: (data: Array<List>) => T) {
const client = useClient();
const features = useFeatures();
const { isLoggedIn } = useLoggedIn();
return useQuery({
queryKey: queryKeys.lists.all,
queryFn: () => client.lists.getLists(),
enabled: features.lists,
enabled: isLoggedIn && features.lists,
select,
});
}

View File

@ -158,3 +158,29 @@
.-load-more {
@include mixins.button($theme: primary, $block: true);
}
.-timeline-picker {
cursor: pointer;
display: flex;
gap: 0.5rem;
align-items: center;
margin: -0.25rem -0.5rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: background 150ms ease-in-out;
&:hover, &:focus-within, &[aria-expanded="true"] {
background: rgb(var(--color-primary-100));
.dark & {
background: rgb(var(--color-primary-800));
}
.dark.black & {
background: rgb(var(--color-gray-900));
}
}
}