diff --git a/packages/pl-fe/src/components/sidebar-menu.tsx b/packages/pl-fe/src/components/sidebar-menu.tsx index 7ae1f5a23..aaa3fd4d5 100644 --- a/packages/pl-fe/src/components/sidebar-menu.tsx +++ b/packages/pl-fe/src/components/sidebar-menu.tsx @@ -37,6 +37,7 @@ const messages = defineMessages({ profileDirectory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' }, bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, lists: { id: 'column.lists', defaultMessage: 'Lists' }, + circles: { id: 'column.circles', defaultMessage: 'Circles' }, groups: { id: 'column.groups', defaultMessage: 'Groups' }, events: { id: 'column.events', defaultMessage: 'Events' }, dashboard: { id: 'navigation.dashboard', defaultMessage: 'Dashboard' }, @@ -283,6 +284,15 @@ const SidebarMenu: React.FC = React.memo((): JSX.Element | null => { /> )} + {features.circles && ( + + )} + {features.events && ( { }); } + if (features.circles) { + menu.push({ + to: '/circles', + text: intl.formatMessage(messages.circles), + icon: require('@tabler/icons/outline/chart-circles.svg'), + }); + } + if (features.events) { menu.push({ to: '/events', diff --git a/packages/pl-fe/src/features/circles/components/new-circle-form.tsx b/packages/pl-fe/src/features/circles/components/new-circle-form.tsx new file mode 100644 index 000000000..1e6b9341b --- /dev/null +++ b/packages/pl-fe/src/features/circles/components/new-circle-form.tsx @@ -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) => { + setTitle(e.target.value); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + createCircle(title); + }; + + const label = intl.formatMessage(messages.label); + const create = intl.formatMessage(messages.create); + + return ( +
+ + + + + +
+ ); +}; + +export { NewCircleForm as default }; diff --git a/packages/pl-fe/src/features/circles/index.tsx b/packages/pl-fe/src/features/circles/index.tsx new file mode 100644 index 000000000..b46e48ad8 --- /dev/null +++ b/packages/pl-fe/src/features/circles/index.tsx @@ -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 ( + + + + ); + } + + const emptyMessage = ; + + return ( + + + + + {!Object.keys(circles).length ? ( + + {emptyMessage} + + ) : ( + + {circles.map((circle) => ( + + + {circle.title} + + } + /> + ))} + + )} + + + ); +}; + +export { Circles as default }; diff --git a/packages/pl-fe/src/features/compose/components/privacy-dropdown.tsx b/packages/pl-fe/src/features/compose/components/privacy-dropdown.tsx index 332aaf8ac..7ce95950c 100644 --- a/packages/pl-fe/src/features/compose/components/privacy-dropdown.tsx +++ b/packages/pl-fe/src/features/compose/components/privacy-dropdown.tsx @@ -8,9 +8,10 @@ import { getOrderedLists } from 'pl-fe/features/lists'; import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useCompose } from 'pl-fe/hooks/use-compose'; 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 type { Features } from 'pl-api'; +import type { Circle, Features } from 'pl-api'; const messages = defineMessages({ 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' }, list_short: { id: 'privacy.list.short', defaultMessage: 'List only' }, 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_long: { id: 'privacy.subscribers.long', defaultMessage: 'Post to users subscribing you only' }, @@ -42,7 +45,7 @@ interface Option { items?: Array>; } -const getItems = (features: Features, lists: ReturnType, intl: IntlShape) => [ +const getItems = (features: Features, lists: ReturnType, circles: Array, intl: IntlShape) => [ { icon: require('@tabler/icons/outline/world.svg'), value: 'public', @@ -96,6 +99,17 @@ const getItems = (features: Features, lists: ReturnType, text: intl.formatMessage(messages.list_short), meta: intl.formatMessage(messages.list_long), } 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); interface IPrivacyDropdown { @@ -111,6 +125,7 @@ const PrivacyDropdown: React.FC = ({ const compose = useCompose(composeId); const { data: lists = [] } = useLists(getOrderedLists); + const { data: circles = [] } = useCircles(getOrderedLists); const value = compose.privacy; const unavailable = compose.id; @@ -118,7 +133,7 @@ const PrivacyDropdown: React.FC = ({ const onChange = (value: string) => value && dispatch(changeComposeVisibility(composeId, value)); - const options = useMemo(() => getItems(features, lists, intl), [features, lists]); + const options = useMemo(() => getItems(features, lists, circles, intl), [features, lists, circles]); const items: Array = options.map(item => ({ ...item, action: item.value ? () => onChange(item.value) : undefined, diff --git a/packages/pl-fe/src/features/lists/index.tsx b/packages/pl-fe/src/features/lists/index.tsx index e5423caa3..dc8b2dce5 100644 --- a/packages/pl-fe/src/features/lists/index.tsx +++ b/packages/pl-fe/src/features/lists/index.tsx @@ -15,11 +15,11 @@ import NewListForm from './components/new-list-form'; import type { List as ListEntity } from 'pl-api'; const messages = defineMessages({ - heading: { id: 'column.lists', defaultMessage: 'Lists' }, + heading: { id: 'column.circles', defaultMessage: 'Lists' }, subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' }, }); -const getOrderedLists = (lists: Array) => { +const getOrderedLists = (lists: Array>) => { if (!lists) { return lists; } diff --git a/packages/pl-fe/src/features/ui/index.tsx b/packages/pl-fe/src/features/ui/index.tsx index ac1fc2fd4..2af1fdf8c 100644 --- a/packages/pl-fe/src/features/ui/index.tsx +++ b/packages/pl-fe/src/features/ui/index.tsx @@ -64,6 +64,7 @@ import { ChatIndex, ChatWidget, Circle, + Circles, CommunityTimeline, ComposeEvent, Conversations, @@ -239,6 +240,7 @@ const SwitchingColumnsArea: React.FC = React.memo(({ chil {features.lists && } {features.lists && } + {features.circles && } {features.bookmarks && } {features.bookmarks && } diff --git a/packages/pl-fe/src/features/ui/util/async-components.ts b/packages/pl-fe/src/features/ui/util/async-components.ts index f924aaa1b..7db1d5312 100644 --- a/packages/pl-fe/src/features/ui/util/async-components.ts +++ b/packages/pl-fe/src/features/ui/util/async-components.ts @@ -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 ChatIndex = lazy(() => import('pl-fe/features/chats')); 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 ComposeEditor = lazy(() => import('pl-fe/features/compose/editor')); export const ComposeEvent = lazy(() => import('pl-fe/features/compose-event')); diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index 31742bc1c..03878f44d 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -314,6 +314,10 @@ "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", + "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.awaiting_approval": "Awaiting Approval", "column.admin.create_announcement": "Create announcement", @@ -344,6 +348,7 @@ "column.bubble": "Bubble timeline", "column.chats": "Chats", "column.circle": "Interactions circle", + "column.circles": "Lists", "column.community": "Local timeline", "column.crypto_donate": "Donate cryptocurrency", "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.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.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.", "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_unlisted": "Unlisted", "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.short": "Direct", "privacy.list.long": "Visible to members of a list", diff --git a/packages/pl-fe/src/queries/accounts/use-circles.ts b/packages/pl-fe/src/queries/accounts/use-circles.ts new file mode 100644 index 000000000..60b5c3a6d --- /dev/null +++ b/packages/pl-fe/src/queries/accounts/use-circles.ts @@ -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 = ( + select?: ((data: Array) => 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>( + ['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 };