diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 296976705a..c2bc6fb4d2 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -18,6 +18,7 @@ import type { ChannelMemberResponse, ChannelQueryOptions, ChannelState, + DeleteMessageOptions, ErrorFromResponse, Event, EventAPIResponse, @@ -176,7 +177,10 @@ export type ChannelProps = ChannelPropsForwardedToComponentContext & { */ channelQueryOptions?: ChannelQueryOptions; /** Custom action handler to override the default `client.deleteMessage(message.id)` function */ - doDeleteMessageRequest?: (message: LocalMessage) => Promise; + doDeleteMessageRequest?: ( + message: LocalMessage, + options?: DeleteMessageOptions, + ) => Promise; /** Custom action handler to override the default `channel.markRead` request function (advanced usage only) */ doMarkReadRequest?: ( channel: StreamChannel, @@ -910,15 +914,18 @@ const ChannelInner = ( ); const deleteMessage = useCallback( - async (message: LocalMessage): Promise => { + async ( + message: LocalMessage, + options?: DeleteMessageOptions, + ): Promise => { if (!message?.id) { throw new Error('Cannot delete a message - missing message ID.'); } let deletedMessage; if (doDeleteMessageRequest) { - deletedMessage = await doDeleteMessageRequest(message); + deletedMessage = await doDeleteMessageRequest(message, options); } else { - const result = await client.deleteMessage(message.id); + const result = await client.deleteMessage(message.id, options); deletedMessage = result.message; } diff --git a/src/components/Channel/__tests__/Channel.test.js b/src/components/Channel/__tests__/Channel.test.js index 66b0474f65..db59877d03 100644 --- a/src/components/Channel/__tests__/Channel.test.js +++ b/src/components/Channel/__tests__/Channel.test.js @@ -1607,15 +1607,18 @@ describe('Channel', () => { it('should call the default client.deleteMessage() function', async () => { const message = generateMessage(); - + const deleteMessageOptions = { deleteForMe: true, hard: false }; const clientDeleteMessageSpy = jest .spyOn(chatClient, 'deleteMessage') .mockImplementationOnce(() => Promise.resolve({ message })); await renderComponent({ channel, chatClient }, ({ deleteMessage }) => { - deleteMessage(message); + deleteMessage(message, deleteMessageOptions); }); await waitFor(() => - expect(clientDeleteMessageSpy).toHaveBeenCalledWith(message.id), + expect(clientDeleteMessageSpy).toHaveBeenCalledWith( + message.id, + deleteMessageOptions, + ), ); }); @@ -1644,7 +1647,7 @@ describe('Channel', () => { it('should call the custom doDeleteMessageRequest instead of client.deleteMessage()', async () => { const message = generateMessage(); - + const deleteMessageOptions = { deleteForMe: true, hard: false }; const doDeleteMessageRequest = jest.fn(); const clientDeleteMessageSpy = jest .spyOn(chatClient, 'deleteMessage') @@ -1653,13 +1656,16 @@ describe('Channel', () => { await renderComponent( { channel, chatClient, doDeleteMessageRequest }, ({ deleteMessage }) => { - deleteMessage(message); + deleteMessage(message, deleteMessageOptions); }, ); await waitFor(() => { expect(clientDeleteMessageSpy).not.toHaveBeenCalled(); - expect(doDeleteMessageRequest).toHaveBeenCalledWith(message); + expect(doDeleteMessageRequest).toHaveBeenCalledWith( + message, + deleteMessageOptions, + ); }); }); }); diff --git a/src/components/Channel/hooks/useCreateChannelStateContext.ts b/src/components/Channel/hooks/useCreateChannelStateContext.ts index 9a4aafdc8b..d53c17221e 100644 --- a/src/components/Channel/hooks/useCreateChannelStateContext.ts +++ b/src/components/Channel/hooks/useCreateChannelStateContext.ts @@ -59,6 +59,8 @@ export const useCreateChannelStateContext = ( channelCapabilities[capability] = true; }); + // FIXME: this is crazy - I could not find out why the messages were not getting updated when only message properties that are not part + // of this serialization has been changed. A great example of memoization gone wrong. const memoizedMessageData = skipMessageDataMemoization ? messages : messages @@ -69,10 +71,11 @@ export const useCreateChannelStateContext = ( pinned, reply_count, status, + type, updated_at, user, }) => - `${deleted_at}${ + `${type}${deleted_at}${ latest_reactions ? latest_reactions.map(({ type }) => type).join() : '' }${pinned}${reply_count}${status}${ updated_at && (isDayOrMoment(updated_at) || isDate(updated_at)) diff --git a/src/components/Message/__tests__/utils.test.js b/src/components/Message/__tests__/utils.test.js index 08d468d815..e42898475b 100644 --- a/src/components/Message/__tests__/utils.test.js +++ b/src/components/Message/__tests__/utils.test.js @@ -17,6 +17,7 @@ import { MESSAGE_ACTIONS, messageHasAttachments, messageHasReactions, + OPTIONAL_MESSAGE_ACTIONS, validateAndGetMessage, } from '../utils'; @@ -90,6 +91,7 @@ describe('Message utils', () => { canReply: true, }; const actions = Object.values(MESSAGE_ACTIONS); + const optionalActions = Object.values(OPTIONAL_MESSAGE_ACTIONS); it.each([ ['empty', []], @@ -131,31 +133,36 @@ describe('Message utils', () => { }); it.each([ - ['allow', 'edit', 'canEdit', true], - ['not allow', 'edit', 'canEdit', false], - ['allow', 'delete', 'canDelete', true], - ['not allow', 'delete', 'canDelete', false], - ['allow', 'flag', 'canFlag', true], - ['not allow', 'flag', 'canFlag', false], - ['allow', 'markUnread', 'canMarkUnread', true], - ['not allow', 'markUnread', 'canMarkUnread', false], - ['allow', 'mute', 'canMute', true], - ['not allow', 'mute', 'canMute', false], - ['allow', 'pin', 'canPin', true], - ['not allow', 'pin', 'canPin', false], - ['allow', 'quote', 'canQuote', true], - ['not allow', 'quote', 'canQuote', false], - ])('it should %s %s when %s is %s', (_, action, capabilityKey, capabilityValue) => { - const capabilities = { - [capabilityKey]: capabilityValue, - }; - const result = getMessageActions(actions, capabilities); - if (capabilityValue) { - expect(result).toStrictEqual([action]); - } else { - expect(result).not.toStrictEqual([action]); - } - }); + ['allow', 'edit', 'canEdit', true, actions], + ['not allow', 'edit', 'canEdit', false, actions], + ['allow', 'delete', 'canDelete', true, actions], + ['not allow', 'delete', 'canDelete', false, actions], + ['allow', 'deleteForMe', 'canDelete', true, optionalActions], + ['not allow', 'deleteForMe', 'canDelete', false, optionalActions], + ['allow', 'flag', 'canFlag', true, actions], + ['not allow', 'flag', 'canFlag', false, actions], + ['allow', 'markUnread', 'canMarkUnread', true, actions], + ['not allow', 'markUnread', 'canMarkUnread', false, actions], + ['allow', 'mute', 'canMute', true, actions], + ['not allow', 'mute', 'canMute', false, actions], + ['allow', 'pin', 'canPin', true, actions], + ['not allow', 'pin', 'canPin', false, actions], + ['allow', 'quote', 'canQuote', true, actions], + ['not allow', 'quote', 'canQuote', false, actions], + ])( + 'it should %s %s when %s is %s', + (_, action, capabilityKey, capabilityValue, actionsToUse) => { + const capabilities = { + [capabilityKey]: capabilityValue, + }; + const result = getMessageActions(actionsToUse, capabilities); + if (capabilityValue) { + expect(result).toStrictEqual([action]); + } else { + expect(result).not.toStrictEqual([action]); + } + }, + ); }); describe('shouldMessageComponentUpdate', () => { diff --git a/src/components/Message/hooks/__tests__/useDeleteHandler.test.js b/src/components/Message/hooks/__tests__/useDeleteHandler.test.js index 3ddd469c74..c7bf98a6bf 100644 --- a/src/components/Message/hooks/__tests__/useDeleteHandler.test.js +++ b/src/components/Message/hooks/__tests__/useDeleteHandler.test.js @@ -75,9 +75,10 @@ describe('useDeleteHandler custom hook', () => { it('should delete a message by its id', async () => { const message = generateMessage(); + const deleteMessageOptions = { deleteForMe: true, hard: false }; const handleDelete = await renderUseDeleteHandler(message); - await handleDelete(mouseEventMock); - expect(deleteMessage).toHaveBeenCalledWith(message); + await handleDelete(mouseEventMock, deleteMessageOptions); + expect(deleteMessage).toHaveBeenCalledWith(message, deleteMessageOptions); }); it('should update the message with the result of deletion', async () => { diff --git a/src/components/Message/hooks/useDeleteHandler.ts b/src/components/Message/hooks/useDeleteHandler.ts index f51dc8bb89..6adcac545b 100644 --- a/src/components/Message/hooks/useDeleteHandler.ts +++ b/src/components/Message/hooks/useDeleteHandler.ts @@ -4,7 +4,7 @@ import { useChannelActionContext } from '../../../context/ChannelActionContext'; import { useChatContext } from '../../../context/ChatContext'; import { useTranslationContext } from '../../../context/TranslationContext'; -import type { LocalMessage } from 'stream-chat'; +import type { DeleteMessageOptions, LocalMessage } from 'stream-chat'; import type { ReactEventHandler } from '../types'; export type DeleteMessageNotifications = { @@ -22,14 +22,14 @@ export const useDeleteHandler = ( const { client } = useChatContext('useDeleteHandler'); const { t } = useTranslationContext('useDeleteHandler'); - return async (event) => { + return async (event, options?: DeleteMessageOptions) => { event.preventDefault(); if (!message?.id || !client || !updateMessage) { return; } try { - const deletedMessage = await deleteMessage(message); + const deletedMessage = await deleteMessage(message, options); updateMessage(deletedMessage); } catch (e) { const errorMessage = diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index c751d15092..5a1974d6be 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -52,6 +52,10 @@ export const isUserMuted = (message: LocalMessage, mutes?: Mute[]) => { return !!userMuted.length; }; +export const OPTIONAL_MESSAGE_ACTIONS = { + deleteForMe: 'deleteForMe', +}; + export const MESSAGE_ACTIONS = { delete: 'delete', edit: 'edit', @@ -67,7 +71,7 @@ export const MESSAGE_ACTIONS = { }; export type MessageActionsArray = Array< - keyof typeof MESSAGE_ACTIONS | T + keyof typeof MESSAGE_ACTIONS | keyof typeof OPTIONAL_MESSAGE_ACTIONS | T >; // @deprecated in favor of `channelCapabilities` - TODO: remove in next major release @@ -172,6 +176,10 @@ export const getMessageActions = ( messageActionsAfterPermission.push(MESSAGE_ACTIONS.delete); } + if (canDelete && messageActions.indexOf(OPTIONAL_MESSAGE_ACTIONS.deleteForMe) > -1) { + messageActionsAfterPermission.push(OPTIONAL_MESSAGE_ACTIONS.deleteForMe); + } + if (canEdit && messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1) { messageActionsAfterPermission.push(MESSAGE_ACTIONS.edit); } diff --git a/src/components/MessageActions/MessageActionsBox.tsx b/src/components/MessageActions/MessageActionsBox.tsx index 17c57a109a..0a6c4392b6 100644 --- a/src/components/MessageActions/MessageActionsBox.tsx +++ b/src/components/MessageActions/MessageActionsBox.tsx @@ -3,7 +3,7 @@ import type { ComponentProps } from 'react'; import React from 'react'; import { CustomMessageActionsList as DefaultCustomMessageActionsList } from './CustomMessageActionsList'; import { RemindMeActionButton } from './RemindMeSubmenu'; -import { useMessageReminder } from '../Message'; +import { OPTIONAL_MESSAGE_ACTIONS, useMessageReminder } from '../Message'; import { useMessageComposer } from '../MessageInput'; import { useChatContext, @@ -162,6 +162,17 @@ const UnMemoizedMessageActionsBox = (props: MessageActionsBoxProps) => { {t('Delete')} )} + {messageActions.indexOf(OPTIONAL_MESSAGE_ACTIONS.deleteForMe) > -1 && + !message.deleted_for_me && ( + + )} {messageActions.indexOf(MESSAGE_ACTIONS.remindMe) > -1 && ( )} diff --git a/src/components/MessageActions/__tests__/MessageActionsBox.test.js b/src/components/MessageActions/__tests__/MessageActionsBox.test.js index 14a62bad58..427cb297ff 100644 --- a/src/components/MessageActions/__tests__/MessageActionsBox.test.js +++ b/src/components/MessageActions/__tests__/MessageActionsBox.test.js @@ -179,6 +179,20 @@ describe('MessageActionsBox', () => { expect(results).toHaveNoViolations(); }); + it('should call the handleDelete prop if the deleteForMe button is clicked', async () => { + getMessageActionsMock.mockImplementationOnce(() => ['deleteForMe']); + const handleDelete = jest.fn(); + const { + result: { container, getByText }, + } = await renderComponent({ handleDelete, message: generateMessage() }); + await act(async () => { + await fireEvent.click(getByText('Delete for me')); + }); + expect(handleDelete).toHaveBeenCalledTimes(1); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + it('should call the handlePin prop if the pin button is clicked', async () => { getMessageActionsMock.mockImplementationOnce(() => ['pin']); const handlePin = jest.fn(); diff --git a/src/context/ChannelActionContext.tsx b/src/context/ChannelActionContext.tsx index e7ed002e99..036f2135b2 100644 --- a/src/context/ChannelActionContext.tsx +++ b/src/context/ChannelActionContext.tsx @@ -2,6 +2,7 @@ import type { PropsWithChildren } from 'react'; import React, { useContext } from 'react'; import type { + DeleteMessageOptions, LocalMessage, Message, MessageResponse, @@ -29,7 +30,10 @@ export type RetrySendMessage = (message: LocalMessage) => Promise; export type ChannelActionContextValue = { addNotification: (text: string, type: 'success' | 'error') => void; closeThread: (event?: React.BaseSyntheticEvent) => void; - deleteMessage: (message: LocalMessage) => Promise; + deleteMessage: ( + message: LocalMessage, + options?: DeleteMessageOptions, + ) => Promise; dispatch: React.Dispatch; editMessage: ( message: LocalMessage | MessageResponse, diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index 8029ce6198..bd21be4201 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -1,7 +1,8 @@ -import type { PropsWithChildren, ReactNode } from 'react'; +import type { BaseSyntheticEvent, PropsWithChildren, ReactNode } from 'react'; import React, { useContext } from 'react'; import type { + DeleteMessageOptions, LocalMessage, Mute, ReactionResponse, @@ -48,7 +49,10 @@ export type MessageContextValue = { /** Function to send an action in a Channel */ handleAction: ActionHandlerReturnType; /** Function to delete a message in a Channel */ - handleDelete: ReactEventHandler; + handleDelete: ( + event: BaseSyntheticEvent, + options?: DeleteMessageOptions, + ) => Promise | void; /** Function to edit a message in a Channel */ handleEdit: ReactEventHandler; /** Function to fetch the message reactions */ diff --git a/src/i18n/de.json b/src/i18n/de.json index 180cccd33e..30f9325570 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -29,6 +29,7 @@ "Create poll": "Umfrage erstellen", "Current location": "Aktueller Standort", "Delete": "Löschen", + "Delete for me": "Für mich löschen", "Delivered": "Zugestellt", "Download attachment {{ name }}": "Anhang {{ name }} herunterladen", "Drag your files here": "Ziehen Sie Ihre Dateien hierher", diff --git a/src/i18n/en.json b/src/i18n/en.json index f63c53d7e4..3dc0a620e1 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -29,6 +29,7 @@ "Create poll": "Create poll", "Current location": "Current location", "Delete": "Delete", + "Delete for me": "Delete for me", "Delivered": "Delivered", "Download attachment {{ name }}": "Download attachment {{ name }}", "Drag your files here": "Drag your files here", diff --git a/src/i18n/es.json b/src/i18n/es.json index 86b0cb27a3..9f28144c92 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -29,6 +29,7 @@ "Create poll": "Crear encuesta", "Current location": "Ubicación actual", "Delete": "Borrar", + "Delete for me": "Eliminar para mí", "Delivered": "Entregado", "Download attachment {{ name }}": "Descargar adjunto {{ name }}", "Drag your files here": "Arrastra tus archivos aquí", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index deb1664c2b..9c5128f1d6 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -29,6 +29,7 @@ "Create poll": "Créer un sondage", "Current location": "Emplacement actuel", "Delete": "Supprimer", + "Delete for me": "Supprimer pour moi", "Delivered": "Publié", "Download attachment {{ name }}": "Télécharger la pièce jointe {{ name }}", "Drag your files here": "Glissez vos fichiers ici", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 3ac1abaf7f..a071b8947d 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -29,6 +29,7 @@ "Create poll": "मतदान बनाएँ", "Current location": "वर्तमान स्थान", "Delete": "डिलीट", + "Delete for me": "मेरे लिए डिलीट करें", "Delivered": "पहुंच गया", "Download attachment {{ name }}": "अनुलग्नक {{ name }} डाउनलोड करें", "Drag your files here": "अपनी फ़ाइलें यहाँ खींचें", diff --git a/src/i18n/it.json b/src/i18n/it.json index 8b01bdfc4f..bd381a59de 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -29,6 +29,7 @@ "Create poll": "Crea sondaggio", "Current location": "Posizione attuale", "Delete": "Elimina", + "Delete for me": "Elimina per me", "Delivered": "Consegnato", "Download attachment {{ name }}": "Scarica l'allegato {{ name }}", "Drag your files here": "Trascina i tuoi file qui", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index c7721c7658..426f130d7c 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -29,6 +29,7 @@ "Create poll": "投票を作成", "Current location": "現在の位置", "Delete": "消去", + "Delete for me": "自分用に削除", "Delivered": "配信しました", "Download attachment {{ name }}": "添付ファイル {{ name }} をダウンロード", "Drag your files here": "ここにファイルをドラッグ", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 2e767e737d..9db8c99390 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -29,6 +29,7 @@ "Create poll": "투표 생성", "Current location": "현재 위치", "Delete": "삭제", + "Delete for me": "나만 삭제", "Delivered": "배달됨", "Download attachment {{ name }}": "첨부 파일 {{ name }} 다운로드", "Drag your files here": "여기로 파일을 끌어다 놓으세요", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 7c841729f3..d84c170856 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -29,6 +29,7 @@ "Create poll": "Maak peiling", "Current location": "Huidige locatie", "Delete": "Verwijder", + "Delete for me": "Voor mij verwijderen", "Delivered": "Afgeleverd", "Download attachment {{ name }}": "Bijlage {{ name }} downloaden", "Drag your files here": "Sleep je bestanden hier naartoe", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 3d2b2f3cb1..c4a3b4dcd5 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -29,6 +29,7 @@ "Create poll": "Criar enquete", "Current location": "Localização atual", "Delete": "Excluir", + "Delete for me": "Excluir para mim", "Delivered": "Entregue", "Download attachment {{ name }}": "Baixar anexo {{ name }}", "Drag your files here": "Arraste seus arquivos aqui", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 7ff258ef01..f2c3e04b23 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -29,6 +29,7 @@ "Create poll": "Создать опрос", "Current location": "Текущее местоположение", "Delete": "Удалить", + "Delete for me": "Удалить для меня", "Delivered": "Отправлено", "Download attachment {{ name }}": "Скачать вложение {{ name }}", "Drag your files here": "Перетащите ваши файлы сюда", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 94f4682706..d4390e1055 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -29,6 +29,7 @@ "Create poll": "Anket oluştur", "Current location": "Mevcut konum", "Delete": "Sil", + "Delete for me": "Benim için sil", "Delivered": "İletildi", "Download attachment {{ name }}": "Ek {{ name }}'i indir", "Drag your files here": "Dosyalarınızı buraya sürükleyin",