diff --git a/package.json b/package.json index e62f69dc23..e5dfe11dfd 100644 --- a/package.json +++ b/package.json @@ -142,7 +142,7 @@ "emoji-mart": "^5.4.0", "react": "^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.14.0", "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.14.0", - "stream-chat": "^9.15.0" + "stream-chat": "^9.17.0" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -236,7 +236,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "semantic-release": "^24.2.3", - "stream-chat": "^9.15.0", + "stream-chat": "^9.17.0", "ts-jest": "^29.2.5", "typescript": "^5.4.5", "typescript-eslint": "^8.17.0" diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 8746dda216..8a0dc6e34c 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -425,6 +425,11 @@ const ChannelInner = ( }); } + // ignore the event if it is not targeted at the current channel. + // Event targeted at this channel or globally targeted event should lead to state refresh + if (event.type === 'user.messages.deleted' && event.cid && event.cid !== channel.cid) + return; + if (event.type === 'user.watching.start' || event.type === 'user.watching.stop') return; @@ -568,6 +573,7 @@ const ChannelInner = ( client.on('connection.recovered', handleEvent); client.on('user.updated', handleEvent); client.on('user.deleted', handleEvent); + client.on('user.messages.deleted', handleEvent); channel.on(handleEvent); } })(); diff --git a/src/components/ChannelPreview/ChannelPreview.tsx b/src/components/ChannelPreview/ChannelPreview.tsx index 937e76b325..154d061646 100644 --- a/src/components/ChannelPreview/ChannelPreview.tsx +++ b/src/components/ChannelPreview/ChannelPreview.tsx @@ -135,7 +135,12 @@ export const ChannelPreview = (props: ChannelPreviewProps) => { useEffect(() => { refreshUnreadCount(); - const handleEvent = () => { + const handleEvent = (event: Event) => { + const deletedMessagesInAnotherChannel = + event.type === 'user.messages.deleted' && event.cid && event.cid !== channel.cid; + + if (deletedMessagesInAnotherChannel) return; + setLastMessage( channel.state.latestMessages[channel.state.latestMessages.length - 1], ); @@ -145,6 +150,7 @@ export const ChannelPreview = (props: ChannelPreviewProps) => { channel.on('message.new', handleEvent); channel.on('message.updated', handleEvent); channel.on('message.deleted', handleEvent); + client.on('user.messages.deleted', handleEvent); channel.on('message.undeleted', handleEvent); channel.on('channel.truncated', handleEvent); @@ -152,10 +158,11 @@ export const ChannelPreview = (props: ChannelPreviewProps) => { channel.off('message.new', handleEvent); channel.off('message.updated', handleEvent); channel.off('message.deleted', handleEvent); + client.off('user.messages.deleted', handleEvent); channel.off('message.undeleted', handleEvent); channel.off('channel.truncated', handleEvent); }; - }, [channel, refreshUnreadCount, channelUpdateCount]); + }, [channel, client, refreshUnreadCount, channelUpdateCount]); if (!Preview) return null; diff --git a/src/components/ChannelPreview/__tests__/ChannelPreview.test.js b/src/components/ChannelPreview/__tests__/ChannelPreview.test.js index db9540daf4..eff538705c 100644 --- a/src/components/ChannelPreview/__tests__/ChannelPreview.test.js +++ b/src/components/ChannelPreview/__tests__/ChannelPreview.test.js @@ -15,6 +15,7 @@ import { dispatchMessageUpdatedEvent, dispatchNotificationMarkRead, dispatchNotificationMarkUnread, + dispatchUserMessagesDeletedEvent, dispatchUserUpdatedEvent, generateChannel, generateMember, @@ -363,6 +364,131 @@ describe('ChannelPreview', () => { }, ); + describe('user.messages.deleted', () => { + const deletedMessageText = 'Message deleted'; + const user = { id: 'banned-user' }; + + it('should update latest message preview for all ChannelPreviews if global ban', async () => { + const { + channels: [c0, c1], + client, + } = await initClientWithChannels({ + channelsData: [ + generateChannel({ + messages: [ + generateMessage({ + created_at: '1970-01-01T00:00:00.000Z', + user: { id: 'other-user' }, + }), + generateMessage({ created_at: '1970-01-02T00:00:00.000Z', user }), + ], + }), + generateChannel({ + messages: [ + generateMessage({ + created_at: '1971-01-01T00:00:00.000Z', + user: { id: 'other-user' }, + }), + generateMessage({ created_at: '1971-01-02T00:00:00.000Z', user }), + ], + }), + ], + customUser: user, + }); + + const { container } = render( + jest.fn(), + }} + > + + + , + ); + + await act(() => { + dispatchUserMessagesDeletedEvent({ + client, + user, + }); + }); + + await waitFor(() => { + const lastMessagePreviews = container.querySelectorAll( + '.str-chat__channel-preview-messenger--last-message', + ); + expect(lastMessagePreviews.length).toBe(2); + lastMessagePreviews.forEach((preview) => { + expect(preview).toHaveTextContent(deletedMessageText); + }); + }); + }); + + it('should update latest message preview if the channel is the target', async () => { + const { + channels: [c0, c1], + client, + } = await initClientWithChannels({ + channelsData: [ + generateChannel({ + messages: [ + generateMessage({ + created_at: '1970-01-01T00:00:00.000Z', + user: { id: 'other-user' }, + }), + generateMessage({ created_at: '1970-01-02T00:00:00.000Z', user }), + ], + }), + generateChannel({ + messages: [ + generateMessage({ + created_at: '1971-01-01T00:00:00.000Z', + user: { id: 'other-user' }, + }), + generateMessage({ created_at: '1971-01-02T00:00:00.000Z', user }), + ], + }), + ], + customUser: user, + }); + + const { container } = render( + jest.fn(), + }} + > + + + , + ); + + await act(() => { + dispatchUserMessagesDeletedEvent({ + channel: c0, // target + client, + user, + }); + }); + + await waitFor(() => { + const lastMessagePreviews = container.querySelectorAll( + '.str-chat__channel-preview-messenger--last-message', + ); + expect(lastMessagePreviews.length).toBe(2); + expect(lastMessagePreviews[0]).toHaveTextContent(deletedMessageText); + expect(lastMessagePreviews[1]).toHaveTextContent( + c1.state.messages.slice(-1)[0].text, + ); + }); + }); + }); + describe('notification.mark_read', () => { it('should set unread count to 0 for event missing CID', async () => { const unreadCount = getRandomInt(1, 10); diff --git a/src/mock-builders/event/index.js b/src/mock-builders/event/index.js index f24d5b165c..f6a118519a 100644 --- a/src/mock-builders/event/index.js +++ b/src/mock-builders/event/index.js @@ -15,4 +15,5 @@ export { default as dispatchNotificationMarkUnread } from './notificationMarkUnr export { default as dispatchNotificationMessageNewEvent } from './notificationMessageNew'; export { default as dispatchNotificationMutesUpdated } from './notificationMutesUpdated'; export { default as dispatchNotificationRemovedFromChannel } from './notificationRemovedFromChannel'; +export { default as dispatchUserMessagesDeletedEvent } from './userMessagesDeleted'; export { default as dispatchUserUpdatedEvent } from './userUpdated'; diff --git a/src/mock-builders/event/userMessagesDeleted.ts b/src/mock-builders/event/userMessagesDeleted.ts new file mode 100644 index 0000000000..4c6e276483 --- /dev/null +++ b/src/mock-builders/event/userMessagesDeleted.ts @@ -0,0 +1,38 @@ +import type { ChannelResponse, StreamChat, UserResponse } from 'stream-chat'; + +export default ({ + channel, + client, + hardDelete, + user, +}: { + channel: ChannelResponse & { name?: string }; // mock-builders are excluded in tsconfig.json + client: StreamChat; + hardDelete?: boolean; + user: UserResponse; +}) => { + if (channel) { + const [channel_id, channel_type] = channel.cid.split(':'); + client.dispatchEvent({ + channel_custom: { + // archived: false, + name: channel.name, + }, + channel_id, + channel_member_count: 2, + channel_type, + cid: channel.cid, + created_at: new Date().toISOString(), + hard_delete: !!hardDelete, + type: 'user.messages.deleted', + user, + }); + } else { + client.dispatchEvent({ + created_at: new Date().toISOString(), + hard_delete: !!hardDelete, + type: 'user.messages.deleted', + user, + }); + } +}; diff --git a/src/mock-builders/generator/channel.ts b/src/mock-builders/generator/channel.ts index 49cd09d8d6..efac3dedbd 100644 --- a/src/mock-builders/generator/channel.ts +++ b/src/mock-builders/generator/channel.ts @@ -1,13 +1,10 @@ import { nanoid } from 'nanoid'; -import type { ChannelConfigWithInfo, ChannelResponse } from 'stream-chat'; +import type { ChannelAPIResponse, ChannelConfigWithInfo } from 'stream-chat'; +import type { DeepPartial } from '../../types/types'; -export const generateChannel = ( - options: { - channel?: Partial; - config?: Partial; - } = { channel: {}, config: {} }, -) => { - const { channel: optionsChannel, ...optionsBesidesChannel } = options; +export const generateChannel = (options?: DeepPartial) => { + const { channel: optionsChannel, ...optionsBesidesChannel } = + options ?? ({} as ChannelAPIResponse); const id = optionsChannel?.id ?? nanoid(); const type = optionsChannel?.type ?? 'messaging'; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -68,6 +65,6 @@ export const generateChannel = ( type, updated_at: '2020-04-28T11:20:48.578147Z', ...restOptionsChannel, - } as ChannelResponse, + }, }; }; diff --git a/src/types/types.ts b/src/types/types.ts index b20af7cf90..ad3d2cc891 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -78,3 +78,7 @@ export type PartialSelected = Omit & Partial = { [K in keyof T]-?: T[K] extends object ? DeepRequired : T[K]; }; + +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; diff --git a/yarn.lock b/yarn.lock index db581f4c7e..a786684c4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8599,11 +8599,6 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -linkifyjs@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.2.0.tgz#9dd30222b9cbabec9c950e725ec00031c7fa3f08" - integrity sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw== - linkifyjs@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1" @@ -12047,10 +12042,10 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== -stream-chat@^9.15.0: - version "9.15.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.15.0.tgz#97df48dd1c11b6c251bace9a43519f98841660b8" - integrity sha512-Y2azwTbfyN8dMAovpxvjZTDTotpmRKucj/dYpVeiDgog9BZzxaaQVhKOYzDFdxA1I6PKicMAXLvgAf+py0CWEg== +stream-chat@^9.17.0: + version "9.17.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.17.0.tgz#540cf1ea03b08a394d6140696aae8528e9ba9ce2" + integrity sha512-ys6K73wIVWs5+qsfPJ9wumEUtgbMXYVbH1dhmAZ1oYtQ01dY/avsvt25PYDakVjKeyrnT+y8T/xEzfeF/WDJsg== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" @@ -12059,7 +12054,7 @@ stream-chat@^9.15.0: form-data "^4.0.4" isomorphic-ws "^5.0.0" jsonwebtoken "^9.0.2" - linkifyjs "^4.2.0" + linkifyjs "^4.3.2" ws "^8.18.1" stream-combiner2@~1.1.1: