Add ability to update deletion duration

environments/review-chats-g56n7m/deployments/1203
Chewbacca 2 years ago
parent 22f3dd9444
commit 54363e24a9

@ -14,16 +14,18 @@ const List: React.FC = ({ children }) => (
interface IListItem {
label: React.ReactNode,
hint?: React.ReactNode,
onClick?: () => void,
onClick?(): void,
onSelect?(): void
isSelected?: boolean
}
const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelect, isSelected }) => {
const id = uuidv4();
const domId = `list-group-${id}`;
const Comp = onClick ? 'a' : 'div';
const LabelComp = onClick ? 'span' : 'label';
const linkProps = onClick ? { onClick } : {};
const LabelComp = onClick || onSelect ? 'span' : 'label';
const linkProps = onClick || onSelect ? { onClick: onClick || onSelect } : {};
const renderChildren = React.useCallback(() => {
return React.Children.map(children, (child) => {
@ -46,7 +48,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
<Comp
className={classNames({
'flex items-center justify-between px-3 py-2 first:rounded-t-lg last:rounded-b-lg bg-gradient-to-r from-gradient-start/10 to-gradient-end/10': true,
'cursor-pointer hover:from-gradient-start/20 hover:to-gradient-end/20 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof onClick !== 'undefined',
'cursor-pointer hover:from-gradient-start/20 hover:to-gradient-end/20 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof onClick !== 'undefined' || typeof onSelect !== 'undefined',
})}
{...linkProps}
>
@ -65,6 +67,16 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
<Icon src={require('@tabler/icons/chevron-right.svg')} className='ml-1' />
</div>
) : renderChildren()}
{onSelect ? (
<div className='flex flex-row items-center text-gray-700 dark:text-gray-600'>
{children}
{isSelected ? (
<Icon src={require('@tabler/icons/check.svg')} className='ml-1 text-primary-500 dark:text-primary-400' />
) : null}
</div>
) : renderChildren()}
</Comp>
);
};

@ -3,11 +3,13 @@ import { defineMessages, useIntl } from 'react-intl';
import { blockAccount, unblockAccount } from 'soapbox/actions/accounts';
import { openModal } from 'soapbox/actions/modals';
import { Avatar, Divider, HStack, Icon, IconButton, Menu, MenuButton, MenuItem, MenuList, Stack, Text } from 'soapbox/components/ui';
import List, { ListItem } from 'soapbox/components/list';
import { Avatar, HStack, Icon, IconButton, Menu, MenuButton, MenuItem, MenuList, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification_badge';
import { useChatContext } from 'soapbox/contexts/chat-context';
import { useAppDispatch, useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { useChatActions } from 'soapbox/queries/chats';
import { MessageExpirationValues, useChatActions } from 'soapbox/queries/chats';
import { secondsToDays } from 'soapbox/utils/numbers';
import Chat from '../../chat';
@ -28,6 +30,13 @@ const messages = defineMessages({
unblockUser: { id: 'chat_settings.options.unblock_user', defaultMessage: 'Unblock @{acct}' },
reportUser: { id: 'chat_settings.options.report_user', defaultMessage: 'Report @{acct}' },
leaveChat: { id: 'chat_settings.options.leave_chat', defaultMessage: 'Leave Chat' },
autoDeleteLabel: { id: 'chat_settings.auto_delete.label', defaultMessage: 'Auto-delete messages' },
autoDeleteHint: { id: 'chat_settings.auto_delete.hint', defaultMessage: 'Sent messages will auto-delete after the time period selected' },
autoDelete7Days: { id: 'chat_settings.auto_delete.7days', defaultMessage: '7 days' },
autoDelete14Days: { id: 'chat_settings.auto_delete.14days', defaultMessage: '14 days' },
autoDelete30Days: { id: 'chat_settings.auto_delete.30days', defaultMessage: '30 days' },
autoDelete90Days: { id: 'chat_settings.auto_delete.90days', defaultMessage: '90 days' },
autoDeleteMessage: { id: 'chat_window.auto_delete_label', defaultMessage: 'Auto-delete after {day} days' },
});
const ChatPageMain = () => {
@ -38,7 +47,9 @@ const ChatPageMain = () => {
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const { chat, setChat } = useChatContext();
const { deleteChat } = useChatActions(chat?.id as string);
const { deleteChat, updateChat } = useChatActions(chat?.id as string);
const handleUpdateChat = (value: MessageExpirationValues) => updateChat.mutate({ message_expiration: value });
const isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking']));
@ -106,11 +117,11 @@ const ChatPageMain = () => {
align='left'
size='sm'
weight='medium'
theme='muted'
theme='primary'
truncate
className='w-full'
>
{chat.account.acct}
{intl.formatMessage(messages.autoDeleteMessage, { day: secondsToDays(chat.message_expiration) })}
</Text>
</Stack>
</HStack>
@ -133,7 +144,32 @@ const ChatPageMain = () => {
</Stack>
</HStack>
<Divider />
<List>
<ListItem
label={intl.formatMessage(messages.autoDeleteLabel)}
hint={intl.formatMessage(messages.autoDeleteHint)}
/>
<ListItem
label={intl.formatMessage(messages.autoDelete7Days)}
onSelect={() => handleUpdateChat(MessageExpirationValues.SEVEN)}
isSelected={chat.message_expiration === MessageExpirationValues.SEVEN}
/>
<ListItem
label={intl.formatMessage(messages.autoDelete14Days)}
onSelect={() => handleUpdateChat(MessageExpirationValues.FOURTEEN)}
isSelected={chat.message_expiration === MessageExpirationValues.FOURTEEN}
/>
<ListItem
label={intl.formatMessage(messages.autoDelete30Days)}
onSelect={() => handleUpdateChat(MessageExpirationValues.THIRTY)}
isSelected={chat.message_expiration === MessageExpirationValues.THIRTY}
/>
<ListItem
label={intl.formatMessage(messages.autoDelete90Days)}
onSelect={() => handleUpdateChat(MessageExpirationValues.NINETY)}
isSelected={chat.message_expiration === MessageExpirationValues.NINETY}
/>
</List>
<Stack space={2}>
<MenuItem

@ -3,10 +3,11 @@ import { defineMessages, useIntl } from 'react-intl';
import { blockAccount, unblockAccount } from 'soapbox/actions/accounts';
import { openModal } from 'soapbox/actions/modals';
import { Avatar, Divider, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import List, { ListItem } from 'soapbox/components/list';
import { Avatar, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import { useChatContext } from 'soapbox/contexts/chat-context';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { useChatActions } from 'soapbox/queries/chats';
import { MessageExpirationValues, useChatActions } from 'soapbox/queries/chats';
import ChatPaneHeader from './chat-pane-header';
@ -24,6 +25,12 @@ const messages = defineMessages({
blockUser: { id: 'chat_settings.options.block_user', defaultMessage: 'Block @{acct}' },
unblockUser: { id: 'chat_settings.options.unblock_user', defaultMessage: 'Unblock @{acct}' },
leaveChat: { id: 'chat_settings.options.leave_chat', defaultMessage: 'Leave Chat' },
autoDeleteLabel: { id: 'chat_settings.auto_delete.label', defaultMessage: 'Auto-delete messages' },
autoDeleteHint: { id: 'chat_settings.auto_delete.hint', defaultMessage: 'Sent messages will auto-delete after the time period selected' },
autoDelete7Days: { id: 'chat_settings.auto_delete.7days', defaultMessage: '7 days' },
autoDelete14Days: { id: 'chat_settings.auto_delete.14days', defaultMessage: '14 days' },
autoDelete30Days: { id: 'chat_settings.auto_delete.30days', defaultMessage: '30 days' },
autoDelete90Days: { id: 'chat_settings.auto_delete.90days', defaultMessage: '90 days' },
});
const ChatSettings = () => {
@ -31,7 +38,9 @@ const ChatSettings = () => {
const intl = useIntl();
const { chat, setEditing, toggleChatPane } = useChatContext();
const { deleteChat } = useChatActions(chat?.id as string);
const { deleteChat, updateChat } = useChatActions(chat?.id as string);
const handleUpdateChat = (value: MessageExpirationValues) => updateChat.mutate({ message_expiration: value });
const isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking']));
@ -107,7 +116,32 @@ const ChatSettings = () => {
</Stack>
</HStack>
<Divider />
<List>
<ListItem
label={intl.formatMessage(messages.autoDeleteLabel)}
hint={intl.formatMessage(messages.autoDeleteHint)}
/>
<ListItem
label={intl.formatMessage(messages.autoDelete7Days)}
onSelect={() => handleUpdateChat(MessageExpirationValues.SEVEN)}
isSelected={chat.message_expiration === MessageExpirationValues.SEVEN}
/>
<ListItem
label={intl.formatMessage(messages.autoDelete14Days)}
onSelect={() => handleUpdateChat(MessageExpirationValues.FOURTEEN)}
isSelected={chat.message_expiration === MessageExpirationValues.FOURTEEN}
/>
<ListItem
label={intl.formatMessage(messages.autoDelete30Days)}
onSelect={() => handleUpdateChat(MessageExpirationValues.THIRTY)}
isSelected={chat.message_expiration === MessageExpirationValues.THIRTY}
/>
<ListItem
label={intl.formatMessage(messages.autoDelete90Days)}
onSelect={() => handleUpdateChat(MessageExpirationValues.NINETY)}
isSelected={chat.message_expiration === MessageExpirationValues.NINETY}
/>
</List>
<Stack space={5}>
<button onClick={isBlocking ? handleUnblockUser : handleBlockUser} className='w-full flex items-center space-x-2 font-bold text-sm text-primary-600 dark:text-accent-blue'>

@ -1,15 +1,21 @@
import React, { useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { Avatar, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification_badge';
import { useChatContext } from 'soapbox/contexts/chat-context';
import { secondsToDays } from 'soapbox/utils/numbers';
import Chat from '../chat';
import ChatPaneHeader from './chat-pane-header';
import ChatSettings from './chat-settings';
const messages = defineMessages({
autoDeleteMessage: { id: 'chat_window.auto_delete_label', defaultMessage: 'Auto-delete after {day} days' },
});
const LinkWrapper = ({ enabled, to, children }: { enabled: boolean, to: string, children: React.ReactNode }): JSX.Element => {
if (!enabled) {
return <>{children}</>;
@ -24,6 +30,8 @@ const LinkWrapper = ({ enabled, to, children }: { enabled: boolean, to: string,
/** Floating desktop chat window. */
const ChatWindow = () => {
const intl = useIntl();
const { chat, setChat, isOpen, isEditing, needsAcceptance, setEditing, setSearching, toggleChatPane } = useChatContext();
const inputRef = useRef<HTMLTextAreaElement | null>(null);
@ -79,7 +87,9 @@ const ChatWindow = () => {
<Text size='sm' weight='bold' truncate>{chat.account.display_name}</Text>
{chat.account.verified && <VerificationBadge />}
</div>
<Text size='sm' weight='medium' theme='primary' truncate>@{chat.account.acct}</Text>
<Text size='sm' weight='medium' theme='primary' truncate>
{intl.formatMessage(messages.autoDeleteMessage, { day: secondsToDays(chat.message_expiration) })}
</Text>
</Stack>
</LinkWrapper>
</HStack>

@ -3,6 +3,7 @@ import sumBy from 'lodash/sumBy';
import { fetchRelationships } from 'soapbox/actions/accounts';
import { importFetchedAccount, importFetchedAccounts } from 'soapbox/actions/importer';
import snackbar from 'soapbox/actions/snackbar';
import { getNextLink } from 'soapbox/api';
import compareId from 'soapbox/compare_id';
import { useChatContext } from 'soapbox/contexts/chat-context';
@ -14,10 +15,20 @@ import { queryClient } from './client';
import type { IAccount } from './accounts';
export enum MessageExpirationValues {
'SEVEN' = 604800,
'FOURTEEN' = 1209600,
'THIRTY' = 2592000,
'NINETY' = 7776000
}
export interface IChat {
id: string
unread: number
accepted: boolean
account: IAccount
created_at: Date
created_by_account: string
discarded_at: null | string
id: string
last_message: null | {
account_id: string
chat_id: string
@ -27,11 +38,12 @@ export interface IChat {
id: string
unread: boolean
}
created_at: Date
updated_at: Date
accepted: boolean
discarded_at: null | string
account: IAccount
latest_read_message_by_account: {
[id: number]: string
}[]
latest_read_message_created_at: string
message_expiration: MessageExpirationValues
unread: number
}
export interface IChatMessage {
@ -44,6 +56,10 @@ export interface IChatMessage {
pending?: boolean
}
type UpdateChatVariables = {
message_expiration: MessageExpirationValues
}
const ChatKeys = {
chat: (chatId?: string) => ['chats', 'chat', chatId] as const,
chatMessages: (chatId: string) => ['chats', 'messages', chatId] as const,
@ -171,7 +187,9 @@ const useChat = (chatId?: string) => {
const useChatActions = (chatId: string) => {
const api = useApi();
const { setChat, setEditing } = useChatContext();
const dispatch = useAppDispatch();
const { chat, setChat, setEditing } = useChatContext();
const markChatAsRead = (lastReadId: string) => {
api.post<IChat>(`/api/v1/pleroma/chats/${chatId}/read`, { last_read_id: lastReadId })
@ -183,6 +201,35 @@ const useChatActions = (chatId: string) => {
return api.post<IChat>(`/api/v1/pleroma/chats/${chatId}/messages`, { content });
};
const updateChat = useMutation((data: UpdateChatVariables) => api.patch<IChat>(`/api/v1/pleroma/chats/${chatId}`, data), {
onMutate: async (data) => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries(ChatKeys.chat(chatId));
// Snapshot the previous value
const prevChat = { ...chat };
const nextChat = { ...chat, ...data };
// Optimistically update to the new value
queryClient.setQueryData(ChatKeys.chat(chatId), nextChat);
setChat(nextChat as IChat);
// Return a context object with the snapshotted value
return { prevChat };
},
// If the mutation fails, use the context returned from onMutate to roll back
onError: (_error: any, _newData: any, context: any) => {
setChat(context?.prevChat);
queryClient.setQueryData(ChatKeys.chat(chatId), context.prevChat);
dispatch(snackbar.error('Chat Settings failed to update.'));
},
onSuccess(response) {
queryClient.invalidateQueries(ChatKeys.chat(chatId));
setChat(response.data);
dispatch(snackbar.success('Chat Settings updated successfully'));
},
});
const deleteChatMessage = (chatMessageId: string) => api.delete<IChat>(`/api/v1/pleroma/chats/${chatId}/messages/${chatMessageId}`);
const acceptChat = useMutation(() => api.post<IChat>(`/api/v1/pleroma/chats/${chatId}/accept`), {
@ -202,7 +249,7 @@ const useChatActions = (chatId: string) => {
},
});
return { createChatMessage, markChatAsRead, deleteChatMessage, acceptChat, deleteChat };
return { createChatMessage, markChatAsRead, deleteChatMessage, updateChat, acceptChat, deleteChat };
};
export { ChatKeys, useChat, useChatActions, useChats, useChatMessages };

@ -1,7 +1,7 @@
import React from 'react';
import { render, screen } from '../../jest/test-helpers';
import { isIntegerId, shortNumberFormat } from '../numbers';
import { isIntegerId, secondsToDays, shortNumberFormat } from '../numbers';
test('isIntegerId()', () => {
expect(isIntegerId('0')).toBe(true);
@ -14,6 +14,13 @@ test('isIntegerId()', () => {
expect(isIntegerId(undefined as any)).toBe(false);
});
test('secondsToDays', () => {
expect(secondsToDays(604800)).toEqual(7);
expect(secondsToDays(1209600)).toEqual(14);
expect(secondsToDays(2592000)).toEqual(30);
expect(secondsToDays(7776000)).toEqual(90);
});
describe('shortNumberFormat', () => {
test('handles non-numbers', () => {
render(<div data-testid='num'>{shortNumberFormat('not-number')}</div>, undefined, null);

@ -4,6 +4,8 @@ import { FormattedNumber } from 'react-intl';
/** Check if a value is REALLY a number. */
export const isNumber = (value: unknown): value is number => typeof value === 'number' && !isNaN(value);
export const secondsToDays = (seconds: number) => Math.floor(seconds / (3600 * 24));
const roundDown = (num: number) => {
if (num >= 100 && num < 1000) {
num = Math.floor(num);

Loading…
Cancel
Save