pl-fe: Allow maanaging interaction requests

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-10-31 20:48:30 +01:00
parent adf1fe9b7d
commit 02a601a8e8
7 changed files with 314 additions and 25 deletions

View file

@ -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 = <T>(select?: (data: InfiniteData<PaginatedResponse<MinifiedInteractionRequest>>) => T) => {
const useInteractionRequests = <T>(
select?: ((data: InfiniteData<PaginatedResponse<MinifiedInteractionRequest>>) => 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<MinifiedInteractionRequest>,
getNextPageParam: (page) => page.next ? page : undefined,
@ -45,24 +47,30 @@ const useInteractionRequests = <T>(select?: (data: InfiniteData<PaginatedRespons
});
};
const useFlatInteractionRequests = () => useInteractionRequests(
(data: InfiniteData<PaginatedResponse<MinifiedInteractionRequest>>) => 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,
};

View file

@ -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<IReadMoreButton> = ({ onClick, quote, poll }) => (
<div className='relative -mt-4'>
const ReadMoreButton: React.FC<IReadMoreButton> = ({ onClick, quote, poll, preview }) => (
<div
className={clsx('relative', {
'-mt-4': !preview,
'-mt-2': preview,
})}
>
<div
className={clsx('absolute -top-16 h-16 w-full bg-gradient-to-b from-transparent', {
'to-white black:to-black dark:to-primary-900': !poll,
@ -39,10 +45,12 @@ const ReadMoreButton: React.FC<IReadMoreButton> = ({ onClick, quote, poll }) =>
'group-hover:to-gray-100 black:group-hover:to-gray-800 dark:group-hover:to-gray-800': quote,
})}
/>
<button className='flex items-center border-0 bg-transparent p-0 pt-2 text-gray-900 hover:underline active:underline dark:text-gray-300' onClick={onClick}>
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
<Icon className='inline-block size-5' src={require('@tabler/icons/outline/chevron-right.svg')} />
</button>
{!preview && (
<button className='flex items-center border-0 bg-transparent p-0 pt-2 text-gray-900 hover:underline active:underline dark:text-gray-300' onClick={onClick}>
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
<Icon className='inline-block size-5' src={require('@tabler/icons/outline/chevron-right.svg')} />
</button>
)}
</div>
);
@ -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<IStatusContent> = React.memo(({
translatable,
textSize = 'md',
quote = false,
preview,
}) => {
const dispatch = useAppDispatch();
const { displaySpoilers } = useSettings();
@ -77,9 +87,9 @@ const StatusContent: React.FC<IStatusContent> = 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<IStatusContent> = 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<IStatusContent> = React.memo(({
}
if (collapsed) {
output.push(<ReadMoreButton onClick={() => {}} key='read-more' quote={quote} />);
output.push(<ReadMoreButton onClick={() => {}} key='read-more' quote={quote} preview={preview} />);
}
if (status.poll_id) {

View file

@ -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 <link>post</link>' },
reply: { id: 'interaction_request.reply', defaultMessage: '{name} wants to reply to your <link>post</link>' },
reblog: { id: 'interaction_request.reblog', defaultMessage: '{name} wants to repost your <link>post</link>' },
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<IInteractionRequestStatus> = ({ id: statusId, hasReply, isReply, actions }) => {
const status = useAppSelector((state) => state.statuses.get(statusId));
if (!status) return null;
return (
<Stack className='relative py-2' space={2}>
{hasReply && (
<div
className='absolute left-5 top-[62px] z-[1] block h-[calc(100%-58px)] w-0.5 bg-gray-200 black:bg-gray-800 dark:bg-primary-800 rtl:left-auto rtl:right-5'
/>
)}
<AccountContainer
id={status.account_id}
showAccountHoverCard={false}
withLinkToProfile={false}
timestamp={status.created_at}
action={actions || <></>}
/>
<Stack space={2} className={clsx(hasReply && 'pl-[54px]')}>
<StatusContent status={status} preview={!isReply} />
{status.media_attachments.length > 0 && (
<AttachmentThumbs status={status} />
)}
</Stack>
</Stack>
);
};
interface IInteractionRequest {
interactionRequest: MinifiedInteractionRequest;
onMoveUp?: (requestId: string) => void;
onMoveDown?: (requestId: string) => void;
}
const InteractionRequest: React.FC<IInteractionRequest> = ({
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 (
<Link className='font-bold text-gray-800 hover:underline dark:text-gray-200' to={`/@${ownAccount?.acct}/posts/${interactionRequest.status_id}`}>
{children}
</Link>
);
}
return children;
},
});
const actions = (
<HStack space={2}>
<Button
theme='secondary'
size='sm'
text={intl.formatMessage(messages.authorize)}
onClick={() => handleAuthorize()}
/>
<Button
theme='danger'
size='sm'
text={intl.formatMessage(messages.reject)}
onClick={() => handleReject()}
/>
</HStack>
);
const handleMoveUp = () => {
if (onMoveUp) {
onMoveUp(interactionRequest.id);
}
};
const handleMoveDown = () => {
if (onMoveDown) {
onMoveDown(interactionRequest.id);
}
};
const handlers = {
moveUp: handleMoveUp,
moveDown: handleMoveDown,
};
return (
<HotKeys handlers={handlers}>
<div className='notification focusable' tabIndex={0}>
<div className='focusable p-4'>
<Stack space={2}>
<div>
<HStack alignItems='center' space={3}>
<div
className='flex justify-end'
style={{ flexBasis: avatarSize }}
>
<Icon
src={icons[interactionRequest.type]}
className='flex-none text-primary-600 dark:text-primary-400'
/>
</div>
<div className='truncate'>
<Text theme='muted' size='xs' truncate>
{message}
</Text>
</div>
{interactionRequest.type !== 'reply' && (
<div className='ml-auto'>
<Text theme='muted' size='xs' truncate>
<RelativeTimestamp timestamp={interactionRequest.created_at} theme='muted' size='sm' className='whitespace-nowrap' />
</Text>
</div>
)}
</HStack>
</div>
{interactionRequest.status_id && <InteractionRequestStatus id={interactionRequest.status_id} hasReply={interactionRequest.type === 'reply'} actions={actions} />}
{interactionRequest.reply_id && <InteractionRequestStatus id={interactionRequest.reply_id} isReply />}
</Stack>
</div>
</div>
</HotKeys>
);
};
const InteractionRequests = () => {
const interactionRequestsQuery = useInteractionRequests();
const intl = useIntl();
return <>{JSON.stringify(interactionRequestsQuery.data)}</>;
const { data = [], fetchNextPage, hasNextPage, isFetching, isLoading, refetch } = useFlatInteractionRequests();
const emptyMessage = <FormattedMessage id='empty_column.interaction_requests' defaultMessage='There are no pending interaction requests.' />;
const handleMoveUp = (id: string) => {
const elementIndex = data.findIndex(item => item !== null && item.id === id) - 1;
_selectChild(elementIndex);
};
const handleMoveDown = (id: string) => {
const elementIndex = data.findIndex(item => item !== null && item.id === id) + 1;
_selectChild(elementIndex);
};
const _selectChild = (index: number) => {
const selector = `[data-index="${index}"] .focusable`;
const element = document.querySelector<HTMLDivElement>(selector);
if (element) element.focus();
};
return (
<Column label={intl.formatMessage(messages.title)} withHeader={false}>
<PullToRefresh onRefresh={() => refetch()}>
<ScrollableList
isLoading={isFetching}
showLoading={isLoading}
hasMore={hasNextPage}
emptyMessage={emptyMessage}
onLoadMore={() => fetchNextPage()}
listClassName={clsx('divide-y divide-solid divide-gray-200 black:divide-gray-800 dark:divide-primary-800', {
'animate-pulse': data?.length === 0,
})}
>
{data?.map(request => (
<InteractionRequest
key={request.id}
interactionRequest={request}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
))}
</ScrollableList>
</PullToRefresh>
</Column>
);
};
export { InteractionRequests as default };

View file

@ -449,4 +449,4 @@ const Notification: React.FC<INotification> = (props) => {
);
};
export { Notification as default, getNotificationStatus };
export { Notification as default, buildLink, getNotificationStatus };

View file

@ -47,7 +47,6 @@ const Notifications = () => {
const hasMore = useAppSelector(state => state.notifications.hasMore);
const totalQueuedNotificationsCount = useAppSelector(state => state.notifications.totalQueuedNotificationsCount || 0);
const column = useRef<HTMLDivElement>(null);
const scrollableContentRef = useRef<ImmutableList<JSX.Element> | null>(null);
// const handleLoadGap = (maxId) => {
@ -143,7 +142,7 @@ const Notifications = () => {
);
return (
<Column ref={column} label={intl.formatMessage(messages.title)} withHeader={false}>
<Column label={intl.formatMessage(messages.title)} withHeader={false}>
{filterBarContainer}
<Portal>

View file

@ -53,7 +53,7 @@ const SelectedStatus = ({ statusId }: { statusId: string }) => {
return (
<Stack space={2} className='rounded-lg bg-gray-100 p-4 dark:bg-gray-800'>
<AccountContainer
id={status.account?.id}
id={status.account_id}
showAccountHoverCard={false}
withLinkToProfile={false}
timestamp={status.created_at}

View file

@ -391,6 +391,7 @@
"column.import_data": "Import data",
"column.info": "Server information",
"column.interaction_policies": "Interaction policies",
"column.interaction_requests": "Interaction requests",
"column.lists": "Lists",
"column.manage_group": "Manage group",
"column.mentions": "Mentions",
@ -725,6 +726,7 @@
"empty_column.home.local_tab": "the {site_title} tab",
"empty_column.home.subtitle": "{siteTitle} gets more interesting once you follow other users.",
"empty_column.home.title": "You're not following anyone yet",
"empty_column.interaction_requests": "There are no pending interaction requests.",
"empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.",
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
"empty_column.mutes": "You haven't muted any users yet.",
@ -916,6 +918,15 @@
"interaction_policies.title.unlisted.can_reblog": "Who can repost an unlisted post?",
"interaction_policies.title.unlisted.can_reply": "Who can reply to an unlisted post?",
"interaction_policies.update": "Update",
"interaction_request.authorize": "Accept",
"interaction_request.authorize.fail": "Failed to authorize @{acct} interaction request",
"interaction_request.authorize.success": "Authorized @{acct} interaction request",
"interaction_request.favourite": "{name} wants to like your <link>post</link>",
"interaction_request.reblog": "{name} wants to repost your <link>post</link>",
"interaction_request.reject": "Reject",
"interaction_request.reject.fail": "Failed to reject @{acct} interaction request",
"interaction_request.reject.success": "Rejected @{acct} interaction request",
"interaction_request.reply": "{name} wants to reply to your <link>post</link>",
"interactions_circle.compose": "Share",
"interactions_circle.confirmation_heading": "Do you want to generate an interaction circle for the user @{username}?",
"interactions_circle.download": "Download",
@ -1068,6 +1079,7 @@
"navigation.direct_messages": "Direct messages",
"navigation.drafts": "Drafts",
"navigation.home": "Home",
"navigation.interaction_requests": "Interaction requests",
"navigation.notifications": "Notifications",
"navigation.search": "Search",
"navigation.sidebar": "Open sidebar",