Skip to content

Commit d622cf7

Browse files
committed
feat: add optional Delete for me message action
1 parent 757f667 commit d622cf7

File tree

23 files changed

+141
-46
lines changed

23 files changed

+141
-46
lines changed

src/components/Channel/Channel.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
ChannelMemberResponse,
1919
ChannelQueryOptions,
2020
ChannelState,
21+
DeleteMessageOptions,
2122
ErrorFromResponse,
2223
Event,
2324
EventAPIResponse,
@@ -176,7 +177,10 @@ export type ChannelProps = ChannelPropsForwardedToComponentContext & {
176177
*/
177178
channelQueryOptions?: ChannelQueryOptions;
178179
/** Custom action handler to override the default `client.deleteMessage(message.id)` function */
179-
doDeleteMessageRequest?: (message: LocalMessage) => Promise<MessageResponse>;
180+
doDeleteMessageRequest?: (
181+
message: LocalMessage,
182+
options?: DeleteMessageOptions,
183+
) => Promise<MessageResponse>;
180184
/** Custom action handler to override the default `channel.markRead` request function (advanced usage only) */
181185
doMarkReadRequest?: (
182186
channel: StreamChannel,
@@ -910,15 +914,18 @@ const ChannelInner = (
910914
);
911915

912916
const deleteMessage = useCallback(
913-
async (message: LocalMessage): Promise<MessageResponse> => {
917+
async (
918+
message: LocalMessage,
919+
options?: DeleteMessageOptions,
920+
): Promise<MessageResponse> => {
914921
if (!message?.id) {
915922
throw new Error('Cannot delete a message - missing message ID.');
916923
}
917924
let deletedMessage;
918925
if (doDeleteMessageRequest) {
919-
deletedMessage = await doDeleteMessageRequest(message);
926+
deletedMessage = await doDeleteMessageRequest(message, options);
920927
} else {
921-
const result = await client.deleteMessage(message.id);
928+
const result = await client.deleteMessage(message.id, options);
922929
deletedMessage = result.message;
923930
}
924931

src/components/Channel/__tests__/Channel.test.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1603,15 +1603,18 @@ describe('Channel', () => {
16031603

16041604
it('should call the default client.deleteMessage() function', async () => {
16051605
const message = generateMessage();
1606-
1606+
const deleteMessageOptions = { deleteForMe: true, hard: false };
16071607
const clientDeleteMessageSpy = jest
16081608
.spyOn(chatClient, 'deleteMessage')
16091609
.mockImplementationOnce(() => Promise.resolve({ message }));
16101610
await renderComponent({ channel, chatClient }, ({ deleteMessage }) => {
1611-
deleteMessage(message);
1611+
deleteMessage(message, deleteMessageOptions);
16121612
});
16131613
await waitFor(() =>
1614-
expect(clientDeleteMessageSpy).toHaveBeenCalledWith(message.id),
1614+
expect(clientDeleteMessageSpy).toHaveBeenCalledWith(
1615+
message.id,
1616+
deleteMessageOptions,
1617+
),
16151618
);
16161619
});
16171620

@@ -1640,7 +1643,7 @@ describe('Channel', () => {
16401643

16411644
it('should call the custom doDeleteMessageRequest instead of client.deleteMessage()', async () => {
16421645
const message = generateMessage();
1643-
1646+
const deleteMessageOptions = { deleteForMe: true, hard: false };
16441647
const doDeleteMessageRequest = jest.fn();
16451648
const clientDeleteMessageSpy = jest
16461649
.spyOn(chatClient, 'deleteMessage')
@@ -1649,13 +1652,16 @@ describe('Channel', () => {
16491652
await renderComponent(
16501653
{ channel, chatClient, doDeleteMessageRequest },
16511654
({ deleteMessage }) => {
1652-
deleteMessage(message);
1655+
deleteMessage(message, deleteMessageOptions);
16531656
},
16541657
);
16551658

16561659
await waitFor(() => {
16571660
expect(clientDeleteMessageSpy).not.toHaveBeenCalled();
1658-
expect(doDeleteMessageRequest).toHaveBeenCalledWith(message);
1661+
expect(doDeleteMessageRequest).toHaveBeenCalledWith(
1662+
message,
1663+
deleteMessageOptions,
1664+
);
16591665
});
16601666
});
16611667
});

src/components/Channel/hooks/useCreateChannelStateContext.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export const useCreateChannelStateContext = (
5959
channelCapabilities[capability] = true;
6060
});
6161

62+
// FIXME: this is crazy - I could not find out why the messages were not getting updated when only message properties that are not part
63+
// of this serialization has been changed. A great example of memoization gone wrong.
6264
const memoizedMessageData = skipMessageDataMemoization
6365
? messages
6466
: messages
@@ -69,10 +71,11 @@ export const useCreateChannelStateContext = (
6971
pinned,
7072
reply_count,
7173
status,
74+
type,
7275
updated_at,
7376
user,
7477
}) =>
75-
`${deleted_at}${
78+
`${type}${deleted_at}${
7679
latest_reactions ? latest_reactions.map(({ type }) => type).join() : ''
7780
}${pinned}${reply_count}${status}${
7881
updated_at && (isDayOrMoment(updated_at) || isDate(updated_at))

src/components/Message/__tests__/utils.test.js

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
MESSAGE_ACTIONS,
1818
messageHasAttachments,
1919
messageHasReactions,
20+
OPTIONAL_MESSAGE_ACTIONS,
2021
validateAndGetMessage,
2122
} from '../utils';
2223

@@ -90,6 +91,7 @@ describe('Message utils', () => {
9091
canReply: true,
9192
};
9293
const actions = Object.values(MESSAGE_ACTIONS);
94+
const optionalActions = Object.values(OPTIONAL_MESSAGE_ACTIONS);
9395

9496
it.each([
9597
['empty', []],
@@ -131,31 +133,36 @@ describe('Message utils', () => {
131133
});
132134

133135
it.each([
134-
['allow', 'edit', 'canEdit', true],
135-
['not allow', 'edit', 'canEdit', false],
136-
['allow', 'delete', 'canDelete', true],
137-
['not allow', 'delete', 'canDelete', false],
138-
['allow', 'flag', 'canFlag', true],
139-
['not allow', 'flag', 'canFlag', false],
140-
['allow', 'markUnread', 'canMarkUnread', true],
141-
['not allow', 'markUnread', 'canMarkUnread', false],
142-
['allow', 'mute', 'canMute', true],
143-
['not allow', 'mute', 'canMute', false],
144-
['allow', 'pin', 'canPin', true],
145-
['not allow', 'pin', 'canPin', false],
146-
['allow', 'quote', 'canQuote', true],
147-
['not allow', 'quote', 'canQuote', false],
148-
])('it should %s %s when %s is %s', (_, action, capabilityKey, capabilityValue) => {
149-
const capabilities = {
150-
[capabilityKey]: capabilityValue,
151-
};
152-
const result = getMessageActions(actions, capabilities);
153-
if (capabilityValue) {
154-
expect(result).toStrictEqual([action]);
155-
} else {
156-
expect(result).not.toStrictEqual([action]);
157-
}
158-
});
136+
['allow', 'edit', 'canEdit', true, actions],
137+
['not allow', 'edit', 'canEdit', false, actions],
138+
['allow', 'delete', 'canDelete', true, actions],
139+
['not allow', 'delete', 'canDelete', false, actions],
140+
['allow', 'deleteForMe', 'canDelete', true, optionalActions],
141+
['not allow', 'deleteForMe', 'canDelete', false, optionalActions],
142+
['allow', 'flag', 'canFlag', true, actions],
143+
['not allow', 'flag', 'canFlag', false, actions],
144+
['allow', 'markUnread', 'canMarkUnread', true, actions],
145+
['not allow', 'markUnread', 'canMarkUnread', false, actions],
146+
['allow', 'mute', 'canMute', true, actions],
147+
['not allow', 'mute', 'canMute', false, actions],
148+
['allow', 'pin', 'canPin', true, actions],
149+
['not allow', 'pin', 'canPin', false, actions],
150+
['allow', 'quote', 'canQuote', true, actions],
151+
['not allow', 'quote', 'canQuote', false, actions],
152+
])(
153+
'it should %s %s when %s is %s',
154+
(_, action, capabilityKey, capabilityValue, actionsToUse) => {
155+
const capabilities = {
156+
[capabilityKey]: capabilityValue,
157+
};
158+
const result = getMessageActions(actionsToUse, capabilities);
159+
if (capabilityValue) {
160+
expect(result).toStrictEqual([action]);
161+
} else {
162+
expect(result).not.toStrictEqual([action]);
163+
}
164+
},
165+
);
159166
});
160167

161168
describe('shouldMessageComponentUpdate', () => {

src/components/Message/hooks/__tests__/useDeleteHandler.test.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,22 @@ describe('useDeleteHandler custom hook', () => {
7575

7676
it('should delete a message by its id', async () => {
7777
const message = generateMessage();
78+
const deleteMessageOptions = { deleteForMe: true, hard: false };
7879
const handleDelete = await renderUseDeleteHandler(message);
79-
await handleDelete(mouseEventMock);
80-
expect(deleteMessage).toHaveBeenCalledWith(message);
80+
await handleDelete(mouseEventMock, deleteMessageOptions);
81+
expect(deleteMessage).toHaveBeenCalledWith(message, deleteMessageOptions);
82+
});
83+
84+
it('should enrich the message in the delete response with deleted_for_me and type="deleted"', async () => {
85+
jest.spyOn(client, 'deleteMessage').mockResolvedValueOnce({ message: testMessage });
86+
const deleteMessageOptions = { deleteForMe: true, hard: false };
87+
const handleDelete = await renderUseDeleteHandler(testMessage);
88+
await handleDelete(mouseEventMock, deleteMessageOptions);
89+
expect(updateMessage).toHaveBeenCalledWith({
90+
...testMessage,
91+
deleted_for_me: true,
92+
type: 'deleted',
93+
});
8194
});
8295

8396
it('should update the message with the result of deletion', async () => {

src/components/Message/hooks/useDeleteHandler.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useChannelActionContext } from '../../../context/ChannelActionContext';
44
import { useChatContext } from '../../../context/ChatContext';
55
import { useTranslationContext } from '../../../context/TranslationContext';
66

7-
import type { LocalMessage } from 'stream-chat';
7+
import type { DeleteMessageOptions, LocalMessage } from 'stream-chat';
88
import type { ReactEventHandler } from '../types';
99

1010
export type DeleteMessageNotifications = {
@@ -22,14 +22,20 @@ export const useDeleteHandler = (
2222
const { client } = useChatContext('useDeleteHandler');
2323
const { t } = useTranslationContext('useDeleteHandler');
2424

25-
return async (event) => {
25+
return async (event, options?: DeleteMessageOptions) => {
2626
event.preventDefault();
2727
if (!message?.id || !client || !updateMessage) {
2828
return;
2929
}
3030

3131
try {
32-
const deletedMessage = await deleteMessage(message);
32+
const deletedMessage = await deleteMessage(message, options);
33+
// necessary to populate the below values as the server does not return the message in the response as deleted
34+
if (options?.deleteForMe) {
35+
// deleted_at is not available for messages that are deleted_for_me
36+
deletedMessage.deleted_for_me = true;
37+
deletedMessage.type = 'deleted';
38+
}
3339
updateMessage(deletedMessage);
3440
} catch (e) {
3541
const errorMessage =

src/components/Message/utils.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ export const isUserMuted = (message: LocalMessage, mutes?: Mute[]) => {
5252
return !!userMuted.length;
5353
};
5454

55+
export const OPTIONAL_MESSAGE_ACTIONS = {
56+
deleteForMe: 'deleteForMe',
57+
};
58+
5559
export const MESSAGE_ACTIONS = {
5660
delete: 'delete',
5761
edit: 'edit',
@@ -67,7 +71,7 @@ export const MESSAGE_ACTIONS = {
6771
};
6872

6973
export type MessageActionsArray<T extends string = string> = Array<
70-
keyof typeof MESSAGE_ACTIONS | T
74+
keyof typeof MESSAGE_ACTIONS | keyof typeof OPTIONAL_MESSAGE_ACTIONS | T
7175
>;
7276

7377
// @deprecated in favor of `channelCapabilities` - TODO: remove in next major release
@@ -172,6 +176,10 @@ export const getMessageActions = (
172176
messageActionsAfterPermission.push(MESSAGE_ACTIONS.delete);
173177
}
174178

179+
if (canDelete && messageActions.indexOf(OPTIONAL_MESSAGE_ACTIONS.deleteForMe) > -1) {
180+
messageActionsAfterPermission.push(OPTIONAL_MESSAGE_ACTIONS.deleteForMe);
181+
}
182+
175183
if (canEdit && messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1) {
176184
messageActionsAfterPermission.push(MESSAGE_ACTIONS.edit);
177185
}

src/components/MessageActions/MessageActionsBox.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { ComponentProps } from 'react';
33
import React from 'react';
44
import { CustomMessageActionsList as DefaultCustomMessageActionsList } from './CustomMessageActionsList';
55
import { RemindMeActionButton } from './RemindMeSubmenu';
6-
import { useMessageReminder } from '../Message';
6+
import { OPTIONAL_MESSAGE_ACTIONS, useMessageReminder } from '../Message';
77
import { useMessageComposer } from '../MessageInput';
88
import {
99
useChatContext,
@@ -162,6 +162,17 @@ const UnMemoizedMessageActionsBox = (props: MessageActionsBoxProps) => {
162162
{t('Delete')}
163163
</button>
164164
)}
165+
{messageActions.indexOf(OPTIONAL_MESSAGE_ACTIONS.deleteForMe) > -1 &&
166+
!message.deleted_for_me && (
167+
<button
168+
aria-selected='false'
169+
className={buttonClassName}
170+
onClick={(e) => handleDelete(e, { deleteForMe: true })}
171+
role='option'
172+
>
173+
{t('Delete for me')}
174+
</button>
175+
)}
165176
{messageActions.indexOf(MESSAGE_ACTIONS.remindMe) > -1 && (
166177
<RemindMeActionButton className={buttonClassName} isMine={mine} />
167178
)}

src/components/MessageActions/__tests__/MessageActionsBox.test.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,20 @@ describe('MessageActionsBox', () => {
179179
expect(results).toHaveNoViolations();
180180
});
181181

182+
it('should call the handleDelete prop if the deleteForMe button is clicked', async () => {
183+
getMessageActionsMock.mockImplementationOnce(() => ['deleteForMe']);
184+
const handleDelete = jest.fn();
185+
const {
186+
result: { container, getByText },
187+
} = await renderComponent({ handleDelete, message: generateMessage() });
188+
await act(async () => {
189+
await fireEvent.click(getByText('Delete for me'));
190+
});
191+
expect(handleDelete).toHaveBeenCalledTimes(1);
192+
const results = await axe(container);
193+
expect(results).toHaveNoViolations();
194+
});
195+
182196
it('should call the handlePin prop if the pin button is clicked', async () => {
183197
getMessageActionsMock.mockImplementationOnce(() => ['pin']);
184198
const handlePin = jest.fn();

src/context/ChannelActionContext.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { PropsWithChildren } from 'react';
22
import React, { useContext } from 'react';
33

44
import type {
5+
DeleteMessageOptions,
56
LocalMessage,
67
Message,
78
MessageResponse,
@@ -29,7 +30,10 @@ export type RetrySendMessage = (message: LocalMessage) => Promise<void>;
2930
export type ChannelActionContextValue = {
3031
addNotification: (text: string, type: 'success' | 'error') => void;
3132
closeThread: (event?: React.BaseSyntheticEvent) => void;
32-
deleteMessage: (message: LocalMessage) => Promise<MessageResponse>;
33+
deleteMessage: (
34+
message: LocalMessage,
35+
options?: DeleteMessageOptions,
36+
) => Promise<MessageResponse>;
3337
dispatch: React.Dispatch<ChannelStateReducerAction>;
3438
editMessage: (
3539
message: LocalMessage | MessageResponse,

0 commit comments

Comments
 (0)