nicolium: use experimental timeline on all timelines (when enabled)

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-03-03 18:32:21 +01:00
parent 72cfa96db8
commit 2c20f72efc
19 changed files with 563 additions and 329 deletions

View File

@ -7,7 +7,17 @@ import Status from '@/components/statuses/status';
import Tombstone from '@/components/statuses/tombstone';
import PlaceholderStatus from '@/features/placeholder/components/placeholder-status';
import { useStatus } from '@/queries/statuses/use-status';
import { useHomeTimeline } from '@/queries/timelines/use-timelines';
import {
useAntennaTimeline,
useBubbleTimeline,
useGroupTimeline,
useHashtagTimeline,
useHomeTimeline,
useLinkTimeline,
useListTimeline,
usePublicTimeline,
useWrenchedTimeline,
} from '@/queries/timelines/use-timelines';
import type { FilterContextType } from '@/queries/settings/use-filters';
import type { TimelineEntry } from '@/queries/timelines/use-timeline';
@ -70,8 +80,13 @@ const TimelineStatus: React.FC<ITimelineStatus> = (props): React.JSX.Element =>
);
};
const NewTimelineColumn = () => {
const { data, handleLoadMore, isLoading } = useHomeTimeline();
interface ITimeline {
query: ReturnType<typeof useHomeTimeline>;
contextType?: FilterContextType;
}
const Timeline: React.FC<ITimeline> = ({ query, contextType = 'public' }) => {
const { data, handleLoadMore, isLoading } = query;
const renderEntry = (entry: TimelineEntry) => {
if (entry.type === 'status') {
@ -81,7 +96,7 @@ const NewTimelineColumn = () => {
id={entry.id}
isConnectedTop={entry.isConnectedTop}
isConnectedBottom={entry.isConnectedBottom}
contextType='home'
contextType={contextType}
// onMoveUp={handleMoveUp}
// onMoveDown={handleMoveDown}
// contextType={timelineId}
@ -122,4 +137,94 @@ const NewTimelineColumn = () => {
);
};
export { NewTimelineColumn };
const HomeTimelineColumn = () => {
const timelineQuery = useHomeTimeline();
return <Timeline query={timelineQuery} contextType='home' />;
};
interface IPublicTimelineColumn {
local?: boolean;
remote?: boolean;
instance?: string;
}
const PublicTimelineColumn: React.FC<IPublicTimelineColumn> = (params) => {
const timelineQuery = usePublicTimeline(params);
return <Timeline query={timelineQuery} contextType='public' />;
};
interface IHashtagTimelineColumn {
hashtag: string;
}
const HashtagTimelineColumn: React.FC<IHashtagTimelineColumn> = ({ hashtag }) => {
const timelineQuery = useHashtagTimeline(hashtag);
return <Timeline query={timelineQuery} contextType='public' />;
};
interface ILinkTimelineColumn {
url: string;
}
const LinkTimelineColumn: React.FC<ILinkTimelineColumn> = ({ url }) => {
const timelineQuery = useLinkTimeline(url);
return <Timeline query={timelineQuery} contextType='public' />;
};
interface IListTimelineColumn {
listId: string;
}
const ListTimelineColumn: React.FC<IListTimelineColumn> = ({ listId }) => {
const timelineQuery = useListTimeline(listId);
return <Timeline query={timelineQuery} contextType='home' />;
};
interface IGroupTimelineColumn {
groupId: string;
}
const GroupTimelineColumn: React.FC<IGroupTimelineColumn> = ({ groupId }) => {
const timelineQuery = useGroupTimeline(groupId);
return <Timeline query={timelineQuery} contextType='public' />;
};
const BubbleTimelineColumn = () => {
const timelineQuery = useBubbleTimeline();
return <Timeline query={timelineQuery} contextType='public' />;
};
interface IAntennaTimelineColumn {
antennaId: string;
}
const AntennaTimelineColumn: React.FC<IAntennaTimelineColumn> = ({ antennaId }) => {
const timelineQuery = useAntennaTimeline(antennaId);
return <Timeline query={timelineQuery} contextType='public' />;
};
const WrenchedTimelineColumn = () => {
const timelineQuery = useWrenchedTimeline();
return <Timeline query={timelineQuery} contextType='public' />;
};
export {
HomeTimelineColumn,
PublicTimelineColumn,
HashtagTimelineColumn,
LinkTimelineColumn,
ListTimelineColumn,
GroupTimelineColumn,
BubbleTimelineColumn,
AntennaTimelineColumn,
WrenchedTimelineColumn,
};

View File

@ -35,7 +35,7 @@ const AnnouncementContent: React.FC<IAnnouncementContent> = ({ announcement }) =
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
e.stopPropagation();
navigate({ to: '/tags/$id', params: { id: hashtag } });
navigate({ to: '/tags/$hashtag', params: { hashtag } });
}
};

View File

@ -7,7 +7,7 @@ interface IHashtagLink {
}
const HashtagLink: React.FC<IHashtagLink> = ({ hashtag }) => (
<Link to='/tags/$id' params={{ id: hashtag }} /* onClick={(e) => e.stopPropagation()} */>
<Link to='/tags/$hashtag' params={{ hashtag }} /* onClick={(e) => e.stopPropagation()} */>
#{hashtag}
</Link>
);

View File

@ -34,7 +34,7 @@ const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
return (
<HStack alignItems='center' justifyContent='between' data-testid='hashtag'>
<Stack>
<Link to='/tags/$id' params={{ id: hashtag.name }} className='hover:underline'>
<Link to='/tags/$hashtag' params={{ hashtag: hashtag.name }} className='hover:underline'>
<Text tag='span' size='sm' weight='semibold'>
#{hashtag.name}
</Text>

View File

@ -32,8 +32,8 @@ const HashtagsBar: React.FC<IHashtagsBar> = ({ hashtags }) => {
{revealedHashtags.map((hashtag) => (
<Link
key={hashtag}
to='/tags/$id'
params={{ id: hashtag }}
to='/tags/$hashtag'
params={{ hashtag }}
onClick={(e) => {
e.stopPropagation();
}}

View File

@ -438,7 +438,7 @@ export const conversationsRoute = createRoute({
// Tags and links
export const hashtagTimelineRoute = createRoute({
getParentRoute: () => layouts.default,
path: '/tags/$id',
path: '/tags/$hashtag',
component: HashtagTimeline,
beforeLoad: (options) => {
const {
@ -1271,10 +1271,10 @@ const redirectPlFeRoute = createRoute({
});
const redirectTagRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/tag/$id',
path: '/tag/$hashtag',
component: () => {
const { id } = redirectTagRoute.useParams();
return <Navigate to='/tags/$id' params={{ id }} replace />;
const { hashtag } = redirectTagRoute.useParams();
return <Navigate to='/tags/$hashtag' params={{ hashtag }} replace />;
},
});
const redirectNoticeStatusRoute = createRoute({

View File

@ -97,7 +97,7 @@ const EventDiscussionPage: React.FC = () => {
<Stack space={2}>
{me && (
<div className='border-b border-solid border-gray-200 p-2 pt-0 dark:border-gray-800'>
<ComposeForm id={`reply:${status.id}`} autoFocus={false} event={status.id} transparent />
<ComposeForm id={`reply:${status.id}`} event={status.id} transparent />
</div>
)}
<div ref={node} className='thread p-0 shadow-none sm:p-2'>

View File

@ -3,6 +3,7 @@ import React, { useEffect } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { fetchAntennaTimeline } from '@/actions/timelines';
import { AntennaTimelineColumn } from '@/columns/timeline';
import DropdownMenu from '@/components/dropdown-menu';
import MissingIndicator from '@/components/missing-indicator';
// import Button from '@/components/ui/button';
@ -13,6 +14,7 @@ import { antennaTimelineRoute } from '@/features/ui/router';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useAntenna, useDeleteAntenna } from '@/queries/accounts/use-antennas';
import { useModalsActions } from '@/stores/modals';
import { useSettings } from '@/stores/settings';
const messages = defineMessages({
deleteHeading: { id: 'confirmations.delete_antenna.heading', defaultMessage: 'Delete antenna' },
@ -25,16 +27,12 @@ const messages = defineMessages({
deleteAntenna: { id: 'antennas.delete', defaultMessage: 'Delete antenna' },
});
const AntennaTimelinePage: React.FC = () => {
const { antennaId } = antennaTimelineRoute.useParams();
interface IAntennaTimeline {
antennaId: string;
}
const intl = useIntl();
const AntennaTimeline: React.FC<IAntennaTimeline> = ({ antennaId }) => {
const dispatch = useAppDispatch();
const { openModal } = useModalsActions();
const navigate = useNavigate();
const { data: antenna, isFetching } = useAntenna(antennaId);
const { mutate: deleteAntenna } = useDeleteAntenna();
useEffect(() => {
dispatch(fetchAntennaTimeline(antennaId));
@ -44,6 +42,40 @@ const AntennaTimelinePage: React.FC = () => {
dispatch(fetchAntennaTimeline(antennaId, true));
};
const emptyMessage = (
<div>
<FormattedMessage
id='empty_column.antenna'
defaultMessage='There is nothing in this antenna yet. When posts matching the criteria will be created, they will appear here.'
/>
{/* <br /><br />
<Button onClick={handleEditClick}><FormattedMessage id='circle.click_to_add' defaultMessage='Click here to add people' /></Button> */}
</div>
);
return (
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey='antenna_timeline'
timelineId={`antenna:${antennaId}`}
onLoadMore={handleLoadMore}
emptyMessageText={emptyMessage}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
/>
);
};
const AntennaTimelinePage: React.FC = () => {
const { antennaId } = antennaTimelineRoute.useParams();
const intl = useIntl();
const { experimentalTimeline } = useSettings();
const { openModal } = useModalsActions();
const navigate = useNavigate();
const { data: antenna, isFetching } = useAntenna(antennaId);
const { mutate: deleteAntenna } = useDeleteAntenna();
const handleEditClick = () => {
openModal('ANTENNA_EDITOR', { antennaId });
};
@ -79,17 +111,6 @@ const AntennaTimelinePage: React.FC = () => {
return <MissingIndicator />;
}
const emptyMessage = (
<div>
<FormattedMessage
id='empty_column.antenna'
defaultMessage='There is nothing in this antenna yet. When posts matching the criteria will be created, they will appear here.'
/>
{/* <br /><br />
<Button onClick={handleEditClick}><FormattedMessage id='circle.click_to_add' defaultMessage='Click here to add people' /></Button> */}
</div>
);
const items = [
{
text: intl.formatMessage(messages.editAntenna),
@ -113,14 +134,11 @@ const AntennaTimelinePage: React.FC = () => {
/>
}
>
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey='antenna_timeline'
timelineId={`antenna:${antennaId}`}
onLoadMore={handleLoadMore}
emptyMessageText={emptyMessage}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
/>
{experimentalTimeline ? (
<AntennaTimelineColumn antennaId={antennaId} />
) : (
<AntennaTimeline antennaId={antennaId} />
)}
</Column>
);
};

View File

@ -3,6 +3,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchBubbleTimeline } from '@/actions/timelines';
import { useBubbleStream } from '@/api/hooks/streaming/use-bubble-stream';
import { BubbleTimelineColumn } from '@/columns/timeline';
import PullToRefresh from '@/components/pull-to-refresh';
import Column from '@/components/ui/column';
import Timeline from '@/features/ui/components/timeline';
@ -14,12 +15,11 @@ const messages = defineMessages({
title: { id: 'column.bubble', defaultMessage: 'Bubble timeline' },
});
const BubbleTimelinePage = () => {
const intl = useIntl();
const BubbleTimeline = () => {
const dispatch = useAppDispatch();
const features = useFeatures();
const settings = useSettings();
const onlyMedia = settings.timelines.bubble?.other.onlyMedia ?? false;
const timelineId = 'bubble';
@ -36,24 +36,35 @@ const BubbleTimelinePage = () => {
dispatch(fetchBubbleTimeline({ onlyMedia }));
}, [onlyMedia]);
return (
<PullToRefresh onRefresh={handleRefresh}>
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
prefix='home'
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.bubble'
defaultMessage='There is nothing here! Write something publicly to fill it up'
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
/>
</PullToRefresh>
);
};
const BubbleTimelinePage = () => {
const intl = useIntl();
const settings = useSettings();
const { experimentalTimeline } = settings;
return (
<Column className='-mt-3 sm:mt-0' label={intl.formatMessage(messages.title)}>
<PullToRefresh onRefresh={handleRefresh}>
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
prefix='home'
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.bubble'
defaultMessage='There is nothing here! Write something publicly to fill it up'
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
/>
</PullToRefresh>
{experimentalTimeline ? <BubbleTimelineColumn /> : <BubbleTimeline />}
</Column>
);
};

View File

@ -3,6 +3,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchPublicTimeline } from '@/actions/timelines';
import { useCommunityStream } from '@/api/hooks/streaming/use-community-stream';
import { PublicTimelineColumn } from '@/columns/timeline';
import PullToRefresh from '@/components/pull-to-refresh';
import Column from '@/components/ui/column';
import Timeline from '@/features/ui/components/timeline';
@ -13,20 +14,27 @@ const messages = defineMessages({
title: { id: 'column.community', defaultMessage: 'Local timeline' },
});
const CommunityTimelinePage = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
interface ICommunityTimeline {
onTimelineFailed?: () => void;
}
const CommunityTimeline: React.FC<ICommunityTimeline> = ({ onTimelineFailed }) => {
const dispatch = useAppDispatch();
const settings = useSettings();
const onlyMedia = settings.timelines['public:local']?.other.onlyMedia ?? false;
const timelineId = 'public:local';
const handleLoadMore = () => {
dispatch(fetchPublicTimeline({ onlyMedia, local: true }, true));
dispatch(
fetchPublicTimeline({ onlyMedia, local: true }, true, undefined, () => {
onTimelineFailed?.();
}),
);
};
const handleRefresh = () => dispatch(fetchPublicTimeline({ onlyMedia, local: true }));
const handleRefresh = () => dispatch(fetchPublicTimeline({ onlyMedia, local: true }, true));
useCommunityStream({ onlyMedia });
@ -34,26 +42,36 @@ const CommunityTimelinePage = () => {
dispatch(fetchPublicTimeline({ onlyMedia, local: true }));
}, [onlyMedia]);
return (
<PullToRefresh onRefresh={handleRefresh}>
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
prefix='home'
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.community'
defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!'
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
/>
</PullToRefresh>
);
};
const CommunityTimelinePage = () => {
const intl = useIntl();
const { experimentalTimeline } = useSettings();
return (
<Column className='-mt-3 sm:mt-0' label={intl.formatMessage(messages.title)}>
<PullToRefresh onRefresh={handleRefresh}>
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
prefix='home'
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.community'
defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!'
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
/>
</PullToRefresh>
{experimentalTimeline ? <PublicTimelineColumn local /> : <CommunityTimeline />}
</Column>
);
};
export { CommunityTimelinePage as default };
export { CommunityTimeline, CommunityTimelinePage as default };

View File

@ -5,6 +5,7 @@ import { FormattedMessage } from 'react-intl';
import { fetchGroupTimeline } from '@/actions/timelines';
import { useGroupStream } from '@/api/hooks/streaming/use-group-stream';
import { GroupTimelineColumn } from '@/columns/timeline';
import Avatar from '@/components/ui/avatar';
import HStack from '@/components/ui/hstack';
import Stack from '@/components/ui/stack';
@ -18,31 +19,21 @@ import { useOwnAccount } from '@/hooks/use-own-account';
import { useGroupQuery } from '@/queries/groups/use-group';
import { makeGetStatusIds } from '@/selectors';
import { useComposeActions, useUploadCompose } from '@/stores/compose';
import { useSettings } from '@/stores/settings';
const getStatusIds = makeGetStatusIds();
const GroupTimelinePage: React.FC = () => {
const { groupId } = groupTimelineRoute.useParams();
interface IGroupTimeline {
groupId: string;
}
const composeId = `group:${groupId}`;
const { data: account } = useOwnAccount();
const GroupTimeline: React.FC<IGroupTimeline> = ({ groupId }) => {
const dispatch = useAppDispatch();
const uploadCompose = useUploadCompose(composeId);
const { updateCompose } = useComposeActions();
const composer = useRef<HTMLDivElement>(null);
const { data: group } = useGroupQuery(groupId);
const canComposeGroupStatus = !!account && group?.relationship?.member;
const featuredStatusIds = useAppSelector((state) =>
getStatusIds(state, { type: `group:${group?.id}:pinned` }),
getStatusIds(state, { type: `group:${groupId}:pinned` }),
);
const { isDragging, isDraggedOver } = useDraggedFiles(composer, (files) => {
uploadCompose(files);
});
const handleLoadMore = () => {
dispatch(fetchGroupTimeline(groupId, {}, true));
};
@ -51,7 +42,46 @@ const GroupTimelinePage: React.FC = () => {
useEffect(() => {
dispatch(fetchGroupTimeline(groupId, {}));
// dispatch(fetchGroupTimeline(groupId, { pinned: true }));
}, [groupId]);
return (
<Timeline
scrollKey='group_timeline'
timelineId={`group:${groupId}`}
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.group'
defaultMessage='There are no posts in this group yet.'
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
showGroup={false}
featuredStatusIds={featuredStatusIds}
/>
);
};
const GroupTimelinePage: React.FC = () => {
const { groupId } = groupTimelineRoute.useParams();
const composeId = `group:${groupId}`;
const { data: account } = useOwnAccount();
const { experimentalTimeline } = useSettings();
const uploadCompose = useUploadCompose(composeId);
const { updateCompose } = useComposeActions();
const composer = useRef<HTMLDivElement>(null);
const { data: group } = useGroupQuery(groupId);
const canComposeGroupStatus = !!account && group?.relationship?.member;
const { isDragging, isDraggedOver } = useDraggedFiles(composer, (files) => {
uploadCompose(files);
});
useEffect(() => {
updateCompose(composeId, (draft) => {
draft.visibility = 'group';
draft.groupId = groupId;
@ -85,32 +115,16 @@ const GroupTimelinePage: React.FC = () => {
/>
</Link>
<ComposeForm
id={composeId}
shouldCondense
autoFocus={false}
group={groupId}
withAvatar
transparent
/>
<ComposeForm id={composeId} shouldCondense group={groupId} withAvatar transparent />
</HStack>
</div>
)}
<Timeline
scrollKey='group_timeline'
timelineId={composeId}
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.group'
defaultMessage='There are no posts in this group yet.'
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
showGroup={false}
featuredStatusIds={featuredStatusIds}
/>
{experimentalTimeline ? (
<GroupTimelineColumn groupId={groupId} />
) : (
<GroupTimeline groupId={groupId} />
)}
</Stack>
);
};

View File

@ -3,6 +3,7 @@ import { FormattedMessage } from 'react-intl';
import { fetchHashtagTimeline, clearTimeline } from '@/actions/timelines';
import { useHashtagStream } from '@/api/hooks/streaming/use-hashtag-stream';
import { HashtagTimelineColumn } from '@/columns/timeline';
import List, { ListItem } from '@/components/list';
import Column from '@/components/ui/column';
import Toggle from '@/components/ui/toggle';
@ -16,22 +17,53 @@ import {
useUnfollowHashtagMutation,
} from '@/queries/hashtags/use-followed-tags';
import { useHashtag } from '@/queries/hashtags/use-hashtag';
import { useSettings } from '@/stores/settings';
const HashtagTimelinePage: React.FC = () => {
const { id: tagId } = hashtagTimelineRoute.useParams();
interface IHashtagTimeline {
hashtag: string;
}
const features = useFeatures();
const HashtagTimeline: React.FC<IHashtagTimeline> = ({ hashtag }) => {
const dispatch = useAppDispatch();
const { data: tag } = useHashtag(tagId);
const { isLoggedIn } = useLoggedIn();
const { mutate: followHashtag } = useFollowHashtagMutation(tagId);
const { mutate: unfollowHashtag } = useUnfollowHashtagMutation(tagId);
const handleLoadMore = () => {
dispatch(fetchHashtagTimeline(tagId, {}, true));
dispatch(fetchHashtagTimeline(hashtag, {}, true));
};
useHashtagStream(hashtag);
useEffect(() => {
dispatch(clearTimeline(`hashtag:${hashtag}`));
dispatch(fetchHashtagTimeline(hashtag));
}, [hashtag]);
return (
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey='hashtag_timeline'
timelineId={`hashtag:${hashtag}`}
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.hashtag'
defaultMessage='There is nothing in this hashtag yet.'
/>
}
/>
);
};
const HashtagTimelinePage: React.FC = () => {
const { hashtag } = hashtagTimelineRoute.useParams();
const features = useFeatures();
const { experimentalTimeline } = useSettings();
const { data: tag } = useHashtag(hashtag);
const { isLoggedIn } = useLoggedIn();
const { mutate: followHashtag } = useFollowHashtagMutation(hashtag);
const { mutate: unfollowHashtag } = useUnfollowHashtagMutation(hashtag);
const handleFollow = () => {
if (tag?.following) {
unfollowHashtag();
@ -40,15 +72,8 @@ const HashtagTimelinePage: React.FC = () => {
}
};
useHashtagStream(tagId);
useEffect(() => {
dispatch(clearTimeline(`hashtag:${tagId}`));
dispatch(fetchHashtagTimeline(tagId));
}, [tagId]);
return (
<Column label={`#${tagId}`}>
<Column label={`#${hashtag}`}>
{features.followHashtags && isLoggedIn && (
<List>
<ListItem
@ -59,18 +84,11 @@ const HashtagTimelinePage: React.FC = () => {
</ListItem>
</List>
)}
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey='hashtag_timeline'
timelineId={`hashtag:${tagId}`}
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.hashtag'
defaultMessage='There is nothing in this hashtag yet.'
/>
}
/>
{experimentalTimeline ? (
<HashtagTimelineColumn hashtag={hashtag} />
) : (
<HashtagTimeline hashtag={hashtag} />
)}
</Column>
);
};

View File

@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { fetchHomeTimeline } from '@/actions/timelines';
import { NewTimelineColumn } from '@/columns/timeline';
import { HomeTimelineColumn } from '@/columns/timeline';
import { Link } from '@/components/link';
import PullToRefresh from '@/components/pull-to-refresh';
import Column from '@/components/ui/column';
@ -110,7 +110,7 @@ const HomeTimelinePage: React.FC = () => {
return (
<Column className='py-0' label={intl.formatMessage(messages.title)} withHeader={false}>
{experimentalTimeline ? <NewTimelineColumn /> : <HomeTimeline />}
{experimentalTimeline ? <HomeTimelineColumn /> : <HomeTimeline />}
</Column>
);
};

View File

@ -1,23 +1,22 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { fetchPublicTimeline } from '@/actions/timelines';
import { useCommunityStream } from '@/api/hooks/streaming/use-community-stream';
import { PublicTimelineColumn } from '@/columns/timeline';
import Markup from '@/components/markup';
import PullToRefresh from '@/components/pull-to-refresh';
import { ParsedContent } from '@/components/statuses/parsed-content';
import Button from '@/components/ui/button';
import Column from '@/components/ui/column';
import HStack from '@/components/ui/hstack';
import Stack from '@/components/ui/stack';
import Timeline from '@/features/ui/components/timeline';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useInstance } from '@/hooks/use-instance';
import { useRegistrationStatus } from '@/hooks/use-registration-status';
import { About } from '@/pages/utils/about';
import { useSettings } from '@/stores/settings';
import { getTextDirection } from '@/utils/rtl';
import { CommunityTimeline } from './community-timeline';
interface ILogoText extends Pick<React.HTMLAttributes<HTMLHeadingElement>, 'className' | 'dir'> {
children: React.ReactNode;
}
@ -54,34 +53,14 @@ const SiteBanner: React.FC = () => {
};
const LandingTimelinePage = () => {
const dispatch = useAppDispatch();
const instance = useInstance();
const { isOpen } = useRegistrationStatus();
const { experimentalTimeline } = useSettings();
const [timelineFailed, setTimelineFailed] = useState(false);
const timelineEnabled = !instance.pleroma.metadata.restrict_unauthenticated.timelines.local;
const timelineId = 'public:local';
const handleLoadMore = () => {
dispatch(fetchPublicTimeline({ local: true }, true));
};
const handleRefresh = () => dispatch(fetchPublicTimeline({ local: true }));
useCommunityStream({ enabled: timelineEnabled });
useEffect(() => {
if (timelineEnabled) {
dispatch(
fetchPublicTimeline({ local: true }, false, undefined, () => {
setTimelineFailed(true);
}),
);
}
}, []);
return (
<Column withHeader={false}>
<div className='mb-4 mt-12 px-4 lg:mb-12'>
@ -100,22 +79,11 @@ const LandingTimelinePage = () => {
</HStack>
{timelineEnabled && !timelineFailed ? (
<PullToRefresh onRefresh={handleRefresh}>
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey={`${timelineId}_timeline`}
timelineId={timelineId}
prefix='home'
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.community'
defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!'
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
/>
</PullToRefresh>
experimentalTimeline ? (
<PublicTimelineColumn local />
) : (
<CommunityTimeline onTimelineFailed={() => setTimelineFailed(true)} />
)
) : (
<About slug='index' />
)}

View File

@ -2,48 +2,66 @@ import React, { useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { clearTimeline, fetchLinkTimeline } from '@/actions/timelines';
import { LinkTimelineColumn } from '@/columns/timeline';
import Column from '@/components/ui/column';
import Timeline from '@/features/ui/components/timeline';
import { linkTimelineRoute } from '@/features/ui/router';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useSettings } from '@/stores/settings';
const messages = defineMessages({
header: { id: 'column.link_timeline', defaultMessage: 'Posts linking to {url}' },
});
interface ILinkTimeline {
url: string;
}
const LinkTimeline: React.FC<ILinkTimeline> = ({ url }) => {
const dispatch = useAppDispatch();
const handleLoadMore = () => {
dispatch(fetchLinkTimeline(url, true));
};
useEffect(() => {
dispatch(clearTimeline(`link:${url}`));
dispatch(fetchLinkTimeline(url));
}, [url]);
return (
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey='link_timeline'
timelineId={`link:${url}`}
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.link_timeline'
defaultMessage='There are no posts with this link yet.'
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
/>
);
};
const LinkTimelinePage: React.FC = () => {
const { url } = linkTimelineRoute.useParams();
const decodedUrl = decodeURIComponent(url || '');
const intl = useIntl();
const dispatch = useAppDispatch();
const handleLoadMore = () => {
dispatch(fetchLinkTimeline(decodedUrl, true));
};
useEffect(() => {
dispatch(clearTimeline(`link:${decodedUrl}`));
dispatch(fetchLinkTimeline(decodedUrl));
}, [decodedUrl]);
const { experimentalTimeline } = useSettings();
return (
<Column
label={intl.formatMessage(messages.header, { url: decodedUrl.replace(/^https?:\/\//, '') })}
>
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey='link_timeline'
timelineId={`link:${decodedUrl}`}
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.link_timeline'
defaultMessage='There are no posts with this link yet.'
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
/>
{experimentalTimeline ? (
<LinkTimelineColumn url={decodedUrl} />
) : (
<LinkTimeline url={decodedUrl} />
)}
</Column>
);
};

View File

@ -4,6 +4,7 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { fetchListTimeline } from '@/actions/timelines';
import { useListStream } from '@/api/hooks/streaming/use-list-stream';
import { ListTimelineColumn } from '@/columns/timeline';
import DropdownMenu from '@/components/dropdown-menu';
import MissingIndicator from '@/components/missing-indicator';
import Button from '@/components/ui/button';
@ -14,6 +15,7 @@ import { listTimelineRoute } from '@/features/ui/router';
import { useAppDispatch } from '@/hooks/use-app-dispatch';
import { useDeleteList, useList } from '@/queries/accounts/use-lists';
import { useModalsActions } from '@/stores/modals';
import { useSettings } from '@/stores/settings';
const messages = defineMessages({
deleteHeading: { id: 'confirmations.delete_list.heading', defaultMessage: 'Delete list' },
@ -26,27 +28,66 @@ const messages = defineMessages({
deleteList: { id: 'lists.delete', defaultMessage: 'Delete list' },
});
interface IListTimeline {
listId: string;
}
const ListTimeline: React.FC<IListTimeline> = ({ listId }) => {
const dispatch = useAppDispatch();
const settings = useSettings();
const { openModal } = useModalsActions();
const onlyMedia = settings.timelines[`list:${listId}`]?.other.onlyMedia ?? false;
const handleLoadMore = () => {
dispatch(fetchListTimeline(listId, true));
};
useListStream(listId);
useEffect(() => {
dispatch(fetchListTimeline(listId, false));
}, [listId, onlyMedia]);
const handleEditClick = () => {
openModal('LIST_EDITOR', { listId });
};
return (
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey={`list_${listId}_timeline`}
timelineId={`list:${listId}${onlyMedia ? ':media' : ''}`}
onLoadMore={handleLoadMore}
emptyMessageText={
<div>
<FormattedMessage
id='empty_column.list'
defaultMessage='There is nothing in this list yet. When members of this list create new posts, they will appear here.'
/>
<br />
<br />
<Button onClick={handleEditClick}>
<FormattedMessage id='list.click_to_add' defaultMessage='Click here to add people' />
</Button>
</div>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/list-bullets.svg')}
/>
);
};
const ListTimelinePage: React.FC = () => {
const { listId } = listTimelineRoute.useParams();
const intl = useIntl();
const dispatch = useAppDispatch();
const { experimentalTimeline } = useSettings();
const { openModal } = useModalsActions();
const navigate = useNavigate();
const { data: list, isFetching } = useList(listId);
const { mutate: deleteList } = useDeleteList();
useListStream(listId);
useEffect(() => {
dispatch(fetchListTimeline(listId));
}, [listId]);
const handleLoadMore = () => {
dispatch(fetchListTimeline(listId, true));
};
const handleEditClick = () => {
openModal('LIST_EDITOR', { listId });
};
@ -82,20 +123,6 @@ const ListTimelinePage: React.FC = () => {
return <MissingIndicator />;
}
const emptyMessage = (
<div>
<FormattedMessage
id='empty_column.list'
defaultMessage='There is nothing in this list yet. When members of this list create new posts, they will appear here.'
/>
<br />
<br />
<Button onClick={handleEditClick}>
<FormattedMessage id='list.click_to_add' defaultMessage='Click here to add people' />
</Button>
</div>
);
const items = [
{
text: intl.formatMessage(messages.editList),
@ -119,14 +146,11 @@ const ListTimelinePage: React.FC = () => {
/>
}
>
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey='list_timeline'
timelineId={`list:${listId}`}
onLoadMore={handleLoadMore}
emptyMessageText={emptyMessage}
emptyMessageIcon={require('@phosphor-icons/core/regular/list-bullets.svg')}
/>
{experimentalTimeline ? (
<ListTimelineColumn listId={listId} />
) : (
<ListTimeline listId={listId} />
)}
</Column>
);
};

View File

@ -5,6 +5,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { changeSetting } from '@/actions/settings';
import { fetchPublicTimeline } from '@/actions/timelines';
import { usePublicStream } from '@/api/hooks/streaming/use-public-stream';
import { PublicTimelineColumn } from '@/columns/timeline';
import PullToRefresh from '@/components/pull-to-refresh';
import Accordion from '@/components/ui/accordion';
import Column from '@/components/ui/column';
@ -19,27 +20,13 @@ const messages = defineMessages({
dismiss: { id: 'fediverse_tab.explanation_box.dismiss', defaultMessage: "Don't show again" },
});
const PublicTimelinePage = () => {
const intl = useIntl();
const PublicTimeline = () => {
const dispatch = useAppDispatch();
const instance = useInstance();
const settings = useSettings();
const onlyMedia = settings.timelines.public?.other.onlyMedia ?? false;
const timelineId = 'public';
const explanationBoxExpanded = settings.explanationBox;
const showExplanationBox = settings.showExplanationBox;
const dismissExplanationBox = () => {
dispatch(changeSetting(['showExplanationBox'], false));
};
const toggleExplanationBox = (setting: boolean) => {
dispatch(changeSetting(['explanationBox'], setting));
};
const handleLoadMore = () => {
dispatch(fetchPublicTimeline({ onlyMedia }, true));
};
@ -52,6 +39,45 @@ const PublicTimelinePage = () => {
dispatch(fetchPublicTimeline({ onlyMedia }, true));
}, [onlyMedia]);
return (
<PullToRefresh onRefresh={handleRefresh}>
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
prefix='home'
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.public'
defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up'
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
/>
</PullToRefresh>
);
};
const PublicTimelinePage = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const instance = useInstance();
const settings = useSettings();
const { experimentalTimeline } = settings;
const explanationBoxExpanded = settings.explanationBox;
const showExplanationBox = settings.showExplanationBox;
const dismissExplanationBox = () => {
dispatch(changeSetting(['showExplanationBox'], false));
};
const toggleExplanationBox = (setting: boolean) => {
dispatch(changeSetting(['explanationBox'], setting));
};
return (
<Column className='-mt-3 sm:mt-0' label={intl.formatMessage(messages.title)}>
<PinnedHostsPicker />
@ -89,22 +115,7 @@ const PublicTimelinePage = () => {
/>
</Accordion>
)}
<PullToRefresh onRefresh={handleRefresh}>
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
prefix='home'
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.public'
defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up'
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
/>
</PullToRefresh>
{experimentalTimeline ? <PublicTimelineColumn /> : <PublicTimeline />}
</Column>
);
};

View File

@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchPublicTimeline } from '@/actions/timelines';
import { useRemoteStream } from '@/api/hooks/streaming/use-remote-stream';
import { PublicTimelineColumn } from '@/columns/timeline';
import Column from '@/components/ui/column';
import HStack from '@/components/ui/hstack';
import IconButton from '@/components/ui/icon-button';
@ -18,25 +19,17 @@ const messages = defineMessages({
close: { id: 'remote_timeline.close', defaultMessage: 'Close remote timeline' },
});
/** View statuses from a remote instance. */
const RemoteTimelinePage: React.FC = () => {
const { instance } = remoteTimelineRoute.useParams();
interface IRemoteTimeline {
instance: string;
}
const intl = useIntl();
const navigate = useNavigate();
const RemoteTimeline: React.FC<IRemoteTimeline> = ({ instance }) => {
const dispatch = useAppDispatch();
const settings = useSettings();
const timelineId = 'remote';
const onlyMedia = settings.timelines.remote?.other.onlyMedia ?? false;
const pinned = settings.remote_timeline.pinnedHosts.includes(instance);
const handleCloseClick: React.MouseEventHandler = () => {
navigate({ to: '/timeline/fediverse' });
};
const handleLoadMore = () => {
dispatch(fetchPublicTimeline({ onlyMedia, instance }, true));
};
@ -45,7 +38,41 @@ const RemoteTimelinePage: React.FC = () => {
useEffect(() => {
dispatch(fetchPublicTimeline({ onlyMedia, instance }));
}, [onlyMedia]);
}, [onlyMedia, instance]);
return (
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey={`${timelineId}_${instance}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}:${instance}`}
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.remote'
defaultMessage='There is nothing here! Manually follow users from {instance} to fill it up.'
values={{ instance }}
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
/>
);
};
/** View statuses from a remote instance. */
const RemoteTimelinePage: React.FC = () => {
const { instance } = remoteTimelineRoute.useParams();
const intl = useIntl();
const navigate = useNavigate();
const settings = useSettings();
const { experimentalTimeline } = settings;
const pinned = settings.remote_timeline.pinnedHosts.includes(instance);
const handleCloseClick: React.MouseEventHandler = () => {
navigate({ to: '/timeline/fediverse' });
};
return (
<Column label={instance}>
@ -70,20 +97,11 @@ const RemoteTimelinePage: React.FC = () => {
</HStack>
)}
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey={`${timelineId}_${instance}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}:${instance}`}
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.remote'
defaultMessage='There is nothing here! Manually follow users from {instance} to fill it up.'
values={{ instance }}
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/chat-centered-text.svg')}
/>
{experimentalTimeline ? (
<PublicTimelineColumn instance={instance} />
) : (
<RemoteTimeline instance={instance} />
)}
</Column>
);
};

View File

@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchWrenchedTimeline } from '@/actions/timelines';
import { WrenchedTimelineColumn } from '@/columns/timeline';
import PullToRefresh from '@/components/pull-to-refresh';
import Column from '@/components/ui/column';
import Timeline from '@/features/ui/components/timeline';
@ -12,11 +13,10 @@ const messages = defineMessages({
title: { id: 'column.wrenched', defaultMessage: 'Recent wrenches timeline' },
});
const WrenchedTimelinePage = () => {
const intl = useIntl();
const WrenchedTimeline = () => {
const dispatch = useAppDispatch();
const settings = useSettings();
const onlyMedia = settings.timelines.wrenched?.other.onlyMedia ?? false;
const timelineId = 'wrenched';
@ -31,24 +31,35 @@ const WrenchedTimelinePage = () => {
dispatch(fetchWrenchedTimeline({ onlyMedia }));
}, [onlyMedia]);
return (
<PullToRefresh onRefresh={handleRefresh}>
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
prefix='home'
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.wrenched'
defaultMessage='There is nothing here! 🔧 a public post to fill it up'
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/wrench.svg')}
/>
</PullToRefresh>
);
};
const WrenchedTimelinePage = () => {
const intl = useIntl();
const settings = useSettings();
const { experimentalTimeline } = settings;
return (
<Column className='-mt-3 sm:mt-0' label={intl.formatMessage(messages.title)}>
<PullToRefresh onRefresh={handleRefresh}>
<Timeline
loadMoreClassName='sm:pb-4 black:sm:pb-0 black:sm:mx-4'
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
prefix='home'
onLoadMore={handleLoadMore}
emptyMessageText={
<FormattedMessage
id='empty_column.wrenched'
defaultMessage='There is nothing here! 🔧 a public post to fill it up'
/>
}
emptyMessageIcon={require('@phosphor-icons/core/regular/wrench.svg')}
/>
</PullToRefresh>
{experimentalTimeline ? <WrenchedTimelineColumn /> : <WrenchedTimeline />}
</Column>
);
};