pl-fe: kmyblue circles management

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-08-16 10:21:22 +02:00
parent 87254127d6
commit 105e20e046
16 changed files with 324 additions and 20 deletions

View File

@ -168,7 +168,7 @@ import type {
GetChatMessagesParams,
GetChatsParams,
} from './params/chats';
import type { GetCircleStatusesParams } from './params/circles';
import type { GetCircleAccountsParams, GetCircleStatusesParams } from './params/circles';
import type { UpdateFileParams } from './params/drive';
import type {
CreateEventParams,
@ -5620,6 +5620,39 @@ class PlApiClient {
return response.json;
},
/**
* View accounts in a circle
* Requires features{@link Features.circles}.
*/
getCircleAccounts: async (circleId: string, params?: GetCircleAccountsParams) =>
this.#paginatedGet(`/api/v1/circles/${circleId}/accounts`, { params }, accountSchema),
/**
* Add accounts to a circle
* Add accounts to the given circle. Note that the user must be following these accounts.
* Requires features{@link Features.circles}.
*/
addCircleAccounts: async (circleId: string, accountIds: string[]) => {
const response = await this.request(`/api/v1/circles/${circleId}/accounts`, {
method: 'POST', body: { account_ids: accountIds },
});
return response.json as {};
},
/**
* Remove accounts from circle
* Remove accounts from the given circle.
* Requires features{@link Features.circles}.
*/
deleteCircleAccounts: async (circleId: string, accountIds: string[]) => {
const response = await this.request(`/api/v1/circles/${circleId}/accounts`, {
method: 'DELETE', body: { account_ids: accountIds },
});
return response.json as {};
},
getCircleStatuses: (circleId: string, params: GetCircleStatusesParams) =>
this.#paginatedGet(`/api/v1/circles/${circleId}/statuses`, { params }, statusSchema),
};

View File

@ -5,6 +5,12 @@ import { PaginationParams } from './common';
*/
type GetCircleStatusesParams = PaginationParams;
/**
* @category Request params
*/
type GetCircleAccountsParams = PaginationParams;
export type {
GetCircleStatusesParams,
GetCircleAccountsParams,
};

View File

@ -2,6 +2,7 @@ export * from './accounts';
export * from './admin';
export * from './apps';
export * from './chats';
export * from './circles';
export type { PaginationParams } from './common';
export * from './events';
export * from './filtering';

View File

@ -9,6 +9,7 @@ import { importEntities } from './importer';
import type {
Account as BaseAccount,
GetAccountStatusesParams,
GetCircleStatusesParams,
GroupTimelineParams,
HashtagTimelineParams,
HomeTimelineParams,
@ -260,6 +261,21 @@ const fetchListTimeline = (listId: string, expand = false, done = noOp) =>
return dispatch(handleTimelineExpand(timelineId, fn, false, done));
};
const fetchCircleTimeline = (circleId: string, expand = false, done = noOp) =>
async (dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const timelineId = `circle:${circleId}`;
const params: GetCircleStatusesParams = {};
// if (useSettingsStore.getState().settings.autoTranslate) params.language = getLocale();
if (expand && state.timelines[timelineId]?.isLoading) return;
const fn = (expand && state.timelines[timelineId]?.next?.()) || getClient(state).circles.getCircleStatuses(circleId, params);
return dispatch(handleTimelineExpand(timelineId, fn, false, done));
};
const fetchGroupTimeline = (groupId: string, { only_media, limit }: Record<string, any> = {}, expand = false, done = noOp) =>
async (dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
@ -378,6 +394,7 @@ export {
fetchBubbleTimeline,
fetchAccountTimeline,
fetchListTimeline,
fetchCircleTimeline,
fetchGroupTimeline,
fetchHashtagTimeline,
fetchLinkTimeline,

View File

@ -12,6 +12,7 @@ const MODAL_COMPONENTS = {
ALT_TEXT: lazy(() => import('pl-fe/modals/alt-text-modal')),
BIRTHDAYS: lazy(() => import('pl-fe/modals/birthdays-modal')),
BOOST: lazy(() => import('pl-fe/modals/boost-modal')),
CIRCLE_EDITOR: lazy(() => import('pl-fe/modals/circle-editor-modal')),
COMPARE_HISTORY: lazy(() => import('pl-fe/modals/compare-history-modal')),
COMPONENT: lazy(() => import('pl-fe/modals/component-modal')),
COMPOSE: lazy(() => import('pl-fe/modals/compose-modal')),

View File

@ -67,6 +67,7 @@ import {
ChatWidget,
Circle,
Circles,
CircleTimeline,
CommunityTimeline,
ComposeEvent,
Conversations,
@ -249,6 +250,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = React.memo(({ chil
{features.lists && <WrappedRoute path='/lists' layout={DefaultLayout} component={Lists} content={children} />}
{features.lists && <WrappedRoute path='/list/:id' layout={DefaultLayout} component={ListTimeline} content={children} />}
{features.circles && <WrappedRoute path='/circles/:id' layout={DefaultLayout} component={CircleTimeline} 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/:id' layout={DefaultLayout} component={Bookmarks} content={children} />}

View File

@ -16,6 +16,7 @@ export const BubbleTimeline = lazy(() => import('pl-fe/pages/timelines/bubble-ti
export const ChatIndex = lazy(() => import('pl-fe/pages/chats/chats'));
export const Circle = lazy(() => import('pl-fe/pages/fun/circle'));
export const Circles = lazy(() => import('pl-fe/pages/account-lists/circles'));
export const CircleTimeline = lazy(() => import('pl-fe/pages/timelines/circle-timeline'));
export const CommunityTimeline = lazy(() => import('pl-fe/pages/timelines/community-timeline'));
export const ComposeEvent = lazy(() => import('pl-fe/pages/statuses/compose-event'));
export const Conversations = lazy(() => import('pl-fe/pages/status-lists/conversations'));

View File

@ -353,9 +353,14 @@
"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.title": "Select a chat",
"circle.click_to_add": "Click here to add people",
"circles.add_to_circle": "Add to circle",
"circles.delete": "Delete circle",
"circles.edit": "Edit circle",
"circles.new.create": "Add circle",
"circles.new.create_title": "Add circle",
"circles.new.title_placeholder": "New circle title",
"circles.remove_from_circle": "Remove from circle",
"circles.subheading": "Your circles",
"column.admin.account": "Moderate @{acct}",
"column.admin.announcements": "Announcements",
@ -616,6 +621,9 @@
"confirmations.delete_bookmark_folder.confirm": "Delete folder",
"confirmations.delete_bookmark_folder.heading": "Delete \"{name}\" folder?",
"confirmations.delete_bookmark_folder.message": "Are you sure you want to delete the folder? The bookmarks will still be stored.",
"confirmations.delete_circle.confirm": "Delete",
"confirmations.delete_circle.heading": "Delete circle",
"confirmations.delete_circle.message": "Are you sure you want to permanently delete this circle?",
"confirmations.delete_event.confirm": "Delete",
"confirmations.delete_event.heading": "Delete event",
"confirmations.delete_event.message": "Are you sure you want to delete this event?",
@ -788,6 +796,8 @@
"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.bubble": "There is nothing here! Write something publicly to fill it up",
"empty_column.circle": "There is nothing in this circle yet. When members of this circle create new posts, they will appear here.",
"empty_column.circle_members": "There are no members in this circle. Use search to find users to add.",
"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.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",

View File

@ -0,0 +1,82 @@
import React, { useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { CardHeader, CardTitle } from 'pl-fe/components/ui/card';
import Modal from 'pl-fe/components/ui/modal';
import Spinner from 'pl-fe/components/ui/spinner';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import { useAddAccountsToCircle, useCircle, useCircleAccounts, useRemoveAccountsFromCircle } from 'pl-fe/queries/accounts/use-circles';
import { useAccountSearch } from 'pl-fe/queries/search/use-search-accounts';
import Account from './list-editor-modal/components/account';
import Search from './list-editor-modal/components/search';
import type { BaseModalProps } from 'pl-fe/features/ui/components/modal-root';
const messages = defineMessages({
addToCircle: { id: 'circles.add_to_circle', defaultMessage: 'Add to circle' },
removeFromCircle: { id: 'circles.remove_from_circle', defaultMessage: 'Remove from circle' },
});
interface CircleEditorModalProps {
circleId: string;
}
const CircleEditorModal: React.FC<BaseModalProps & CircleEditorModalProps> = ({ circleId, onClose }) => {
const intl = useIntl();
const [searchValue, setSearchValue] = useState('');
const { data: circle } = useCircle(circleId);
const { data: accountIds = [] } = useCircleAccounts(circleId);
const { data: searchAccountIds = [] } = useAccountSearch(searchValue, { following: true, limit: 5 });
const { mutate: addToCircle } = useAddAccountsToCircle(circleId);
const { mutate: removeFromCircle } = useRemoveAccountsFromCircle(circleId);
const onAdd = (accountId: string) => addToCircle([accountId]);
const onRemove = (accountId: string) => removeFromCircle([accountId]);
const onClickClose = () => {
onClose('CIRCLE_EDITOR');
};
return (
<Modal
title={<FormattedMessage id='circles.edit' defaultMessage='Edit circle' />}
onClose={onClickClose}
>
{circle ? (
<Stack space={2}>
{accountIds.length > 0 ? (
<div>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.removeFromCircle)} />
</CardHeader>
<div className='max-h-48 overflow-y-auto'>
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} added={accountIds.includes(accountId)} onAdd={onAdd} onRemove={onRemove} />)}
</div>
</div>
) : (
<Text theme='muted' size='sm'>
<FormattedMessage id='empty_column.circle_members' defaultMessage='There are no members in this circle. Use search to find users to add.' />
</Text>
)}
<div>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.addToCircle)} />
</CardHeader>
<Search value={searchValue} onSubmit={setSearchValue} />
<div className='max-h-48 overflow-y-auto'>
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} added={accountIds.includes(accountId)} onAdd={onAdd} onRemove={onRemove} />)}
</div>
</div>
</Stack>
) : <Spinner />}
</Modal>
);
};
export { CircleEditorModal as default, type CircleEditorModalProps };

View File

@ -4,7 +4,6 @@ import { defineMessages, useIntl } from 'react-intl';
import IconButton from 'pl-fe/components/icon-button';
import HStack from 'pl-fe/components/ui/hstack';
import AccountContainer from 'pl-fe/containers/account-container';
import { useAddAccountsToList, useRemoveAccountsFromList } from 'pl-fe/queries/accounts/use-lists';
const messages = defineMessages({
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
@ -12,26 +11,21 @@ const messages = defineMessages({
});
interface IAccount {
listId: string;
accountId: string;
added?: boolean;
onAdd: (accountId: string) => void;
onRemove: (accountId: string) => void;
}
const Account: React.FC<IAccount> = ({ listId, accountId, added }) => {
const Account: React.FC<IAccount> = ({ accountId, added, onAdd, onRemove }) => {
const intl = useIntl();
const { mutate: addToList } = useAddAccountsToList(listId);
const { mutate: removeFromList } = useRemoveAccountsFromList(listId);
const onAdd = () => addToList([accountId]);
const onRemove = () => removeFromList([accountId]);
let button;
if (added) {
button = <IconButton src={require('@tabler/icons/outline/x.svg')} iconClassName='h-5 w-5' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
button = <IconButton src={require('@tabler/icons/outline/x.svg')} iconClassName='h-5 w-5' title={intl.formatMessage(messages.remove)} onClick={() => onRemove(accountId)} />;
} else {
button = <IconButton src={require('@tabler/icons/outline/plus.svg')} iconClassName='h-5 w-5' title={intl.formatMessage(messages.add)} onClick={onAdd} />;
button = <IconButton src={require('@tabler/icons/outline/plus.svg')} iconClassName='h-5 w-5' title={intl.formatMessage(messages.add)} onClick={() => onAdd(accountId)} />;
}
return (

View File

@ -37,7 +37,6 @@ const ListForm: React.FC<IListForm> = ({
const { data: list } = useList(listId);
const { mutate: updateList, isPending: disabled } = useUpdateList(listId!);
console.log(list);
const [title, setTitle] = useState(list!.title);
const [repliesPolicy, setRepliesPolicy] = useState(list!.replies_policy);
const [exclusive, setExclusive] = useState(list!.exclusive);

View File

@ -4,7 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { CardHeader, CardTitle } from 'pl-fe/components/ui/card';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import { useListAccounts } from 'pl-fe/queries/accounts/use-lists';
import { useAddAccountsToList, useListAccounts, useRemoveAccountsFromList } from 'pl-fe/queries/accounts/use-lists';
import { useAccountSearch } from 'pl-fe/queries/search/use-search-accounts';
import Account from './account';
@ -27,6 +27,12 @@ const ListMembersForm: React.FC<IListMembersForm> = ({ listId }) => {
const { data: accountIds = [] } = useListAccounts(listId);
const { data: searchAccountIds = [] } = useAccountSearch(searchValue, { following: true, limit: 5 });
const { mutate: addToList } = useAddAccountsToList(listId);
const { mutate: removeFromList } = useRemoveAccountsFromList(listId);
const onAdd = (accountId: string) => addToList([accountId]);
const onRemove = (accountId: string) => removeFromList([accountId]);
return (
<Stack space={2}>
{accountIds.length > 0 ? (
@ -35,7 +41,7 @@ const ListMembersForm: React.FC<IListMembersForm> = ({ listId }) => {
<CardTitle title={intl.formatMessage(messages.removeFromList)} />
</CardHeader>
<div className='max-h-48 overflow-y-auto'>
{accountIds.map(accountId => <Account key={accountId} listId={listId} accountId={accountId} added={accountIds.includes(accountId)} />)}
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} added={accountIds.includes(accountId)} onAdd={onAdd} onRemove={onRemove} />)}
</div>
</div>
) : (
@ -50,7 +56,7 @@ const ListMembersForm: React.FC<IListMembersForm> = ({ listId }) => {
</CardHeader>
<Search value={searchValue} onSubmit={setSearchValue} />
<div className='max-h-48 overflow-y-auto'>
{searchAccountIds.map(accountId => <Account key={accountId} listId={listId} accountId={accountId} added={accountIds.includes(accountId)} />)}
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} added={accountIds.includes(accountId)} onAdd={onAdd} onRemove={onRemove} />)}
</div>
</div>
</Stack>

View File

@ -97,7 +97,7 @@ const CirclesPage: React.FC = () => {
{circles.map((circle) => (
<ListItem
key={circle.id}
// to={`/circles/${circle.id}`}
to={`/circles/${circle.id}`}
label={
<HStack alignItems='center' space={2}>
<Icon src={require('@tabler/icons/outline/list.svg')} size={20} />

View File

@ -0,0 +1,118 @@
import React, { useEffect } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { useParams } from 'react-router-dom';
import { fetchCircleTimeline } from 'pl-fe/actions/timelines';
import DropdownMenu from 'pl-fe/components/dropdown-menu';
import MissingIndicator from 'pl-fe/components/missing-indicator';
import Button from 'pl-fe/components/ui/button';
import Column from 'pl-fe/components/ui/column';
import Spinner from 'pl-fe/components/ui/spinner';
import Timeline from 'pl-fe/features/ui/components/timeline';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useIsMobile } from 'pl-fe/hooks/use-is-mobile';
import { useTheme } from 'pl-fe/hooks/use-theme';
import { useCircle, useDeleteCircle } from 'pl-fe/queries/accounts/use-circles';
import { useModalsStore } from 'pl-fe/stores/modals';
const messages = defineMessages({
deleteHeading: { id: 'confirmations.delete_circle.heading', defaultMessage: 'Delete circle' },
deleteMessage: { id: 'confirmations.delete_circle.message', defaultMessage: 'Are you sure you want to permanently delete this circle?' },
deleteConfirm: { id: 'confirmations.delete_circle.confirm', defaultMessage: 'Delete' },
editCircle: { id: 'circles.edit', defaultMessage: 'Edit circle' },
deleteCircle: { id: 'circles.delete', defaultMessage: 'Delete circle' },
});
const CircleTimelinePage: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { id } = useParams<{ id: string }>();
const theme = useTheme();
const isMobile = useIsMobile();
const { openModal } = useModalsStore();
const { data: circle, isFetching } = useCircle(id);
const { mutate: deleteCircle } = useDeleteCircle();
useEffect(() => {
dispatch(fetchCircleTimeline(id));
}, [id]);
const handleLoadMore = () => {
dispatch(fetchCircleTimeline(id, true));
};
const handleEditClick = () => {
openModal('CIRCLE_EDITOR', { circleId: id });
};
const handleDeleteClick = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault();
openModal('CONFIRM', {
heading: intl.formatMessage(messages.deleteHeading),
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
deleteCircle(id);
},
});
};
const title = circle ? circle.title : id;
if (!circle && isFetching) {
return (
<Column>
<div>
<Spinner />
</div>
</Column>
);
} else if (!circle) {
return (
<MissingIndicator />
);
}
const emptyMessage = (
<div>
<FormattedMessage id='empty_column.circle' defaultMessage='There is nothing in this circle yet. When members of this circle create new posts, 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.editCircle),
action: handleEditClick,
icon: require('@tabler/icons/outline/edit.svg'),
},
{
text: intl.formatMessage(messages.deleteCircle),
action: handleDeleteClick,
icon: require('@tabler/icons/outline/trash.svg'),
},
];
return (
<Column
label={title}
action={<DropdownMenu items={items} src={require('@tabler/icons/outline/dots-vertical.svg')} />}
transparent={!isMobile}
>
<Timeline
className='black:p-0 black:sm:p-4 black:sm:pt-0'
loadMoreClassName='black:sm:mx-4'
scrollKey='circle_timeline'
timelineId={`circle:${id}`}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
divideType={(theme === 'black' || isMobile) ? 'border' : 'space'}
/>
</Column>
);
};
export { CircleTimelinePage as default };

View File

@ -1,11 +1,14 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { type InfiniteData, 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 { filterById } from '../utils/filter-id';
import { makePaginatedResponseQuery } from '../utils/make-paginated-response-query';
import { minifyAccountList } from '../utils/minify-list';
import type { Circle } from 'pl-api';
import type { Circle, PaginatedResponse } from 'pl-api';
const useCircles = <T>(
select?: ((data: Array<Circle>) => T),
@ -58,4 +61,33 @@ const useUpdateCircle = (circleId: string) => {
});
};
export { useCircles, useCircle, useCreateCircle, useDeleteCircle, useUpdateCircle };
const useCircleAccounts = makePaginatedResponseQuery(
(circleId: string) => ['accountsLists', 'circles', circleId],
(client, [circleId]) => client.circles.getCircleAccounts(circleId).then(minifyAccountList),
);
const useAddAccountsToCircle = (circleId: string) => {
const client = useClient();
return useMutation({
mutationKey: ['accountsLists', 'circles', circleId, 'add'],
mutationFn: (accountIds: Array<string>) => client.circles.addCircleAccounts(circleId, accountIds),
onSettled: (_, __, accountIds) => {
queryClient.invalidateQueries({ queryKey: ['accountsLists', 'circles', circleId] });
},
});
};
const useRemoveAccountsFromCircle = (circleId: string) => {
const client = useClient();
return useMutation({
mutationKey: ['accountsLists', 'circles', circleId, 'remove'],
mutationFn: (accountIds: Array<string>) => client.circles.deleteCircleAccounts(circleId, accountIds),
onSettled: (_, __, accountIds) => {
queryClient.setQueryData<InfiniteData<PaginatedResponse<string>>>(['accountsLists', 'circles', circleId], filterById(accountIds));
},
});
};
export { useCircles, useCircle, useCreateCircle, useDeleteCircle, useUpdateCircle, useCircleAccounts, useAddAccountsToCircle, useRemoveAccountsFromCircle };

View File

@ -7,6 +7,7 @@ import type { ICryptoAddress } from 'pl-fe/features/crypto-donate/components/cry
import type { ModalType } from 'pl-fe/features/ui/components/modal-root';
import type { AltTextModalProps } from 'pl-fe/modals/alt-text-modal';
import type { BoostModalProps } from 'pl-fe/modals/boost-modal';
import type { CircleEditorModalProps } from 'pl-fe/modals/circle-editor-modal';
import type { CompareHistoryModalProps } from 'pl-fe/modals/compare-history-modal';
import type { ComponentModalProps } from 'pl-fe/modals/component-modal';
import type { ComposeInteractionPolicyModalProps } from 'pl-fe/modals/compose-interaction-policy-modal';
@ -43,6 +44,7 @@ type OpenModalProps =
| [type: 'ALT_TEXT', props: AltTextModalProps]
| [type: 'BIRTHDAYS' | 'CREATE_GROUP' | 'HOTKEYS']
| [type: 'BOOST', props: BoostModalProps]
| [type: 'CIRCLE_EDITOR', props: CircleEditorModalProps]
| [type: 'COMPARE_HISTORY', props: CompareHistoryModalProps]
| [type: 'COMPONENT', props: ComponentModalProps]
| [type: 'COMPOSE', props?: ComposeModalProps]