From dba85273b206c94da8247d57550a2ffb6aac1c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 30 Oct 2024 22:32:10 +0100 Subject: [PATCH 1/3] pl-fe: Display interaction requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../statuses/use-interaction-requests.ts | 50 +++++++++++++++++++ .../pl-fe/src/components/sidebar-menu.tsx | 12 +++++ .../src/components/sidebar-navigation.tsx | 12 +++++ .../features/interaction-requests/index.tsx | 11 ++++ packages/pl-fe/src/features/ui/index.tsx | 2 + .../src/features/ui/util/async-components.ts | 1 + 6 files changed, 88 insertions(+) create mode 100644 packages/pl-fe/src/api/hooks/statuses/use-interaction-requests.ts create mode 100644 packages/pl-fe/src/features/interaction-requests/index.tsx diff --git a/packages/pl-fe/src/api/hooks/statuses/use-interaction-requests.ts b/packages/pl-fe/src/api/hooks/statuses/use-interaction-requests.ts new file mode 100644 index 000000000..b77264808 --- /dev/null +++ b/packages/pl-fe/src/api/hooks/statuses/use-interaction-requests.ts @@ -0,0 +1,50 @@ +import { type InfiniteData, useInfiniteQuery } from '@tanstack/react-query'; + +import { importEntities } from 'pl-fe/actions/importer'; +import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; +import { useClient } from 'pl-fe/hooks/use-client'; +import { useFeatures } from 'pl-fe/hooks/use-features'; + +import type { InteractionRequest, PaginatedResponse } from 'pl-api'; +import type { AppDispatch } from 'pl-fe/store'; + +const minifyInteractionRequest = ({ account, status, reply, ...interactionRequest }: InteractionRequest) => ({ + account_id: account.id, + status: status?.id || null, + reply: reply?.id || null, + ...interactionRequest, +}); + +type MinifiedInteractionRequest = ReturnType; + +const minifyInteractionRequestsList = (dispatch: AppDispatch, { previous, next, items, ...response }: PaginatedResponse): PaginatedResponse => { + dispatch(importEntities({ + statuses: items.map(item => [item.status, item.reply]).flat(), + })); + + return { + ...response, + previous: previous ? () => previous().then(response => minifyInteractionRequestsList(dispatch, response)) : null, + next: next ? () => next().then(response => minifyInteractionRequestsList(dispatch, response)) : null, + items: items.map(minifyInteractionRequest), + }; +}; + +const useInteractionRequests = (select?: (data: InfiniteData>) => T) => { + const client = useClient(); + const features = useFeatures(); + const dispatch = useAppDispatch(); + + return useInfiniteQuery({ + queryKey: ['interaction_requests'], + queryFn: ({ pageParam }) => pageParam.next?.() || client.interactionRequests.getInteractionRequests().then(response => minifyInteractionRequestsList(dispatch, response)), + initialPageParam: { previous: null, next: null, items: [], partial: false } as PaginatedResponse, + getNextPageParam: (page) => page.next ? page : undefined, + enabled: features.interactionRequests, + select, + }); +}; + +const useInteractionRequestsCount = () => useInteractionRequests(data => data.pages.map(({ items }) => items).flat().length); + +export { useInteractionRequests, useInteractionRequestsCount }; diff --git a/packages/pl-fe/src/components/sidebar-menu.tsx b/packages/pl-fe/src/components/sidebar-menu.tsx index 90e51a2b5..f2338dcc1 100644 --- a/packages/pl-fe/src/components/sidebar-menu.tsx +++ b/packages/pl-fe/src/components/sidebar-menu.tsx @@ -6,6 +6,7 @@ import { Link, NavLink } from 'react-router-dom'; import { fetchOwnAccounts, logOut, switchAccount } from 'pl-fe/actions/auth'; import { useAccount } from 'pl-fe/api/hooks/accounts/use-account'; +import { useInteractionRequestsCount } from 'pl-fe/api/hooks/statuses/use-interaction-requests'; import Account from 'pl-fe/components/account'; import Divider from 'pl-fe/components/ui/divider'; import HStack from 'pl-fe/components/ui/hstack'; @@ -42,6 +43,7 @@ const messages = defineMessages({ drafts: { id: 'navigation.drafts', defaultMessage: 'Drafts' }, addAccount: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' }, followRequests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, + interactionRequests: { id: 'navigation.interaction_requests', defaultMessage: 'Interaction requests' }, close: { id: 'lightbox.close', defaultMessage: 'Close' }, login: { id: 'account.login', defaultMessage: 'Log in' }, register: { id: 'account.register', defaultMessage: 'Sign up' }, @@ -96,6 +98,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { const otherAccounts: ImmutableList = useAppSelector((state) => getOtherAccounts(state)); const { settings } = useSettingsStore(); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); + const interactionRequestsCount = useInteractionRequestsCount().data || 0; const scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size); const draftCount = useAppSelector((state) => state.draft_statuses.size); // const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); @@ -232,6 +235,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { /> )} + {(interactionRequestsCount > 0) && ( + + )} + {features.conversations && ( { const notificationCount = useAppSelector((state) => state.notifications.unread); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); + const interactionRequestsCount = useInteractionRequestsCount().data || 0; const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); const scheduledStatusCount = useAppSelector((state) => state.scheduled_statuses.size); const draftCount = useAppSelector((state) => state.draft_statuses.size); @@ -74,6 +77,15 @@ const SidebarNavigation = () => { }); } + if (interactionRequestsCount > 0) { + menu.push({ + to: '/interaction_requests', + text: intl.formatMessage(messages.interactionRequests), + icon: require('@tabler/icons/outline/flag-question.svg'), + count: interactionRequestsCount, + }); + } + if (features.bookmarks) { menu.push({ to: '/bookmarks', diff --git a/packages/pl-fe/src/features/interaction-requests/index.tsx b/packages/pl-fe/src/features/interaction-requests/index.tsx new file mode 100644 index 000000000..97250fed8 --- /dev/null +++ b/packages/pl-fe/src/features/interaction-requests/index.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { useInteractionRequests } from 'pl-fe/api/hooks/statuses/use-interaction-requests'; + +const InteractionRequests = () => { + const interactionRequestsQuery = useInteractionRequests(); + + return <>{JSON.stringify(interactionRequestsQuery.data)}; +}; + +export { InteractionRequests as default }; diff --git a/packages/pl-fe/src/features/ui/index.tsx b/packages/pl-fe/src/features/ui/index.tsx index 02b1e0d54..a9f574d23 100644 --- a/packages/pl-fe/src/features/ui/index.tsx +++ b/packages/pl-fe/src/features/ui/index.tsx @@ -138,6 +138,7 @@ import { Circle, BubbleTimeline, InteractionPolicies, + InteractionRequests, } from './util/async-components'; import GlobalHotkeys from './util/global-hotkeys'; import { WrappedRoute } from './util/react-router-helpers'; @@ -255,6 +256,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {(features.filters || features.filtersV2) && } {(features.filters || features.filtersV2) && } {(features.followedHashtagsList) && } + {features.interactionRequests && } 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 2c2cddd45..d97ffd6f8 100644 --- a/packages/pl-fe/src/features/ui/util/async-components.ts +++ b/packages/pl-fe/src/features/ui/util/async-components.ts @@ -56,6 +56,7 @@ export const HomeTimeline = lazy(() => import('pl-fe/features/home-timeline')); export const ImportData = lazy(() => import('pl-fe/features/import-data')); export const IntentionalError = lazy(() => import('pl-fe/features/intentional-error')); export const InteractionPolicies = lazy(() => import('pl-fe/features/interaction-policies')); +export const InteractionRequests = lazy(() => import('pl-fe/features/interaction-requests')); export const LandingTimeline = lazy(() => import('pl-fe/features/landing-timeline')); export const Lists = lazy(() => import('pl-fe/features/lists')); export const ListTimeline = lazy(() => import('pl-fe/features/list-timeline')); From adf1fe9b7dc0b52c37e183cbc109d79f2fb0d0fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 30 Oct 2024 23:38:04 +0100 Subject: [PATCH 2/3] pl-fe: Add interaction requests mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../statuses/use-interaction-requests.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/pl-fe/src/api/hooks/statuses/use-interaction-requests.ts b/packages/pl-fe/src/api/hooks/statuses/use-interaction-requests.ts index b77264808..379b7ea54 100644 --- a/packages/pl-fe/src/api/hooks/statuses/use-interaction-requests.ts +++ b/packages/pl-fe/src/api/hooks/statuses/use-interaction-requests.ts @@ -1,4 +1,4 @@ -import { type InfiniteData, useInfiniteQuery } from '@tanstack/react-query'; +import { type InfiniteData, useInfiniteQuery, useMutation } from '@tanstack/react-query'; import { importEntities } from 'pl-fe/actions/importer'; import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; @@ -47,4 +47,29 @@ const useInteractionRequests = (select?: (data: InfiniteData useInteractionRequests(data => data.pages.map(({ items }) => items).flat().length); -export { useInteractionRequests, useInteractionRequestsCount }; +const useAuthorizeInteractionRequestMutation = () => { + const client = useClient(); + const { refetch } = useInteractionRequests(); + + return useMutation({ + mutationFn: (requestId: string) => client.interactionRequests.authorizeInteractionRequest(requestId), + onSettled: () => refetch(), + }); +}; + +const useRejectInteractionRequestMutation = () => { + const client = useClient(); + const { refetch } = useInteractionRequests(); + + return useMutation({ + mutationFn: (requestId: string) => client.interactionRequests.rejectInteractionRequest(requestId), + onSettled: () => refetch(), + }); +}; + +export { + useInteractionRequests, + useInteractionRequestsCount, + useAuthorizeInteractionRequestMutation, + useRejectInteractionRequestMutation, +}; From 02a601a8e8348a81ef845e9701747937db11af49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 31 Oct 2024 20:48:30 +0100 Subject: [PATCH 3/3] pl-fe: Allow maanaging interaction requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../statuses/use-interaction-requests.ts | 26 +- .../pl-fe/src/components/status-content.tsx | 31 ++- .../features/interaction-requests/index.tsx | 263 +++++++++++++++++- .../notifications/components/notification.tsx | 2 +- .../src/features/notifications/index.tsx | 3 +- .../components/modals/report-modal/index.tsx | 2 +- packages/pl-fe/src/locales/en.json | 12 + 7 files changed, 314 insertions(+), 25 deletions(-) diff --git a/packages/pl-fe/src/api/hooks/statuses/use-interaction-requests.ts b/packages/pl-fe/src/api/hooks/statuses/use-interaction-requests.ts index 379b7ea54..b28aa065b 100644 --- a/packages/pl-fe/src/api/hooks/statuses/use-interaction-requests.ts +++ b/packages/pl-fe/src/api/hooks/statuses/use-interaction-requests.ts @@ -10,8 +10,8 @@ import type { AppDispatch } from 'pl-fe/store'; const minifyInteractionRequest = ({ account, status, reply, ...interactionRequest }: InteractionRequest) => ({ account_id: account.id, - status: status?.id || null, - reply: reply?.id || null, + status_id: status?.id || null, + reply_id: reply?.id || null, ...interactionRequest, }); @@ -30,13 +30,15 @@ const minifyInteractionRequestsList = (dispatch: AppDispatch, { previous, next, }; }; -const useInteractionRequests = (select?: (data: InfiniteData>) => T) => { +const useInteractionRequests = ( + select?: ((data: InfiniteData>) => T), +) => { const client = useClient(); const features = useFeatures(); const dispatch = useAppDispatch(); return useInfiniteQuery({ - queryKey: ['interaction_requests'], + queryKey: ['interactionRequests'], queryFn: ({ pageParam }) => pageParam.next?.() || client.interactionRequests.getInteractionRequests().then(response => minifyInteractionRequestsList(dispatch, response)), initialPageParam: { previous: null, next: null, items: [], partial: false } as PaginatedResponse, getNextPageParam: (page) => page.next ? page : undefined, @@ -45,24 +47,30 @@ const useInteractionRequests = (select?: (data: InfiniteData useInteractionRequests( + (data: InfiniteData>) => data.pages.map(page => page.items).flat(), +); + const useInteractionRequestsCount = () => useInteractionRequests(data => data.pages.map(({ items }) => items).flat().length); -const useAuthorizeInteractionRequestMutation = () => { +const useAuthorizeInteractionRequestMutation = (requestId: string) => { const client = useClient(); const { refetch } = useInteractionRequests(); return useMutation({ - mutationFn: (requestId: string) => client.interactionRequests.authorizeInteractionRequest(requestId), + mutationKey: ['interactionRequests', requestId], + mutationFn: () => client.interactionRequests.authorizeInteractionRequest(requestId), onSettled: () => refetch(), }); }; -const useRejectInteractionRequestMutation = () => { +const useRejectInteractionRequestMutation = (requestId: string) => { const client = useClient(); const { refetch } = useInteractionRequests(); return useMutation({ - mutationFn: (requestId: string) => client.interactionRequests.rejectInteractionRequest(requestId), + mutationKey: ['interactionRequests', requestId], + mutationFn: () => client.interactionRequests.rejectInteractionRequest(requestId), onSettled: () => refetch(), }); }; @@ -70,6 +78,8 @@ const useRejectInteractionRequestMutation = () => { export { useInteractionRequests, useInteractionRequestsCount, + useFlatInteractionRequests, useAuthorizeInteractionRequestMutation, useRejectInteractionRequestMutation, + type MinifiedInteractionRequest, }; diff --git a/packages/pl-fe/src/components/status-content.tsx b/packages/pl-fe/src/components/status-content.tsx index c1e0795bc..8d8f22684 100644 --- a/packages/pl-fe/src/components/status-content.tsx +++ b/packages/pl-fe/src/components/status-content.tsx @@ -27,11 +27,17 @@ interface IReadMoreButton { onClick: React.MouseEventHandler; quote?: boolean; poll?: boolean; + preview?: boolean; } /** Button to expand a truncated status (due to too much content) */ -const ReadMoreButton: React.FC = ({ onClick, quote, poll }) => ( -
+const ReadMoreButton: React.FC = ({ onClick, quote, poll, preview }) => ( +
= ({ onClick, quote, poll }) => 'group-hover:to-gray-100 black:group-hover:to-gray-800 dark:group-hover:to-gray-800': quote, })} /> - + {!preview && ( + + )}
); @@ -53,6 +61,7 @@ interface IStatusContent { translatable?: boolean; textSize?: Sizes; quote?: boolean; + preview?: boolean; } /** Renders the text content of a status */ @@ -63,6 +72,7 @@ const StatusContent: React.FC = React.memo(({ translatable, textSize = 'md', quote = false, + preview, }) => { const dispatch = useAppDispatch(); const { displaySpoilers } = useSettings(); @@ -77,9 +87,9 @@ const StatusContent: React.FC = React.memo(({ const maybeSetCollapsed = (): void => { if (!node.current) return; - if (collapsable && !collapsed) { + if ((collapsable || preview) && !collapsed) { // 20px * x lines (+ 2px padding at the top) - if (node.current.clientHeight > (quote ? 202 : 282)) { + if (node.current.clientHeight > (preview ? 82 : quote ? 202 : 282)) { setCollapsed(true); } } @@ -130,8 +140,9 @@ const StatusContent: React.FC = React.memo(({ const className = clsx('relative text-ellipsis break-words text-gray-900 focus:outline-none dark:text-gray-100', { 'cursor-pointer': onClick, 'overflow-hidden': collapsed, - 'max-h-[200px]': collapsed && !quote, + 'max-h-[200px]': collapsed && !quote && !preview, 'max-h-[120px]': collapsed && quote, + 'max-h-[80px]': collapsed && preview, 'leading-normal big-emoji': onlyEmoji, }); @@ -212,7 +223,7 @@ const StatusContent: React.FC = React.memo(({ } if (collapsed) { - output.push( {}} key='read-more' quote={quote} />); + output.push( {}} key='read-more' quote={quote} preview={preview} />); } if (status.poll_id) { diff --git a/packages/pl-fe/src/features/interaction-requests/index.tsx b/packages/pl-fe/src/features/interaction-requests/index.tsx index 97250fed8..fb651ac8b 100644 --- a/packages/pl-fe/src/features/interaction-requests/index.tsx +++ b/packages/pl-fe/src/features/interaction-requests/index.tsx @@ -1,11 +1,268 @@ +import clsx from 'clsx'; import React from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; -import { useInteractionRequests } from 'pl-fe/api/hooks/statuses/use-interaction-requests'; +import { useAccount } from 'pl-fe/api/hooks/accounts/use-account'; +import { type MinifiedInteractionRequest, useAuthorizeInteractionRequestMutation, useFlatInteractionRequests, useRejectInteractionRequestMutation } from 'pl-fe/api/hooks/statuses/use-interaction-requests'; +import AttachmentThumbs from 'pl-fe/components/attachment-thumbs'; +import Icon from 'pl-fe/components/icon'; +import PullToRefresh from 'pl-fe/components/pull-to-refresh'; +import RelativeTimestamp from 'pl-fe/components/relative-timestamp'; +import ScrollableList from 'pl-fe/components/scrollable-list'; +import StatusContent from 'pl-fe/components/status-content'; +import Button from 'pl-fe/components/ui/button'; +import Column from 'pl-fe/components/ui/column'; +import HStack from 'pl-fe/components/ui/hstack'; +import Stack from 'pl-fe/components/ui/stack'; +import Text from 'pl-fe/components/ui/text'; +import AccountContainer from 'pl-fe/containers/account-container'; +import { buildLink } from 'pl-fe/features/notifications/components/notification'; +import { HotKeys } from 'pl-fe/features/ui/components/hotkeys'; +import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; +import { useOwnAccount } from 'pl-fe/hooks/use-own-account'; +import toast from 'pl-fe/toast'; + +const messages = defineMessages({ + title: { id: 'column.interaction_requests', defaultMessage: 'Interaction requests' }, + favourite: { id: 'interaction_request.favourite', defaultMessage: '{name} wants to like your post' }, + reply: { id: 'interaction_request.reply', defaultMessage: '{name} wants to reply to your post' }, + reblog: { id: 'interaction_request.reblog', defaultMessage: '{name} wants to repost your post' }, + authorize: { id: 'interaction_request.authorize', defaultMessage: 'Accept' }, + reject: { id: 'interaction_request.reject', defaultMessage: 'Reject' }, + authorized: { id: 'interaction_request.authorize.success', defaultMessage: 'Authorized @{acct} interaction request' }, + authorizeFail: { id: 'interaction_request.authorize.fail', defaultMessage: 'Failed to authorize @{acct} interaction request' }, + rejected: { id: 'interaction_request.reject.success', defaultMessage: 'Rejected @{acct} interaction request' }, + rejectFail: { id: 'interaction_request.reject.fail', defaultMessage: 'Failed to reject @{acct} interaction request' }, +}); + +const icons = { + favourite: require('@tabler/icons/outline/heart.svg'), + reblog: require('@tabler/icons/outline/repeat.svg'), + reply: require('@tabler/icons/outline/corner-up-left.svg'), +}; + +const avatarSize = 42; + +interface IInteractionRequestStatus { + id: string; + hasReply?: boolean; + isReply?: boolean; + actions?: JSX.Element; +} + +const InteractionRequestStatus: React.FC = ({ id: statusId, hasReply, isReply, actions }) => { + const status = useAppSelector((state) => state.statuses.get(statusId)); + + if (!status) return null; + + return ( + + {hasReply && ( +
+ )} + + } + /> + + + + + {status.media_attachments.length > 0 && ( + + )} + + + ); +}; + +interface IInteractionRequest { + interactionRequest: MinifiedInteractionRequest; + onMoveUp?: (requestId: string) => void; + onMoveDown?: (requestId: string) => void; +} + +const InteractionRequest: React.FC = ({ + interactionRequest, onMoveUp, onMoveDown, +}) => { + const intl = useIntl(); + const { account: ownAccount } = useOwnAccount(); + const { account } = useAccount(interactionRequest.account_id); + + const { mutate: authorize } = useAuthorizeInteractionRequestMutation(interactionRequest.id); + const { mutate: reject } = useRejectInteractionRequestMutation(interactionRequest.id); + + const handleAuthorize = () => { + authorize(undefined, { + onSuccess: () => toast.success(intl.formatMessage(messages.authorized, { + acct: account?.acct, + })), + onError: () => toast.error(intl.formatMessage(messages.authorizeFail, { + acct: account?.acct, + })), + }); + }; + + const handleReject = () => { + reject(undefined, { + onSuccess: () => toast.success(intl.formatMessage(messages.rejected, { + acct: account?.acct, + })), + onError: () => toast.error(intl.formatMessage(messages.rejectFail, { + acct: account?.acct, + })), + }); + }; + + if (interactionRequest.accepted_at || interactionRequest.rejected_at) return null; + + const message = intl.formatMessage(messages[interactionRequest.type], { + name: account && buildLink(account), + link: (children: React.ReactNode) => { + if (interactionRequest.status_id) { + return ( + + {children} + + ); + } + + return children; + }, + }); + + const actions = ( + +