From 96cb8faf2dedbcd9f14023d6ef5390798fd985a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Wed, 18 Mar 2026 12:52:03 +0100 Subject: [PATCH] nicolium: Add a timeline picker to timeline column header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- .../src/components/timeline-picker.tsx | 213 ++++++++++++++++++ .../nicolium/src/components/ui/column.tsx | 30 ++- .../components/pinned-hosts-picker.tsx | 34 --- packages/nicolium/src/locales/en.json | 1 + .../src/pages/timelines/antenna-timeline.tsx | 3 + .../src/pages/timelines/bubble-timeline.tsx | 8 +- .../src/pages/timelines/circle-timeline.tsx | 3 + .../pages/timelines/community-timeline.tsx | 8 +- .../src/pages/timelines/home-timeline.tsx | 9 +- .../src/pages/timelines/list-timeline.tsx | 7 +- .../src/pages/timelines/public-timeline.tsx | 11 +- .../src/pages/timelines/remote-timeline.tsx | 10 +- .../src/pages/timelines/wrenched-timeline.tsx | 8 +- .../src/queries/accounts/use-antennas.ts | 4 +- .../src/queries/accounts/use-circles.ts | 4 +- .../src/queries/accounts/use-lists.ts | 4 +- .../nicolium/src/styles/new/timelines.scss | 26 +++ 17 files changed, 328 insertions(+), 55 deletions(-) create mode 100644 packages/nicolium/src/components/timeline-picker.tsx delete mode 100644 packages/nicolium/src/features/remote-timeline/components/pinned-hosts-picker.tsx diff --git a/packages/nicolium/src/components/timeline-picker.tsx b/packages/nicolium/src/components/timeline-picker.tsx new file mode 100644 index 000000000..bcdc8fdd1 --- /dev/null +++ b/packages/nicolium/src/components/timeline-picker.tsx @@ -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 = ({ 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 ( + +
+ {heading} + +
+
+ ); +}; + +export { TimelinePicker }; diff --git a/packages/nicolium/src/components/ui/column.tsx b/packages/nicolium/src/components/ui/column.tsx index 69d66291f..60cf9bdf2 100644 --- a/packages/nicolium/src/components/ui/column.tsx +++ b/packages/nicolium/src/components/ui/column.tsx @@ -8,17 +8,28 @@ import { useFrontendConfig } from '@/hooks/use-frontend-config'; import { Card, CardBody, CardHeader, CardTitle, type CardSizes } from './card'; -interface IColumnHeader extends Pick { - 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 = ({ label, + title, + withBack, backHref, backParams, className, action, + truncateTitle, }) => { const navigate = useNavigate(); const { history } = useRouter(); @@ -37,8 +48,8 @@ const ColumnHeader: React.FC = ({ }; return ( - - + + {action &&
{action}
}
@@ -47,11 +58,13 @@ const ColumnHeader: React.FC = ({ 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 = (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 = (props): React.JSX.Element => { {withHeader && ( )} diff --git a/packages/nicolium/src/features/remote-timeline/components/pinned-hosts-picker.tsx b/packages/nicolium/src/features/remote-timeline/components/pinned-hosts-picker.tsx deleted file mode 100644 index 6342eb417..000000000 --- a/packages/nicolium/src/features/remote-timeline/components/pinned-hosts-picker.tsx +++ /dev/null @@ -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 = ({ host: activeHost }) => { - const settings = useSettings(); - const pinnedHosts = settings.remote_timeline.pinnedHosts; - - if (!pinnedHosts.length) return null; - - return ( -
- {pinnedHosts.map((host) => ( - - ))} -
- ); -}; - -export { PinnedHostsPicker as default }; diff --git a/packages/nicolium/src/locales/en.json b/packages/nicolium/src/locales/en.json index 5a6b42a9b..c343fd519 100644 --- a/packages/nicolium/src/locales/en.json +++ b/packages/nicolium/src/locales/en.json @@ -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.", diff --git a/packages/nicolium/src/pages/timelines/antenna-timeline.tsx b/packages/nicolium/src/pages/timelines/antenna-timeline.tsx index 9a2cf07db..ac4efed7b 100644 --- a/packages/nicolium/src/pages/timelines/antenna-timeline.tsx +++ b/packages/nicolium/src/pages/timelines/antenna-timeline.tsx @@ -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={} + truncateTitle={false} > { const intl = useIntl(); return ( - + } + truncateTitle={false} + > { src={require('@phosphor-icons/core/regular/dots-three-vertical.svg')} /> } + title={} + truncateTitle={false} > { const intl = useIntl(); return ( - + } + truncateTitle={false} + > { if (isSledzikRemoved) return null; return ( - + } + withBack={false} + truncateTitle={false} + > diff --git a/packages/nicolium/src/pages/timelines/list-timeline.tsx b/packages/nicolium/src/pages/timelines/list-timeline.tsx index bd67658b0..c64145ccd 100644 --- a/packages/nicolium/src/pages/timelines/list-timeline.tsx +++ b/packages/nicolium/src/pages/timelines/list-timeline.tsx @@ -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 ( - + } + truncateTitle={false} + > } text={ diff --git a/packages/nicolium/src/pages/timelines/public-timeline.tsx b/packages/nicolium/src/pages/timelines/public-timeline.tsx index c0ffbd33e..5124ca2af 100644 --- a/packages/nicolium/src/pages/timelines/public-timeline.tsx +++ b/packages/nicolium/src/pages/timelines/public-timeline.tsx @@ -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 ( - - - + } + truncateTitle={false} + > {showExplanationBox && ( { }; return ( - - {instance && } - + } + truncateTitle={false} + > {!pinned && (
{ const intl = useIntl(); return ( - + } + truncateTitle={false} + > , Error>; function useAntennas>(select?: (data: Array) => 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, }); } diff --git a/packages/nicolium/src/queries/accounts/use-circles.ts b/packages/nicolium/src/queries/accounts/use-circles.ts index 35868d31e..fdd4b0e21 100644 --- a/packages/nicolium/src/queries/accounts/use-circles.ts +++ b/packages/nicolium/src/queries/accounts/use-circles.ts @@ -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, Error>; function useCircles>(select?: (data: Array) => 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, }); } diff --git a/packages/nicolium/src/queries/accounts/use-lists.ts b/packages/nicolium/src/queries/accounts/use-lists.ts index 0c11f8a21..a4704c040 100644 --- a/packages/nicolium/src/queries/accounts/use-lists.ts +++ b/packages/nicolium/src/queries/accounts/use-lists.ts @@ -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, Error>; function useLists>(select?: (data: Array) => 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, }); } diff --git a/packages/nicolium/src/styles/new/timelines.scss b/packages/nicolium/src/styles/new/timelines.scss index 662031a2f..b547b37a0 100644 --- a/packages/nicolium/src/styles/new/timelines.scss +++ b/packages/nicolium/src/styles/new/timelines.scss @@ -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)); + } + } +}