pl-fe: Allow maanaging interaction requests
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
adf1fe9b7d
commit
02a601a8e8
7 changed files with 314 additions and 25 deletions
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -449,4 +449,4 @@ const Notification: React.FC<INotification> = (props) => {
|
|||
);
|
||||
};
|
||||
|
||||
export { Notification as default, getNotificationStatus };
|
||||
export { Notification as default, buildLink, getNotificationStatus };
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue