nicolium: downstream ports

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2026-02-15 15:36:51 +01:00
parent fdb2befb52
commit 27f731e406
8 changed files with 400 additions and 98 deletions

View File

@ -0,0 +1,77 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import RelativeTimestamp from './relative-timestamp';
import Avatar from './ui/avatar';
import HStack from './ui/hstack';
import Icon from './ui/icon';
import Stack from './ui/stack';
import Text from './ui/text';
import type { RssFeed } from 'pl-api';
interface IRssFeedInfo {
feed: RssFeed;
timestamp: string;
timestampUrl?: string;
}
const RssFeedInfo: React.FC<IRssFeedInfo> = ({ feed, timestamp, timestampUrl }) => (
<div className='group block w-full shrink-0'>
<HStack alignItems='center' space={3} className='overflow-hidden'>
<div className='rounded-lg'>
<Avatar src={feed.image_url || ''} size={42} alt={feed.title || ''} />
</div>
<div className='grow overflow-hidden'>
<HStack space={1} alignItems='center' grow>
<Text size='sm' weight='semibold' truncate>
{feed.title}
</Text>
</HStack>
<Stack>
<HStack alignItems='center' space={1}>
<Text theme='muted' size='sm'>
<FormattedMessage id='rss_feed.label' defaultMessage='RSS Feed' />
</Text>
<Icon
className='size-4 text-gray-700 dark:text-gray-600'
src={require('@phosphor-icons/core/regular/rss.svg')}
/>
<Text tag='span' theme='muted' size='sm'>
&middot;
</Text>
{timestampUrl ? (
<Link
to={timestampUrl}
className='hover:underline'
onClick={(event) => event.stopPropagation()}
>
<RelativeTimestamp
timestamp={timestamp}
theme='muted'
size='sm'
className='whitespace-nowrap'
/>
</Link>
) : (
<RelativeTimestamp
timestamp={timestamp}
theme='muted'
size='sm'
className='whitespace-nowrap'
/>
)}
</HStack>
</Stack>
</div>
</HStack>
</div>
);
export default RssFeedInfo;

View File

