diff --git a/src/channel_state.ts b/src/channel_state.ts index 973dbcb94..9e47032c1 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -220,6 +220,17 @@ export class ChannelState< for (let i = 0; i < newMessages.length; i += 1) { const message = this.formatMessage(newMessages[i]); + if (message.user && this._channel?.cid) { + /** + * Store the reference to user for this channel, so that when we have to + * handle updates to user, we can use the reference map, to determine which + * channels need to be updated with updated user object. + */ + this._channel + .getClient() + .state.updateUserReference(message.user, this._channel.cid); + } + if (initializing && message.id && this.threads[message.id]) { // If we are initializing the state of channel (e.g., in case of connection recovery), // then in that case we remove thread related to this message from threads object. @@ -603,6 +614,126 @@ export class ChannelState< return { removed: result.length < msgArray.length, result }; }; + + /** + * Updates the message.user property with updated user object, for messages. + * + * @param {UserResponse} user + */ + updateUserMessages = (user: UserResponse) => { + const _updateUserMessages = ( + messages: Array< + ReturnType< + ChannelState< + AttachmentType, + ChannelType, + CommandType, + EventType, + MessageType, + ReactionType, + UserType + >['formatMessage'] + > + >, + user: UserResponse, + ) => { + for (let i = 0; i < messages.length; i++) { + const m = messages[i]; + if (m.user?.id === user.id) { + messages[i] = { ...m, user }; + } + } + }; + + _updateUserMessages(this.messages, user); + + for (const parentId in this.threads) { + _updateUserMessages(this.threads[parentId], user); + } + + _updateUserMessages(this.pinnedMessages, user); + }; + + /** + * Marks the messages as deleted, from deleted user. + * + * @param {UserResponse} user + * @param {boolean} hardDelete + */ + deleteUserMessages = (user: UserResponse, hardDelete = false) => { + const _deleteUserMessages = ( + messages: Array< + ReturnType< + ChannelState< + AttachmentType, + ChannelType, + CommandType, + EventType, + MessageType, + ReactionType, + UserType + >['formatMessage'] + > + >, + user: UserResponse, + hardDelete = false, + ) => { + for (let i = 0; i < messages.length; i++) { + const m = messages[i]; + if (m.user?.id !== user.id) { + continue; + } + + if (hardDelete) { + /** + * In case of hard delete, we need to strip down all text, html, + * attachments and all the custom properties on message + */ + messages[i] = ({ + cid: m.cid, + created_at: m.created_at, + deleted_at: user.deleted_at, + id: m.id, + latest_reactions: [], + mentioned_users: [], + own_reactions: [], + parent_id: m.parent_id, + reply_count: m.reply_count, + status: m.status, + thread_participants: m.thread_participants, + type: 'deleted', + updated_at: m.updated_at, + user: m.user, + } as unknown) as ReturnType< + ChannelState< + AttachmentType, + ChannelType, + CommandType, + EventType, + MessageType, + ReactionType, + UserType + >['formatMessage'] + >; + } else { + messages[i] = { + ...m, + type: 'deleted', + deleted_at: user.deleted_at, + }; + } + } + }; + + _deleteUserMessages(this.messages, user, hardDelete); + + for (const parentId in this.threads) { + _deleteUserMessages(this.threads[parentId], user, hardDelete); + } + + _deleteUserMessages(this.pinnedMessages, user, hardDelete); + }; + /** * filterErrorMessages - Removes error messages from the channel state. * diff --git a/src/client.ts b/src/client.ts index 7bbff364c..6ce027893 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1169,6 +1169,130 @@ export class StreamChat< this.dispatchEvent(event); }; + /** + * Updates the members and watchers of the currently active channels that contain this user + * + * @param {UserResponse} user + */ + _updateMemberWatcherReferences = (user: UserResponse) => { + const refMap = this.state.userChannelReferences[user.id] || {}; + for (const channelID in refMap) { + const channel = this.activeChannels[channelID]; + /** search the members and watchers and update as needed... */ + if (channel?.state) { + if (channel.state.members[user.id]) { + channel.state.members[user.id].user = user; + } + if (channel.state.watchers[user.id]) { + channel.state.watchers[user.id] = user; + } + } + } + }; + + /** + * @deprecated Please _updateMemberWatcherReferences instead. + * @private + */ + _updateUserReferences = this._updateMemberWatcherReferences; + + /** + * @private + * + * Updates the messages from the currently active channels that contain this user, + * with updated user object. + * + * @param {UserResponse} user + */ + _updateUserMessageReferences = (user: UserResponse) => { + const refMap = this.state.userChannelReferences[user.id] || {}; + + for (const channelID in refMap) { + const channel = this.activeChannels[channelID]; + const state = channel.state; + + /** update the messages from this user. */ + state?.updateUserMessages(user); + } + }; + + /** + * @private + * + * Deletes the messages from the currently active channels that contain this user + * + * If hardDelete is true, all the content of message will be stripped down. + * Otherwise, only 'message.type' will be set as 'deleted'. + * + * @param {UserResponse} user + * @param {boolean} hardDelete + */ + _deleteUserMessageReference = (user: UserResponse, hardDelete = false) => { + const refMap = this.state.userChannelReferences[user.id] || {}; + + for (const channelID in refMap) { + const channel = this.activeChannels[channelID]; + const state = channel.state; + + /** deleted the messages from this user. */ + state?.deleteUserMessages(user, hardDelete); + } + }; + + /** + * @private + * + * Handle following user related events: + * - user.presence.changed + * - user.updated + * - user.deleted + * + * @param {Event} event + */ + _handleUserEvent = ( + event: Event< + AttachmentType, + ChannelType, + CommandType, + EventType, + MessageType, + ReactionType, + UserType + >, + ) => { + if (!event.user) { + return; + } + + /** update the client.state with any changes to users */ + if (event.type === 'user.presence.changed' || event.type === 'user.updated') { + if (event.user?.id === this.userID) { + this.user = this.user && { ...this.user, ...event.user }; + /** Updating only available properties in _user object. */ + Object.keys(event.user).forEach((key) => { + if (this._user && key in this._user) { + /** @ts-expect-error */ + this._user[key] = event.user[key]; + } + }); + } + this.state.updateUser(event.user); + this._updateMemberWatcherReferences(event.user); + } + + if (event.type === 'user.updated') { + this._updateUserMessageReferences(event.user); + } + + if ( + event.type === 'user.deleted' && + event.user.deleted_at && + (event.mark_messages_deleted || event.hard_delete) + ) { + this._deleteUserMessageReference(event.user, event.hard_delete); + } + }; + _handleClientEvent( event: Event< AttachmentType, @@ -1190,24 +1314,14 @@ export class StreamChat< }, ); - // update the client.state with any changes to users if ( - event.user && - (event.type === 'user.presence.changed' || event.type === 'user.updated') + event.type === 'user.presence.changed' || + event.type === 'user.updated' || + event.type === 'user.deleted' ) { - if (event.user?.id === this.userID) { - this.user = this.user && { ...this.user, ...event.user }; - // Updating only available properties in _user object. - Object.keys(event.user).forEach(function (key) { - if (client._user && key in client._user) { - // @ts-expect-error - client._user[key] = event.user[key]; - } - }); - } - client.state.updateUser(event.user); - client._updateUserReferences(event.user); + this._handleUserEvent(event); } + if (event.type === 'health.check' && event.me) { client.user = event.me; client.state.updateUser(event.me); @@ -1334,27 +1448,6 @@ export class StreamChat< this.setUserPromise = Promise.resolve(); }; - /* - _updateUserReferences updates the members and watchers of the currently active channels - that contain this user - */ - _updateUserReferences(user: UserResponse) { - const refMap = this.state.userChannelReferences[user.id] || {}; - const refs = Object.keys(refMap); - for (const channelID of refs) { - const channel = this.activeChannels[channelID]; - // search the members and watchers and update as needed... - if (channel?.state) { - if (channel.state.members[user.id]) { - channel.state.members[user.id].user = user; - } - if (channel.state.watchers[user.id]) { - channel.state.watchers[user.id] = user; - } - } - } - } - /** * @private */ diff --git a/src/types.ts b/src/types.ts index 008b6c22d..f191ff987 100644 --- a/src/types.ts +++ b/src/types.ts @@ -951,6 +951,8 @@ export type Event< clear_history?: boolean; connection_id?: string; created_at?: string; + hard_delete?: boolean; + mark_messages_deleted?: boolean; me?: OwnUserResponse; member?: ChannelMemberResponse; message?: MessageResponse< diff --git a/test/unit/channel.js b/test/unit/channel.js index 1aa822125..d94a65a83 100644 --- a/test/unit/channel.js +++ b/test/unit/channel.js @@ -10,6 +10,7 @@ import { getOrCreateChannelApi } from './test-utils/getOrCreateChannelApi'; import { StreamChat } from '../../src/client'; import { Channel } from '../../src/channel'; +import { ClientState } from '../../src'; const expect = chai.expect; @@ -23,6 +24,7 @@ describe('Channel count unread', function () { user, userID: 'user', userMuteStatus: (targetId) => targetId.startsWith('mute'), + state: new ClientState(), }); const ignoredMessages = [ @@ -114,6 +116,7 @@ describe('Channel _handleChannelEvent', function () { user, userID: user.id, userMuteStatus: (targetId) => targetId.startsWith('mute'), + state: new ClientState(), }, 'messaging', 'id', diff --git a/test/unit/channel_state.js b/test/unit/channel_state.js index cea4e9dc7..bdb0879f3 100644 --- a/test/unit/channel_state.js +++ b/test/unit/channel_state.js @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { generateChannel } from './test-utils/generateChannel'; import { generateMsg } from './test-utils/generateMessage'; +import { generateUser } from './test-utils/generateUser'; import { getClientWithUser } from './test-utils/getClient'; import { getOrCreateChannelApi } from './test-utils/getOrCreateChannelApi'; @@ -385,3 +386,107 @@ describe('ChannelState clean', () => { expect(channel.state.typing['other']).to.be.undefined; }); }); + +describe('deleteUserMessages', () => { + it('should remove content of messages from given user, when hardDelete is true', () => { + const state = new ChannelState(); + const user1 = generateUser(); + const user2 = generateUser(); + + const m1u1 = generateMsg({ user: user1 }); + const m2u1 = generateMsg({ user: user1 }); + const m1u2 = generateMsg({ user: user2 }); + const m2u2 = generateMsg({ user: user2 }); + + state.addMessagesSorted([m1u1, m2u1, m1u2, m2u2]); + + expect(state.messages).to.have.length(4); + + state.deleteUserMessages(user1, true); + + expect(state.messages).to.have.length(4); + + expect(state.messages[0].type).to.be.equal('deleted'); + expect(state.messages[0].text).to.be.equal(undefined); + expect(state.messages[0].html).to.be.equal(undefined); + + expect(state.messages[1].type).to.be.equal('deleted'); + expect(state.messages[1].text).to.be.equal(undefined); + expect(state.messages[1].html).to.be.equal(undefined); + + expect(state.messages[2].type).to.be.equal('regular'); + expect(state.messages[2].text).to.be.equal(m1u2.text); + expect(state.messages[2].html).to.be.equal(m1u2.html); + + expect(state.messages[3].type).to.be.equal('regular'); + expect(state.messages[3].text).to.be.equal(m2u2.text); + expect(state.messages[3].html).to.be.equal(m2u2.html); + }); + it('should mark messages from given user as deleted, when hardDelete is false', () => { + const state = new ChannelState(); + + const user1 = generateUser(); + const user2 = generateUser(); + + const m1u1 = generateMsg({ user: user1 }); + const m2u1 = generateMsg({ user: user1 }); + const m1u2 = generateMsg({ user: user2 }); + const m2u2 = generateMsg({ user: user2 }); + + state.addMessagesSorted([m1u1, m2u1, m1u2, m2u2]); + expect(state.messages).to.have.length(4); + + state.deleteUserMessages(user1); + + expect(state.messages).to.have.length(4); + + expect(state.messages[0].type).to.be.equal('deleted'); + expect(state.messages[0].text).to.be.equal(m1u1.text); + expect(state.messages[0].html).to.be.equal(m1u1.html); + + expect(state.messages[1].type).to.be.equal('deleted'); + expect(state.messages[1].text).to.be.equal(m2u1.text); + expect(state.messages[1].html).to.be.equal(m2u1.html); + + expect(state.messages[2].type).to.be.equal('regular'); + expect(state.messages[2].text).to.be.equal(m1u2.text); + expect(state.messages[2].html).to.be.equal(m1u2.html); + + expect(state.messages[3].type).to.be.equal('regular'); + expect(state.messages[3].text).to.be.equal(m2u2.text); + expect(state.messages[3].html).to.be.equal(m2u2.html); + }); +}); + +describe('updateUserMessages', () => { + it('should update user property of messages from given user', () => { + const state = new ChannelState(); + let user1 = generateUser(); + const user2 = generateUser(); + + const m1u1 = generateMsg({ user: user1 }); + const m2u1 = generateMsg({ user: user1 }); + const m1u2 = generateMsg({ user: user2 }); + const m2u2 = generateMsg({ user: user2 }); + + state.addMessagesSorted([m1u1, m2u1, m1u2, m2u2]); + + expect(state.messages).to.have.length(4); + + const user1NewName = uuidv4(); + user1 = { + ...user1, + name: user1NewName, + }; + + state.updateUserMessages(user1, true); + + expect(state.messages).to.have.length(4); + + expect(state.messages[0].user.name).to.be.equal(user1NewName); + expect(state.messages[1].user.name).to.be.equal(user1NewName); + + expect(state.messages[2].user.name).to.be.equal(user2.name); + expect(state.messages[3].user.name).to.be.equal(user2.name); + }); +});