pl-fe: basic support for circles

Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-04-21 23:55:41 +02:00
parent f747f9b9dd
commit fa0ab88236
10 changed files with 240 additions and 5 deletions

View File

@ -37,6 +37,7 @@ const messages = defineMessages({
profileDirectory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' }, profileDirectory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' },
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'column.lists', defaultMessage: 'Lists' }, lists: { id: 'column.lists', defaultMessage: 'Lists' },
circles: { id: 'column.circles', defaultMessage: 'Circles' },
groups: { id: 'column.groups', defaultMessage: 'Groups' }, groups: { id: 'column.groups', defaultMessage: 'Groups' },
events: { id: 'column.events', defaultMessage: 'Events' }, events: { id: 'column.events', defaultMessage: 'Events' },
dashboard: { id: 'navigation.dashboard', defaultMessage: 'Dashboard' }, dashboard: { id: 'navigation.dashboard', defaultMessage: 'Dashboard' },
@ -283,6 +284,15 @@ const SidebarMenu: React.FC = React.memo((): JSX.Element | null => {
/> />
)} )}
{features.circles && (
<SidebarLink
to='/circles'
icon={require('@tabler/icons/outline/chart-circles.svg')}
text={intl.formatMessage(messages.circles)}
onClick={closeSidebar}
/>
)}
{features.events && ( {features.events && (
<SidebarLink <SidebarLink
to='/events' to='/events'

View File

@ -26,6 +26,7 @@ const messages = defineMessages({
followRequests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, followRequests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'column.lists', defaultMessage: 'Lists' }, lists: { id: 'column.lists', defaultMessage: 'Lists' },
circles: { id: 'column.circles', defaultMessage: 'Circles' },
events: { id: 'column.events', defaultMessage: 'Events' }, events: { id: 'column.events', defaultMessage: 'Events' },
profileDirectory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' }, profileDirectory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' },
followedTags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' }, followedTags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
@ -100,6 +101,14 @@ const SidebarNavigation = React.memo(() => {
}); });
} }
if (features.circles) {
menu.push({
to: '/circles',
text: intl.formatMessage(messages.circles),
icon: require('@tabler/icons/outline/chart-circles.svg'),
});
}
if (features.events) { if (features.events) {
menu.push({ menu.push({
to: '/events', to: '/events',

View File

@ -0,0 +1,62 @@
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Button from 'pl-fe/components/ui/button';
import Form from 'pl-fe/components/ui/form';
import HStack from 'pl-fe/components/ui/hstack';
import Input from 'pl-fe/components/ui/input';
import { useCreateCircle } from 'pl-fe/queries/accounts/use-circles';
const messages = defineMessages({
label: { id: 'circles.new.title_placeholder', defaultMessage: 'New circle title' },
title: { id: 'circles.new.create', defaultMessage: 'Add circle' },
create: { id: 'circles.new.create_title', defaultMessage: 'Add circle' },
});
const NewCircleForm: React.FC = () => {
const intl = useIntl();
const [title, setTitle] = useState('');
const { mutate: createCircle, isPending } = useCreateCircle();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value);
};
const handleSubmit = (e: React.FormEvent<Element>) => {
e.preventDefault();
createCircle(title);
};
const label = intl.formatMessage(messages.label);
const create = intl.formatMessage(messages.create);
return (
<Form onSubmit={handleSubmit}>
<HStack space={2} alignItems='center'>
<label className='grow'>
<span style={{ display: 'none' }}>{label}</span>
<Input
type='text'
value={title}
disabled={isPending}
onChange={handleChange}
placeholder={label}
/>
</label>
<Button
disabled={isPending}
onClick={handleSubmit}
theme='primary'
>
{create}
</Button>
</HStack>
</Form>
);
};
export { NewCircleForm as default };

View File

@ -0,0 +1,67 @@
import React from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import List, { ListItem } from 'pl-fe/components/list';
import Card from 'pl-fe/components/ui/card';
import Column from 'pl-fe/components/ui/column';
import HStack from 'pl-fe/components/ui/hstack';
import Icon from 'pl-fe/components/ui/icon';
import Spinner from 'pl-fe/components/ui/spinner';
import Stack from 'pl-fe/components/ui/stack';
import { useCircles } from 'pl-fe/queries/accounts/use-circles';
import { getOrderedLists } from '../lists';
import NewCircleForm from './components/new-circle-form';
const messages = defineMessages({
heading: { id: 'column.circles', defaultMessage: 'Circles' },
subheading: { id: 'circles.subheading', defaultMessage: 'Your circles' },
});
const Circles: React.FC = () => {
const intl = useIntl();
const { data: circles } = useCircles(getOrderedLists);
if (!circles) {
return (
<Column>
<Spinner />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.circles' defaultMessage="You don't have any circles yet. When you create one, it will show up here." />;
return (
<Column label={intl.formatMessage(messages.heading)}>
<Stack space={4}>
<NewCircleForm />
{!Object.keys(circles).length ? (
<Card variant='rounded' size='lg'>
{emptyMessage}
</Card>
) : (
<List>
{circles.map((circle) => (
<ListItem
key={circle.id}
// to={`/circles/${circle.id}`}
label={
<HStack alignItems='center' space={2}>
<Icon src={require('@tabler/icons/outline/list.svg')} size={20} />
<span>{circle.title}</span>
</HStack>
}
/>
))}
</List>
)}
</Stack>
</Column>
);
};
export { Circles as default };

View File

@ -8,9 +8,10 @@ import { getOrderedLists } from 'pl-fe/features/lists';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useCompose } from 'pl-fe/hooks/use-compose'; import { useCompose } from 'pl-fe/hooks/use-compose';
import { useFeatures } from 'pl-fe/hooks/use-features'; import { useFeatures } from 'pl-fe/hooks/use-features';
import { useCircles } from 'pl-fe/queries/accounts/use-circles';
import { useLists } from 'pl-fe/queries/accounts/use-lists'; import { useLists } from 'pl-fe/queries/accounts/use-lists';
import type { Features } from 'pl-api'; import type { Circle, Features } from 'pl-api';
const messages = defineMessages({ const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@ -27,6 +28,8 @@ const messages = defineMessages({
local_long: { id: 'privacy.local.long', defaultMessage: 'Only visible on your instance' }, local_long: { id: 'privacy.local.long', defaultMessage: 'Only visible on your instance' },
list_short: { id: 'privacy.list.short', defaultMessage: 'List only' }, list_short: { id: 'privacy.list.short', defaultMessage: 'List only' },
list_long: { id: 'privacy.list.long', defaultMessage: 'Visible to members of a list' }, list_long: { id: 'privacy.list.long', defaultMessage: 'Visible to members of a list' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle only' },
circle_long: { id: 'privacy.circle.long', defaultMessage: 'Visible to members of a circle' },
subscribers_short: { id: 'privacy.subscribers.short', defaultMessage: 'Subscribers-only' }, subscribers_short: { id: 'privacy.subscribers.short', defaultMessage: 'Subscribers-only' },
subscribers_long: { id: 'privacy.subscribers.long', defaultMessage: 'Post to users subscribing you only' }, subscribers_long: { id: 'privacy.subscribers.long', defaultMessage: 'Post to users subscribing you only' },
@ -42,7 +45,7 @@ interface Option {
items?: Array<Omit<Option, 'items'>>; items?: Array<Omit<Option, 'items'>>;
} }
const getItems = (features: Features, lists: ReturnType<typeof getOrderedLists>, intl: IntlShape) => [ const getItems = (features: Features, lists: ReturnType<typeof getOrderedLists>, circles: Array<Circle>, intl: IntlShape) => [
{ {
icon: require('@tabler/icons/outline/world.svg'), icon: require('@tabler/icons/outline/world.svg'),
value: 'public', value: 'public',
@ -96,6 +99,17 @@ const getItems = (features: Features, lists: ReturnType<typeof getOrderedLists>,
text: intl.formatMessage(messages.list_short), text: intl.formatMessage(messages.list_short),
meta: intl.formatMessage(messages.list_long), meta: intl.formatMessage(messages.list_long),
} as Option : undefined, } as Option : undefined,
features.circles && Object.keys(circles).length ? {
icon: require('@tabler/icons/outline/chart-circles.svg'),
value: '',
items: Object.values(circles).map((circle) => ({
icon: require('@tabler/icons/outline/list.svg'),
value: `circle:${circle.id}`,
text: circle.title,
})),
text: intl.formatMessage(messages.circle_short),
meta: intl.formatMessage(messages.circle_long),
} as Option : undefined,
].filter((option): option is Option => !!option); ].filter((option): option is Option => !!option);
interface IPrivacyDropdown { interface IPrivacyDropdown {
@ -111,6 +125,7 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
const compose = useCompose(composeId); const compose = useCompose(composeId);
const { data: lists = [] } = useLists(getOrderedLists); const { data: lists = [] } = useLists(getOrderedLists);
const { data: circles = [] } = useCircles(getOrderedLists);
const value = compose.privacy; const value = compose.privacy;
const unavailable = compose.id; const unavailable = compose.id;
@ -118,7 +133,7 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
const onChange = (value: string) => value && dispatch(changeComposeVisibility(composeId, const onChange = (value: string) => value && dispatch(changeComposeVisibility(composeId,
value)); value));
const options = useMemo(() => getItems(features, lists, intl), [features, lists]); const options = useMemo(() => getItems(features, lists, circles, intl), [features, lists, circles]);
const items: Array<MenuItem> = options.map(item => ({ const items: Array<MenuItem> = options.map(item => ({
...item, ...item,
action: item.value ? () => onChange(item.value) : undefined, action: item.value ? () => onChange(item.value) : undefined,

View File

@ -15,11 +15,11 @@ import NewListForm from './components/new-list-form';
import type { List as ListEntity } from 'pl-api'; import type { List as ListEntity } from 'pl-api';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.lists', defaultMessage: 'Lists' }, heading: { id: 'column.circles', defaultMessage: 'Lists' },
subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' }, subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
}); });
const getOrderedLists = (lists: Array<ListEntity>) => { const getOrderedLists = (lists: Array<Pick<ListEntity, 'title'>>) => {
if (!lists) { if (!lists) {
return lists; return lists;
} }

View File

@ -64,6 +64,7 @@ import {
ChatIndex, ChatIndex,
ChatWidget, ChatWidget,
Circle, Circle,
Circles,
CommunityTimeline, CommunityTimeline,
ComposeEvent, ComposeEvent,
Conversations, Conversations,
@ -239,6 +240,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = React.memo(({ chil
{features.lists && <WrappedRoute path='/lists' layout={DefaultLayout} component={Lists} content={children} />} {features.lists && <WrappedRoute path='/lists' layout={DefaultLayout} component={Lists} content={children} />}
{features.lists && <WrappedRoute path='/list/:id' layout={DefaultLayout} component={ListTimeline} content={children} />} {features.lists && <WrappedRoute path='/list/:id' layout={DefaultLayout} component={ListTimeline} content={children} />}
{features.circles && <WrappedRoute path='/circles' layout={DefaultLayout} component={Circles} content={children} />}
{features.bookmarks && <WrappedRoute path='/bookmarks/all' layout={DefaultLayout} component={Bookmarks} content={children} />} {features.bookmarks && <WrappedRoute path='/bookmarks/all' layout={DefaultLayout} component={Bookmarks} content={children} />}
{features.bookmarks && <WrappedRoute path='/bookmarks/:id' layout={DefaultLayout} component={Bookmarks} content={children} />} {features.bookmarks && <WrappedRoute path='/bookmarks/:id' layout={DefaultLayout} component={Bookmarks} content={children} />}
<WrappedRoute path='/bookmarks' layout={DefaultLayout} component={BookmarkFolders} content={children} /> <WrappedRoute path='/bookmarks' layout={DefaultLayout} component={BookmarkFolders} content={children} />

View File

@ -14,6 +14,7 @@ export const Bookmarks = lazy(() => import('pl-fe/features/bookmarks'));
export const BubbleTimeline = lazy(() => import('pl-fe/features/bubble-timeline')); export const BubbleTimeline = lazy(() => import('pl-fe/features/bubble-timeline'));
export const ChatIndex = lazy(() => import('pl-fe/features/chats')); export const ChatIndex = lazy(() => import('pl-fe/features/chats'));
export const Circle = lazy(() => import('pl-fe/features/circle')); export const Circle = lazy(() => import('pl-fe/features/circle'));
export const Circles = lazy(() => import('pl-fe/features/circles'));
export const CommunityTimeline = lazy(() => import('pl-fe/features/community-timeline')); export const CommunityTimeline = lazy(() => import('pl-fe/features/community-timeline'));
export const ComposeEditor = lazy(() => import('pl-fe/features/compose/editor')); export const ComposeEditor = lazy(() => import('pl-fe/features/compose/editor'));
export const ComposeEvent = lazy(() => import('pl-fe/features/compose-event')); export const ComposeEvent = lazy(() => import('pl-fe/features/compose-event'));

View File

@ -314,6 +314,10 @@
"chats.main.blankslate.title": "No messages yet", "chats.main.blankslate.title": "No messages yet",
"chats.main.blankslate_with_chats.subtitle": "Select from one of your open chats or create a new message.", "chats.main.blankslate_with_chats.subtitle": "Select from one of your open chats or create a new message.",
"chats.main.blankslate_with_chats.title": "Select a chat", "chats.main.blankslate_with_chats.title": "Select a chat",
"circles.new.create": "Add circle",
"circles.new.create_title": "Add circle",
"circles.new.title_placeholder": "New circle title",
"circles.subheading": "Your circles",
"column.admin.announcements": "Announcements", "column.admin.announcements": "Announcements",
"column.admin.awaiting_approval": "Awaiting Approval", "column.admin.awaiting_approval": "Awaiting Approval",
"column.admin.create_announcement": "Create announcement", "column.admin.create_announcement": "Create announcement",
@ -344,6 +348,7 @@
"column.bubble": "Bubble timeline", "column.bubble": "Bubble timeline",
"column.chats": "Chats", "column.chats": "Chats",
"column.circle": "Interactions circle", "column.circle": "Interactions circle",
"column.circles": "Lists",
"column.community": "Local timeline", "column.community": "Local timeline",
"column.crypto_donate": "Donate cryptocurrency", "column.crypto_donate": "Donate cryptocurrency",
"column.developers": "Developers", "column.developers": "Developers",
@ -725,6 +730,7 @@
"empty_column.bookmarks": "You don't have any bookmarks yet. When you add one, it will show up here.", "empty_column.bookmarks": "You don't have any bookmarks yet. When you add one, it will show up here.",
"empty_column.bookmarks.folder": "You don't have any bookmarks in this folder yet. When you add one, it will show up here.", "empty_column.bookmarks.folder": "You don't have any bookmarks in this folder yet. When you add one, it will show up here.",
"empty_column.bubble": "There is nothing here! Write something publicly to fill it up", "empty_column.bubble": "There is nothing here! Write something publicly to fill it up",
"empty_column.circles": "You don't have any circles yet. When you create one, it will show up here.",
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
"empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.", "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
"empty_column.dislikes": "No one has disliked this post yet. When someone does, they will show up here.", "empty_column.dislikes": "No one has disliked this post yet. When someone does, they will show up here.",
@ -1315,6 +1321,8 @@
"preferences.options.privacy_public": "Public", "preferences.options.privacy_public": "Public",
"preferences.options.privacy_unlisted": "Unlisted", "preferences.options.privacy_unlisted": "Unlisted",
"privacy.change": "Adjust post privacy", "privacy.change": "Adjust post privacy",
"privacy.circle.long": "Visible to members of a circle",
"privacy.circle.short": "Circle only",
"privacy.direct.long": "Post to mentioned users only", "privacy.direct.long": "Post to mentioned users only",
"privacy.direct.short": "Direct", "privacy.direct.short": "Direct",
"privacy.list.long": "Visible to members of a list", "privacy.list.long": "Visible to members of a list",

View File

@ -0,0 +1,61 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useClient } from 'pl-fe/hooks/use-client';
import { useFeatures } from 'pl-fe/hooks/use-features';
import { queryClient } from '../client';
import type { Circle } from 'pl-api';
const useCircles = <T>(
select?: ((data: Array<Circle>) => T),
) => {
const client = useClient();
const features = useFeatures();
return useQuery({
queryKey: ['circles'],
queryFn: () => client.circles.fetchCircles(),
enabled: features.circles,
select,
});
};
const useCircle = (circleId?: string) => useCircles((data) => circleId ? data.find(circle => circle.id === circleId) : undefined);
const useCreateCircle = () => {
const client = useClient();
return useMutation({
mutationKey: ['circles', 'create'],
mutationFn: (title: string) => client.circles.createCircle(title),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['circles'] }),
});
};
const useDeleteCircle = () => {
const client = useClient();
return useMutation({
mutationKey: ['circles', 'delete'],
mutationFn: (circleId: string) => client.circles.deleteCircle(circleId),
onSuccess: (_, deletedCircleId) => {
queryClient.setQueryData<Array<Circle>>(
['circles'],
(prevData) => prevData?.filter(({ id }) => id !== deletedCircleId),
);
},
});
};
const useUpdateCircle = (circleId: string) => {
const client = useClient();
return useMutation({
mutationKey: ['circles', 'update', circleId],
mutationFn: (title: string) => client.circles.updateCircle(circleId, title),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['circles'] }),
});
};
export { useCircles, useCircle, useCreateCircle, useDeleteCircle, useUpdateCircle };