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..b28aa065b --- /dev/null +++ b/packages/pl-fe/src/api/hooks/statuses/use-interaction-requests.ts @@ -0,0 +1,85 @@ +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'; +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_id: status?.id || null, + reply_id: 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: ['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, + enabled: features.interactionRequests, + select, + }); +}; + +const useFlatInteractionRequests = () => useInteractionRequests( + (data: InfiniteData>) => data.pages.map(page => page.items).flat(), +); + +const useInteractionRequestsCount = () => useInteractionRequests(data => data.pages.map(({ items }) => items).flat().length); + +const useAuthorizeInteractionRequestMutation = (requestId: string) => { + const client = useClient(); + const { refetch } = useInteractionRequests(); + + return useMutation({ + mutationKey: ['interactionRequests', requestId], + mutationFn: () => client.interactionRequests.authorizeInteractionRequest(requestId), + onSettled: () => refetch(), + }); +}; + +const useRejectInteractionRequestMutation = (requestId: string) => { + const client = useClient(); + const { refetch } = useInteractionRequests(); + + return useMutation({ + mutationKey: ['interactionRequests', requestId], + mutationFn: () => client.interactionRequests.rejectInteractionRequest(requestId), + onSettled: () => refetch(), + }); +}; + +export { + useInteractionRequests, + useInteractionRequestsCount, + useFlatInteractionRequests, + useAuthorizeInteractionRequestMutation, + useRejectInteractionRequestMutation, + type MinifiedInteractionRequest, +}; 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/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 new file mode 100644 index 000000000..fb651ac8b --- /dev/null +++ b/packages/pl-fe/src/features/interaction-requests/index.tsx @@ -0,0 +1,268 @@ +import clsx from 'clsx'; +import React from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; + +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 = ( + +