@ -30,6 +30,7 @@ import { textForScreenReader } from '@/utils/status';
import EventPreview from './event-preview';
import HashtagLink from './hashtag-link';
import RelativeTimestamp from './relative-timestamp';
import RssFeedInfo from './rss-feed-info';
import StatusActionBar from './status-action-bar';
import StatusContent from './status-content';
import StatusLanguagePicker from './status-language-picker';
@ -549,19 +550,23 @@ const Status: React.FC<IStatus> = (props) => {
>
{statusInfo}
{actualStatus.account_id && (
<div className='flex'>
<AccountContainer
key={actualStatus.account_id}
id={actualStatus.account_id}
action={<AccountInfo status={actualStatus} />}
showAccountHoverCard={hoverable}
withLinkToProfile={hoverable}
approvalStatus={actualStatus.approval_status}
avatarSize={avatarSize}
actionAlignment='top'
/>
</div>
{status.rss_feed ? (
<RssFeedInfo feed={status.rss_feed} timestamp={status.created_at} />
) : (
actualStatus.account_id && (
<div className='flex'>
<AccountContainer
key={actualStatus.account_id}
id={actualStatus.account_id}
action={<AccountInfo status={actualStatus} />}
showAccountHoverCard={hoverable}
withLinkToProfile={hoverable}
approvalStatus={actualStatus.approval_status}
avatarSize={avatarSize}
actionAlignment='top'
/>
</div>
)
)}
<div className='status__content-wrapper'>
@ -579,22 +584,26 @@ const Status: React.FC<IStatus> = (props) => {
/>
)}
<StatusReactionsBar status={actualStatus} collapsed />
{!status.rss_feed && (
<>
<StatusReactionsBar status={actualStatus} collapsed />
{!hideActionBar && (
<div
className={clsx({
'pt-2': actualStatus.emoji_reactions.length,
'pt-4': !actualStatus.emoji_reactions.length,
})}
>
<StatusActionBar
status={actualStatus}
rebloggedBy={isReblog ? status.account : undefined}
fromBookmarks={fromBookmarks}
expandable
/>
</div>
{!hideActionBar && (
<div
className={clsx({
'pt-2': actualStatus.emoji_reactions.length,
'pt-4': !actualStatus.emoji_reactions.length,
})}
>
<StatusActionBar
status={actualStatus}
rebloggedBy={isReblog ? status.account : undefined}
fromBookmarks={fromBookmarks}
expandable
/>
</div>
)}
</>
)}
</div>
</Card>

View File

@ -3,6 +3,7 @@ import React, { useRef } from 'react';
import { defineMessages, FormattedDate, FormattedMessage, useIntl } from 'react-intl';
import Account from '@/components/account';
import RssFeedInfo from '@/components/rss-feed-info';
import StatusContent from '@/components/status-content';
import StatusLanguagePicker from '@/components/status-language-picker';
import StatusReactionsBar from '@/components/status-reactions-bar';
@ -95,7 +96,9 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
<div ref={node} className='detailed-actualStatus' tabIndex={-1}>
{renderStatusInfo()}
{account.id && (
{actualStatus.rss_feed ? (
<RssFeedInfo feed={actualStatus.rss_feed} timestamp={actualStatus.created_at} />
) : (
<div className='mb-4'>
<Account
key={account.id}
@ -115,83 +118,87 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
</Stack>
</Stack>
<StatusReactionsBar status={actualStatus} />
{!status.rss_feed && (
<>
<StatusReactionsBar status={actualStatus} />
<HStack space={2} justifyContent='between' alignItems='center' className='py-3' wrap>
<StatusInteractionBar status={actualStatus} />
<HStack space={2} justifyContent='between' alignItems='center' className='py-3' wrap>
<StatusInteractionBar status={actualStatus} />
<HStack space={1} alignItems='center'>
<span>
<Text tag='span' theme='muted' size='sm'>
<HStack space={1} alignItems='center' wrap>
<a
href={actualStatus.url}
target='_blank'
rel='noopener'
className='hover:underline'
>
<FormattedDate
value={new Date(actualStatus.created_at)}
hour12
year='numeric'
month='short'
day='2-digit'
hour='numeric'
minute='2-digit'
/>
</a>
{actualStatus.application && (
<>
<span className='⁂-separator' />
<HStack space={1} alignItems='center'>
<span>
<Text tag='span' theme='muted' size='sm'>
<HStack space={1} alignItems='center' wrap>
<a
href={actualStatus.application.website ?? '#'}
href={actualStatus.url}
target='_blank'
rel='noopener'
className='hover:underline'
title={intl.formatMessage(messages.applicationName, {
name: actualStatus.application.name,
})}
>
{actualStatus.application.name}
</a>
</>
)}
{actualStatus.edited_at && (
<>
<span className='⁂-separator' />
<div
className='inline hover:underline'
onClick={handleOpenCompareHistoryModal}
role='button'
tabIndex={0}
>
<FormattedMessage
id='status.edited'
defaultMessage='Edited {date}'
values={{
date: intl.formatDate(new Date(actualStatus.edited_at), {
hour12: true,
month: 'short',
day: '2-digit',
hour: 'numeric',
minute: '2-digit',
}),
}}
<FormattedDate
value={new Date(actualStatus.created_at)}
hour12
year='numeric'
month='short'
day='2-digit'
hour='numeric'
minute='2-digit'
/>
</div>
</>
)}
</HStack>
</Text>
</span>
</a>
<StatusTypeIcon visibility={actualStatus.visibility} />
{actualStatus.application && (
<>
<span className='⁂-separator' />
<a
href={actualStatus.application.website ?? '#'}
target='_blank'
rel='noopener'
className='hover:underline'
title={intl.formatMessage(messages.applicationName, {
name: actualStatus.application.name,
})}
>
{actualStatus.application.name}
</a>
</>
)}
<StatusLanguagePicker status={actualStatus} showLabel />
</HStack>
</HStack>
{actualStatus.edited_at && (
<>
<span className='⁂-separator' />
<div
className='inline hover:underline'
onClick={handleOpenCompareHistoryModal}
role='button'
tabIndex={0}
>
<FormattedMessage
id='status.edited'
defaultMessage='Edited {date}'
values={{
date: intl.formatDate(new Date(actualStatus.edited_at), {
hour12: true,
month: 'short',
day: '2-digit',
hour: 'numeric',
minute: '2-digit',
}),
}}
/>
</div>
</>
)}
</HStack>
</Text>
</span>
<StatusTypeIcon visibility={actualStatus.visibility} />
<StatusLanguagePicker status={actualStatus} showLabel />
</HStack>
</HStack>
</>
)}
</div>
</div>
);

View File

@ -356,9 +356,13 @@ const Thread = ({
withMedia={withMedia}
/>
<hr className='-mx-4 mb-2 max-w-[100vw] border-t-2 black:border-t dark:border-gray-800' />
{!status.rss_feed && (
<>
<hr className='-mx-4 mb-2 max-w-[100vw] border-t-2 black:border-t dark:border-gray-800' />
<StatusActionBar status={status} expandable={isModal} space='lg' withLabels />
<StatusActionBar status={status} expandable={isModal} space='lg' withLabels />
</>
)}
</div>
</Hotkeys>
)}

View File

@ -687,6 +687,15 @@ export const followedTagsRoute = createRoute({
}),
});
export const rssFeedSubscriptionsRoute = createRoute({
getParentRoute: () => layouts.default,
path: '/rss_feed_subscriptions',
component: RssFeedSubscriptions,
beforeLoad: requireAuthMiddleware(({ context: { features } }) => {
if (!features.rssFeedSubscriptions) throw notFound();
}),
});
// Interaction requests
export const interactionRequestsRoute = createRoute({
getParentRoute: () => layouts.default,
@ -1460,6 +1469,7 @@ const routeTree = rootRoute.addChildren([
filtersRoute,
editFilterRoute,
followedTagsRoute,
rssFeedSubscriptionsRoute,
interactionRequestsRoute,
newStatusRoute,
scheduledStatusesRoute,

View File

@ -96,6 +96,7 @@ export const RegisterInvite = lazy(() => import('@/pages/auth/register-with-invi
export const RegistrationPage = lazy(() => import('@/pages/auth/registration'));
export const Relays = lazy(() => import('@/pages/dashboard/relays'));
export const RemoteTimeline = lazy(() => import('@/pages/timelines/remote-timeline'));
export const RssFeedSubscriptions = lazy(() => import('@/pages/settings/rss-feed-subscriptions'));
export const Rules = lazy(() => import('@/pages/dashboard/rules'));
export const ScheduledStatuses = lazy(() => import('@/pages/status-lists/scheduled-statuses'));
export const Search = lazy(() => import('@/pages/search/search'));

View File

@ -0,0 +1,158 @@
import React from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import List, { ListItem } from '@/components/list';
import Avatar from '@/components/ui/avatar';
import Button from '@/components/ui/button';
import Card, { CardTitle } from '@/components/ui/card';
import Column from '@/components/ui/column';
import Form from '@/components/ui/form';
import HStack from '@/components/ui/hstack';
import Icon from '@/components/ui/icon';
import IconButton from '@/components/ui/icon-button';
import Input from '@/components/ui/input';
import Stack from '@/components/ui/stack';
import Text from '@/components/ui/text';
import { useTextField } from '@/hooks/forms/use-text-field';
import {
useCreateRssFeedSubscription,
useDeleteRssFeedSubscription,
useRssFeedSubscriptions,
} from '@/queries/rss-feed-subscriptions/use-rss-feed-subscriptions';
import toast from '@/toast';
const messages = defineMessages({
heading: { id: 'column.rss_feed_subscriptions', defaultMessage: 'Subscribed RSS feeds' },
label: { id: 'rss_feed_subscriptions.new.title_placeholder', defaultMessage: 'RSS feed URL' },
createSuccess: {
id: 'rss_feed_subscriptions.add.success',
defaultMessage: 'Successfully subscribed to RSS feed',
},
createFail: {
id: 'rss_feed_subscriptions.add.fail',
defaultMessage: 'Failed to subsrcibe to RSS feed',
},
});
const NewFeedForm: React.FC = () => {
const intl = useIntl();
const url = useTextField();
const { mutate: createRssFeedSubscription, isPending } = useCreateRssFeedSubscription();
const handleSubmit = (e: React.FormEvent<Element>) => {
e.preventDefault();
createRssFeedSubscription(url.value, {
onSuccess() {
toast.success(messages.createSuccess);
},
onError() {
toast.success(messages.createFail);
},
});
};
const label = intl.formatMessage(messages.label);
return (
<Form onSubmit={handleSubmit}>
<HStack space={2} alignItems='center'>
<label className='grow'>
<span style={{ display: 'none' }}>{label}</span>
<Input type='text' placeholder={label} disabled={isPending} {...url} />
</label>
<Button disabled={isPending} onClick={handleSubmit} theme='primary'>
<FormattedMessage
id='rss_feed_subscriptions.new.create_title'
defaultMessage='Subscribe'
/>
</Button>
</HStack>
</Form>
);
};
const RssFeedSubscriptions = () => {
const intl = useIntl();
const { data: feeds = [], isLoading } = useRssFeedSubscriptions();
const { mutate: deleteRssFeedSubscription, isPending } = useDeleteRssFeedSubscription();
const handleDelete = (url: string) => () => {
deleteRssFeedSubscription(url);
};
const emptyMessage = (
<FormattedMessage
id='empty_column.rss_feed_subscriptions'
defaultMessage="You haven't subscribed to any RSS feeds yet."
/>
);
return (
<Column label={intl.formatMessage(messages.heading)}>
<Stack space={4}>
<CardTitle
title={
<FormattedMessage
id='rss_feed_subscriptions.new.heading'
defaultMessage='Subscribe to a new RSS feed'
/>
}
/>
<NewFeedForm />
<CardTitle
title={
<FormattedMessage
id='rss_feed_subscriptions.list.heading'
defaultMessage='Subscribed feeds'
/>
}
/>
{feeds?.length ? (
<List>
{feeds?.map((feed) => (
<ListItem
key={feed.id}
label={
<HStack className='w-full' alignItems='center' space={2}>
{feed.image_url ? (
<Avatar size={40} src={feed.image_url} />
) : (
<Icon src={require('@phosphor-icons/core/regular/rss.svg')} size={40} />
)}
<Stack className='flex-1'>
<span>{feed.title}</span>
<Text size='sm' theme='muted' truncate>
{feed.url}
</Text>
</Stack>
<IconButton
onClick={handleDelete(feed.url)}
disabled={isPending}
className='size-8 text-gray-700 dark:text-gray-600'
src={require('@phosphor-icons/core/regular/x.svg')}
/>
</HStack>
}
/>
))}
</List>
) : (
!isLoading && (
<Card variant='rounded' size='lg'>
{emptyMessage}
</Card>
)
)}
</Stack>
</Column>
);
};
export { RssFeedSubscriptions as default };

View File

@ -0,0 +1,36 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useClient } from '@/hooks/use-client';
import { queryClient } from '../client';
const useRssFeedSubscriptions = () => {
const client = useClient();
return useQuery({
queryKey: ['rssFeedSubscriptions'],
queryFn: () => client.rssFeedSubscriptions.fetchRssFeedSubscriptions(),
});
};
const useCreateRssFeedSubscription = () => {
const client = useClient();
return useMutation({
mutationKey: ['rss-feed-subscriptions'],
mutationFn: (url: string) => client.rssFeedSubscriptions.createRssFeedSubscription(url),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['rssFeedSubscriptions'] }),
});
};
const useDeleteRssFeedSubscription = () => {
const client = useClient();
return useMutation({
mutationKey: ['rss-feed-subscriptions'],
mutationFn: (url: string) => client.rssFeedSubscriptions.deleteRssFeedSubscription(url),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['rssFeedSubscriptions'] }),
});
};
export { useRssFeedSubscriptions, useCreateRssFeedSubscription, useDeleteRssFeedSubscription };