diff --git a/src/components/ChannelListItem/__tests__/utils.test.ts b/src/components/ChannelListItem/__tests__/utils.test.ts index 5777bba42..fa1148b75 100644 --- a/src/components/ChannelListItem/__tests__/utils.test.ts +++ b/src/components/ChannelListItem/__tests__/utils.test.ts @@ -46,6 +46,12 @@ describe('ChannelPreview utils', () => { const channelWithDeletedMessage = generateChannel({ messages: [generateMessage({ deleted_at: new Date().toISOString() })], }); + const channelWithDeletedTypeMessage = generateChannel({ + messages: [generateMessage({ type: 'deleted' })], + }); + const channelWithDeletedForMeMessage = generateChannel({ + messages: [generateMessage({ deleted_for_me: true })], + }); const channelWithLocationMessage = generateChannel({ messages: [ generateMessage({ @@ -85,6 +91,12 @@ describe('ChannelPreview utils', () => { it.each([ ['Nothing yet...', 'channelWithEmptyMessage', channelWithEmptyMessage], ['Message deleted', 'channelWithDeletedMessage', channelWithDeletedMessage], + ['Message deleted', 'channelWithDeletedTypeMessage', channelWithDeletedTypeMessage], + [ + 'Message deleted', + 'channelWithDeletedForMeMessage', + channelWithDeletedForMeMessage, + ], ['🏙 Attachment...', 'channelWithAttachmentMessage', channelWithAttachmentMessage], ['📍Shared location', 'channelWithLocationMessage', channelWithLocationMessage], [ diff --git a/src/components/ChannelListItem/utils.tsx b/src/components/ChannelListItem/utils.tsx index a4d319b69..841119b6d 100644 --- a/src/components/ChannelListItem/utils.tsx +++ b/src/components/ChannelListItem/utils.tsx @@ -8,6 +8,7 @@ import { getTranslatedMessageText } from '../../context/MessageTranslationViewCo import type { TranslationContextValue } from '../../context/TranslationContext'; import type { PluggableList } from 'unified'; import { htmlToTextPlugin, imageToLink, plusPlusToEmphasis } from '../Message'; +import { isMessageDeleted } from '../Message/utils'; import remarkGfm from 'remark-gfm'; const remarkPlugins: PluggableList = [ @@ -54,7 +55,7 @@ export const getLatestMessagePreview = ( return t('Nothing yet...'); } - if (latestMessage.deleted_at) { + if (isMessageDeleted(latestMessage)) { return t('Message deleted'); } diff --git a/src/components/Message/MessageUI.tsx b/src/components/Message/MessageUI.tsx index 6c11bada8..0d5ea0158 100644 --- a/src/components/Message/MessageUI.tsx +++ b/src/components/Message/MessageUI.tsx @@ -20,6 +20,7 @@ import { countEmojis, isMessageBlocked, isMessageBounced, + isMessageDeleted, isMessageEdited, isMessageErrorRetryable, messageHasAttachments, @@ -95,8 +96,7 @@ const MessageUIWithContext = ({ () => isMessageAIGenerated?.(message), [isMessageAIGenerated, message], ); - const isDeleted = - !!message.deleted_at || message.type === 'deleted' || message.deleted_for_me; + const isDeleted = isMessageDeleted(message); const finalAttachments = useMemo( () => diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index 7a16c493b..34d8f3586 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -102,7 +102,7 @@ export const getMessageActions = ( if (actions && typeof actions === 'boolean') { // If value of actions is true, then populate all the possible values messageActions = Object.keys(MESSAGE_ACTIONS); - } else if (actions && actions.length > 0) { + } else if (actions && Array.isArray(actions) && actions.length > 0) { messageActions = [...actions]; } else { return []; @@ -405,5 +405,8 @@ export const isMessageBlocked = ( (message.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_REMOVE' || message.moderation?.action === 'remove'); +export const isMessageDeleted = (message: LocalMessage): boolean => + Boolean(message.deleted_at || message.type === 'deleted' || message.deleted_for_me); + export const isMessageEdited = (message: Pick) => !!message.message_text_updated_at; diff --git a/src/components/MessageActions/MessageActions.defaults.tsx b/src/components/MessageActions/MessageActions.defaults.tsx index 09a2be922..e0bf15b06 100644 --- a/src/components/MessageActions/MessageActions.defaults.tsx +++ b/src/components/MessageActions/MessageActions.defaults.tsx @@ -25,7 +25,7 @@ import { IconUnpin, IconUserCheck, } from '../Icons'; -import { isUserMuted } from '../Message/utils'; +import { isMessageDeleted, isUserMuted } from '../Message/utils'; import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController'; import { savePreEditSnapshot } from '../MessageComposer/preEditSnapshot'; import { useNotificationApi } from '../Notifications'; @@ -500,6 +500,8 @@ const DefaultMessageActionComponents = { const { t } = useTranslationContext(); const [openModal, setOpenModal] = useState(false); + if (isMessageDeleted(message)) return null; + return ( <> ', () => { expect(handleDelete).toHaveBeenCalledTimes(1); }); + it('should not show Delete when the message is already deleted', async () => { + const message = generateMessage({ + deleted_at: new Date().toISOString(), + user: alice, + }); + await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'delete-own-message': true }, + }, + customMessageContext: { message }, + }); + await toggleOpenMessageActions(); + + expect(screen.queryByText('Delete message')).not.toBeInTheDocument(); + }); + + it('should not show Delete when the message type is deleted', async () => { + const message = generateMessage({ + type: 'deleted', + user: alice, + }); + await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'delete-own-message': true }, + }, + customMessageContext: { message }, + }); + await toggleOpenMessageActions(); + + expect(screen.queryByText('Delete message')).not.toBeInTheDocument(); + }); + + it('should not show Delete when the message is deleted for me', async () => { + const message = generateMessage({ + deleted_for_me: true, + user: alice, + }); + await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'delete-own-message': true }, + }, + customMessageContext: { message }, + }); + await toggleOpenMessageActions(); + + expect(screen.queryByText('Delete message')).not.toBeInTheDocument(); + }); + it('should include Edit in dropdown actions when user has edit capability', async () => { const message = generateMessage({ user: alice }); const { container } = await renderMessageActions({ diff --git a/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts b/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts index 7eb03e084..0ab21c78b 100644 --- a/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts +++ b/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts @@ -5,6 +5,7 @@ import { useUserRole } from '../../Message/hooks'; import { ACTIONS_NOT_WORKING_IN_THREAD, isMessageBounced, + isMessageDeleted, isMessageErrorRetryable, isNetworkSendFailure, } from '../../Message/utils'; @@ -15,7 +16,7 @@ import type { MessageActionSetItem } from '../MessageActions'; * Base filter hook which covers actions of type `delete`, `edit`, * `flag`, `markUnread`, `mute`, `quote`, `react` and `reply`, whether * the rendered message is a reply (replies are limited to certain actions) and - * whether the message has appropriate type and status. + * whether the message has appropriate type and status (including soft-deleted). */ export const useBaseMessageActionSetFilter = ( messageActionSet: MessageActionSetItem[], @@ -23,6 +24,7 @@ export const useBaseMessageActionSetFilter = ( ) => { const { initialMessage: isInitialMessage, message } = useMessageContext(); const { channelConfig } = useChannelStateContext(); + const messageIsDeleted = isMessageDeleted(message); const { canBlockUser, canDelete, @@ -68,7 +70,9 @@ export const useBaseMessageActionSetFilter = ( return ( (type === 'resendMessage' && canSendMessage && (allowRetry || isBounced)) || (type === 'edit' && ((isBounced && canEdit) || hasNetworkSendFailure)) || - (type === 'delete' && ((isBounced && canDelete) || hasNetworkSendFailure)) + (type === 'delete' && + !messageIsDeleted && + ((isBounced && canDelete) || hasNetworkSendFailure)) ); } @@ -76,7 +80,7 @@ export const useBaseMessageActionSetFilter = ( type === 'resendMessage' || (type === 'blockUser' && !canBlockUser) || (type === 'copyMessageText' && !message.text) || - (type === 'delete' && !canDelete) || + (type === 'delete' && (!canDelete || messageIsDeleted)) || (type === 'edit' && !canEdit) || (type === 'flag' && !canFlag) || (type === 'markUnread' && !canMarkUnread) || @@ -106,6 +110,7 @@ export const useBaseMessageActionSetFilter = ( channelConfig, isBounced, isInitialMessage, + messageIsDeleted, isMessageThreadReply, message.error, message.status, diff --git a/src/components/SummarizedMessagePreview/__tests__/useLatestMessagePreview.test.tsx b/src/components/SummarizedMessagePreview/__tests__/useLatestMessagePreview.test.tsx index c3a3f7568..4595cafb1 100644 --- a/src/components/SummarizedMessagePreview/__tests__/useLatestMessagePreview.test.tsx +++ b/src/components/SummarizedMessagePreview/__tests__/useLatestMessagePreview.test.tsx @@ -155,20 +155,27 @@ describe('useLatestMessagePreview', () => { }); describe('deleted message', () => { - it('returns deleted type with delivery status and sender name', () => { - const message = generateMessage({ - deleted_at: new Date().toISOString(), - user: ownUser, - }); - const { result } = renderPreviewHook({ - latestMessage: message, - messageDeliveryStatus: MessageDeliveryStatus.DELIVERED, - }); - expect(result.current.type).toBe('deleted'); - expect(result.current.text).toBe('Message deleted'); - expect(result.current.deliveryStatus).toBe('delivered'); - expect(result.current.senderName).toBe('You'); - }); + it.each([ + ['deleted_at timestamp', { deleted_at: new Date().toISOString() }], + ['deleted type', { type: 'deleted' as const }], + ['deleted for current user', { deleted_for_me: true }], + ])( + 'returns deleted type with delivery status and sender name for %s', + (_label, messageOverrides) => { + const message = generateMessage({ + ...messageOverrides, + user: ownUser, + }); + const { result } = renderPreviewHook({ + latestMessage: message, + messageDeliveryStatus: MessageDeliveryStatus.DELIVERED, + }); + expect(result.current.type).toBe('deleted'); + expect(result.current.text).toBe('Message deleted'); + expect(result.current.deliveryStatus).toBe('delivered'); + expect(result.current.senderName).toBe('You'); + }, + ); }); describe('poll message', () => { diff --git a/src/components/SummarizedMessagePreview/hooks/useLatestMessagePreview.ts b/src/components/SummarizedMessagePreview/hooks/useLatestMessagePreview.ts index f90170309..6796680af 100644 --- a/src/components/SummarizedMessagePreview/hooks/useLatestMessagePreview.ts +++ b/src/components/SummarizedMessagePreview/hooks/useLatestMessagePreview.ts @@ -15,6 +15,7 @@ import { useChatContext, useTranslationContext, } from '../../../context'; +import { isMessageDeleted } from '../../Message/utils'; import type { MessageDeliveryStatus } from '../../ChannelListItem'; @@ -161,7 +162,7 @@ export const useLatestMessagePreview = ({ senderName = latestMessage.user?.name || latestMessage.user?.id; } - if (latestMessage.deleted_at) { + if (isMessageDeleted(latestMessage)) { return { deliveryStatus, senderName,