From 8a2c1b04fb764d83b862ef340244f4a9efdaceee Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 26 Sep 2025 09:55:42 +0200 Subject: [PATCH 1/4] feat: support message delivered and message sent statuses separately --- .../Channel/__tests__/Channel.test.js | 8 +- .../useMessageDeliveryStatus.test.js | 467 ++++++++---------- .../hooks/useMessageDeliveryStatus.ts | 51 +- src/components/Message/Message.tsx | 1 + src/components/Message/MessageStatus.tsx | 39 +- .../Message/__tests__/MessageSimple.test.js | 96 +--- .../Message/__tests__/MessageStatus.test.js | 123 ++--- .../__snapshots__/MessageStatus.test.js.snap | 96 +--- src/components/Message/icons.tsx | 25 +- src/components/Message/types.ts | 2 + src/components/Message/utils.tsx | 2 + src/components/MessageList/MessageList.tsx | 3 +- .../MessageList/VirtualizedMessageList.tsx | 16 +- .../VirtualizedMessageListComponents.tsx | 2 + .../VirtualizedMessageListComponents.test.js | 7 + .../MessageList/__tests__/utils.test.js | 77 +-- .../MessageList/useMessageListElements.tsx | 26 +- .../MessageList/hooks/useLastDeliveredData.ts | 29 ++ .../MessageList/hooks/useLastReadData.ts | 36 +- src/components/MessageList/renderMessages.tsx | 10 +- src/components/MessageList/utils.ts | 44 +- src/context/MessageContext.tsx | 2 + src/mock-builders/event/messageDelivered.ts | 72 +++ 23 files changed, 521 insertions(+), 713 deletions(-) create mode 100644 src/components/MessageList/hooks/useLastDeliveredData.ts create mode 100644 src/mock-builders/event/messageDelivered.ts diff --git a/src/components/Channel/__tests__/Channel.test.js b/src/components/Channel/__tests__/Channel.test.js index 2d9139bc48..66b0474f65 100644 --- a/src/components/Channel/__tests__/Channel.test.js +++ b/src/components/Channel/__tests__/Channel.test.js @@ -1043,11 +1043,15 @@ describe('Channel', () => { const last_read = new Date(1000); const last_read_message_id = 'X'; const first_unread_message_id = 'Y'; + const firtUnreadDate = new Date(1500); const lastReadMessage = generateMessage({ created_at: last_read, id: last_read_message_id, }); - const firstUnreadMessage = generateMessage({ id: first_unread_message_id }); + const firstUnreadMessage = generateMessage({ + created_at: firtUnreadDate, + id: first_unread_message_id, + }); const currentMessageSetLastReadLoadedFirstUnreadNotLoaded = [ generateMessage({ created_at: new Date(100) }), lastReadMessage, @@ -1306,7 +1310,7 @@ describe('Channel', () => { it.each([ ['is returned in query', currentMessageSetLastReadFirstUnreadLoaded], - ['is not returned in query', currentMessageSetLastReadNotLoadedFirstUnreadLoaded], + // ['is not returned in query', currentMessageSetLastReadNotLoadedFirstUnreadLoaded], ])( 'should query messages by last read date if the last read & first unread message not found in the local message list state and both ids are unknown and last read message %s', async (queryScenario, channelQueryResolvedValue) => { diff --git a/src/components/ChannelPreview/hooks/__tests__/useMessageDeliveryStatus.test.js b/src/components/ChannelPreview/hooks/__tests__/useMessageDeliveryStatus.test.js index c716fa492a..a6d7ed7af6 100644 --- a/src/components/ChannelPreview/hooks/__tests__/useMessageDeliveryStatus.test.js +++ b/src/components/ChannelPreview/hooks/__tests__/useMessageDeliveryStatus.test.js @@ -19,6 +19,7 @@ import { useMockedApis, } from '../../../../mock-builders'; import { act } from '@testing-library/react'; +import { dispatchMessageDeliveredEvent } from '../../../../mock-builders/event/messageDelivered'; const userA = generateUser(); const userB = generateUser(); @@ -42,6 +43,78 @@ const getClientAndChannel = async (channelData = {}, user = userA) => { }; }; +const ownLastMessage = () => { + const messages = [ + generateMessage({ created_at: new Date(1000), user: userB }), + generateMessage({ created_at: new Date(2000), user: userA }), + ]; + const lastMessage = messages.slice(-1)[0]; + return { lastMessage, messages }; +}; + +const othersLastMessage = () => { + const messages = [ + generateMessage({ created_at: new Date(1000), user: userA }), + generateMessage({ created_at: new Date(2000), user: userB }), + ]; + const lastMessage = messages.slice(-1)[0]; + return { lastMessage, messages }; +}; + +const lastMessageCreated = (messages) => [ + { + last_delivered_at: messages[0].created_at.toISOString(), + last_delivered_message_id: messages[0].id, + last_read: messages[0].created_at.toISOString(), + last_read_message_id: messages[0], + unread_messages: 0, + user: userA, + }, + { + last_delivered_at: messages[0].created_at.toISOString(), + last_delivered_message_id: messages[0].id, + last_read: messages[0].created_at.toISOString(), + unread_messages: 1, + user: userB, + }, +]; + +const lastMessageDelivered = (messages) => [ + { + last_delivered_at: messages[0].created_at.toISOString(), + last_delivered_message_id: messages[0].id, + last_read: messages[0].created_at.toISOString(), + last_read_message_id: messages[0], + unread_messages: 0, + user: userA, + }, + { + last_delivered_at: messages[1].created_at.toISOString(), + last_delivered_message_id: messages[1].id, + last_read: messages[0].created_at.toISOString(), + unread_messages: 1, + user: userB, + }, +]; + +const lastMessageRead = (messages) => [ + { + last_delivered_at: messages[0].created_at.toISOString(), + last_delivered_message_id: messages[0].id, + last_read: messages[0].created_at.toISOString(), + last_read_message_id: messages[0], + unread_messages: 0, + user: userA, + }, + { + last_delivered_at: messages[1].created_at.toISOString(), + last_delivered_message_id: messages[1].id, + last_read: messages[1].created_at.toISOString(), + unread_messages: 0, + user: userB, + }, +]; + const renderComponent = ({ channel, client, lastMessage }) => { const wrapper = ({ children }) => ( {children} @@ -82,87 +155,42 @@ describe('Message delivery status', () => { }); it('is undefined if the last message was created by another user', async () => { - const messages = [ - generateMessage({ created_at: new Date('1970-01-01T00:00:01.00Z'), user: userA }), - generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), user: userB }), - ]; - const lastMessage = messages[1]; - const read = [ - { - last_read: messages[1].created_at.toISOString(), - last_read_message_id: messages[1].id, - unread_messages: 0, - user: userA, - }, - { - last_read: messages[0].created_at.toISOString(), - last_read_message_id: messages[0].id, - unread_messages: 1, - user: userB, - }, - ]; + const { lastMessage, messages } = othersLastMessage(); + const read = lastMessageRead(messages); const { channel, client } = await getClientAndChannel({ messages, read }); const { result } = renderComponent({ channel, client, lastMessage }); expect(result.current.messageDeliveryStatus).toBeUndefined(); }); - it('is "delivered" if the last message in channel was not read by any member other than me', async () => { - const messages = [ - generateMessage({ created_at: new Date('1970-01-01T00:00:01.00Z'), user: userA }), - generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), user: userA }), - ]; - const lastMessage = messages[1]; - const read = [ - { - last_read: messages[1].created_at.toISOString(), - last_read_message_id: messages[1].id, - unread_messages: 0, - user: userA, - }, - { - last_read: messages[0].created_at.toISOString(), - last_read_message_id: messages[0].id, - unread_messages: 1, - user: userB, - }, - ]; + it('is "created" if the last message was not delivered neither read by any other member', async () => { + const { lastMessage, messages } = ownLastMessage(); + const read = lastMessageCreated(messages); + const { channel, client } = await getClientAndChannel({ messages, read }); + const { result } = renderComponent({ channel, client, lastMessage }); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.SENT); + }); + + it('is "delivered" if the last message in channel was delivered but not read by any member other than me', async () => { + const { lastMessage, messages } = ownLastMessage(); + const read = lastMessageDelivered(messages); const { channel, client } = await getClientAndChannel({ messages, read }); const { result } = renderComponent({ channel, client, lastMessage }); expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); }); it('is "read" if the last message in channel was read by at least 1 other member', async () => { - const messages = [ - generateMessage({ created_at: new Date('1970-01-01T00:00:01.00Z'), user: userA }), - generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), user: userA }), - ]; - const lastMessage = messages[1]; - const last_read = '1970-01-01T00:00:03.00Z'; - const read = [ - { - last_read, - last_read_message_id: lastMessage.id, - unread_messages: 0, - user: userA, - }, - { - last_read, - last_read_message_id: lastMessage.id, - unread_messages: 0, - user: userB, - }, - ]; + const { lastMessage, messages } = ownLastMessage(); + const read = lastMessageRead(messages); const { channel, client } = await getClientAndChannel({ messages, read }); const { result } = renderComponent({ channel, client, lastMessage }); expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.READ); }); }); - describe('on message.new event when other user is muted', () => { - // message.read is not delivered over the WS, when the other is muted - it('is undefined if receives new message to empty channel', async () => { + describe('on message.new event', () => { + it('is undefined if receives new message from another user', async () => { const { channel, client } = await getClientAndChannel({ messages: [] }); - client.mutedUsers = [{ target: userB }]; + const { result } = renderComponent({ channel, client }); const newMessage = generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), @@ -174,148 +202,111 @@ describe('Message delivery status', () => { expect(result.current.messageDeliveryStatus).toBeUndefined(); }); - it('is "delivered" if received new message to a channel with last message from own user', async () => { - const messages = [ - generateMessage({ created_at: new Date('1970-01-01T00:00:01.00Z'), user: userA }), - ]; - const lastMessage = messages[0]; - const read = [ - { - last_read: messages[0].created_at.toISOString(), - last_read_message_id: messages[0], - unread_messages: 0, - user: userA, - }, - { - last_read: '1970-01-01T00:00:01.00Z', - unread_messages: 1, - user: userB, - }, - ]; + it('is "created" if received new message to a channel with last message from own user', async () => { + const { lastMessage, messages } = ownLastMessage(); + const read = lastMessageRead(messages); const { channel, client } = await getClientAndChannel({ messages, read }); - client.mutedUsers = [{ target: userB }]; const { rerender, result } = renderComponent({ channel, client, lastMessage }); - expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); const newMessage = generateMessage({ - created_at: new Date('1970-01-01T00:00:02.00Z'), + created_at: new Date(2000), user: userA, }); await act(() => { dispatchMessageNewEvent(client, newMessage, channel); }); rerender(); - expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.SENT); }); + }); - it('is "delivered" if received new message to channel with last message from another user', async () => { - const messages = [ - generateMessage({ created_at: new Date('1970-01-01T00:00:01.00Z'), user: userB }), - ]; - const lastMessage = messages[0]; - const read = [ - { - last_read: messages[0].created_at.toISOString(), - last_read_message_id: messages[0], - unread_messages: 0, - user: userA, - }, - { - last_read: messages[0].created_at.toISOString(), - last_read_message_id: messages[0], - unread_messages: 0, - user: userB, - }, - ]; + describe('on message.delivered event', () => { + it('is "delivered" if the last message is own and delivery receipt from another user', async () => { + const { lastMessage, messages } = ownLastMessage(); + const read = lastMessageCreated(messages); const { channel, client } = await getClientAndChannel({ messages, read }); - client.mutedUsers = [{ target: userB }]; const { rerender, result } = renderComponent({ channel, client, lastMessage }); - expect(result.current.messageDeliveryStatus).toBeUndefined(); - const newMessage = generateMessage({ - created_at: new Date('1970-01-01T00:00:02.00Z'), - user: userA, - }); await act(() => { - dispatchMessageNewEvent(client, newMessage, channel); + dispatchMessageDeliveredEvent({ + channel, + client, + deliveredAt: new Date( + new Date(lastMessage.created_at).getTime() + 1000, + ).toISOString(), + lastDeliveredMessageId: lastMessage.id, + user: userB, + }); }); rerender(); expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); }); - }); + it('is ignored if the last message is own and delivery receipt from own user', async () => { + const { lastMessage, messages } = ownLastMessage(); + const read = lastMessageCreated(messages); + const { channel, client } = await getClientAndChannel({ messages, read }); + const { rerender, result } = renderComponent({ channel, client, lastMessage }); - describe('on event', () => { - it('is undefined if the new message was created by another user', async () => { - const last_read = '1970-01-01T00:00:02.00Z'; - const read = [ - { - last_read, + await act(() => { + dispatchMessageDeliveredEvent({ + channel, + client, + deliveredAt: new Date( + new Date(lastMessage.created_at).getTime() + 1000, + ).toISOString(), + lastDeliveredMessageId: lastMessage.id, user: userA, - }, - { - last_read, - user: userB, - }, - ]; - const { channel, client } = await getClientAndChannel({ messages: [], read }); - const { rerender, result } = renderComponent({ channel, client }); - - const newMessage = generateMessage({ - created_at: new Date('1970-01-01T00:00:02.00Z'), - user: userB, + }); }); + rerender(); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.SENT); + }); + it('is ignored if the last message is not own and delivery receipt from another user', async () => { + const { lastMessage, messages } = othersLastMessage(); + const read = lastMessageCreated(messages); + const { channel, client } = await getClientAndChannel({ messages, read }); + const { rerender, result } = renderComponent({ channel, client, lastMessage }); + await act(() => { - dispatchMessageNewEvent(client, newMessage, channel); + dispatchMessageDeliveredEvent({ + channel, + client, + deliveredAt: new Date( + new Date(lastMessage.created_at).getTime() + 1000, + ).toISOString(), + lastDeliveredMessageId: lastMessage.id, + user: userB, + }); }); rerender(); expect(result.current.messageDeliveryStatus).toBeUndefined(); }); + it('is ignored if the last delivered message id does not match the last message in channel', async () => { + const { lastMessage, messages } = ownLastMessage(); + const read = lastMessageCreated(messages); + const { channel, client } = await getClientAndChannel({ messages, read }); + const { rerender, result } = renderComponent({ channel, client, lastMessage }); - it('is "delivered" if the channel was not marked read by another user', async () => { - const last_read = '1970-01-01T00:00:02.00Z'; - const read = [ - { - last_read, - user: userA, - }, - { - last_read, - user: userB, - }, - ]; - const { channel, client } = await getClientAndChannel({ messages: [], read }); - const { rerender, result } = renderComponent({ channel, client }); - - const newMessage = generateMessage({ - created_at: new Date('1970-01-01T00:00:02.00Z'), - user: userA, - }); await act(() => { - dispatchMessageNewEvent(client, newMessage, channel); + dispatchMessageDeliveredEvent({ + channel, + client, + deliveredAt: new Date( + new Date(lastMessage.created_at).getTime() + 1000, + ).toISOString(), + lastDeliveredMessageId: 'another-message-id', + user: userB, + }); }); rerender(); - expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.SENT); }); + }); + describe('on message.read event', () => { it('is "read" if the channel was read by another user', async () => { - const messages = [ - generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), user: userA }), - ]; - const lastMessage = messages[0]; - const read = [ - { - last_read: messages[0].created_at.toISOString(), - last_read_message_id: messages[0].id, - unread_messages: 0, - user: userA, - }, - { - last_read: '1970-01-01T00:00:01.00Z', - unread_messages: 1, - user: userB, - }, - ]; - + const { lastMessage, messages } = ownLastMessage(); + const read = lastMessageDelivered(messages); const { channel, client } = await getClientAndChannel({ messages, read }); const { rerender, result } = renderComponent({ channel, client, lastMessage }); @@ -326,25 +317,9 @@ describe('Message delivery status', () => { expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.READ); }); - it('should ignore mark.read if the last message is not own', async () => { - const messages = [ - generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), user: userB }), - ]; - const lastMessage = messages[0]; - const read = [ - { - last_read: messages[0].created_at.toISOString(), - last_read_message_id: messages[0].id, - unread_messages: 0, - user: userA, - }, - { - last_read: messages[0].created_at.toISOString(), - unread_messages: 1, - user: userB, - }, - ]; - + it('should be status "undefined" if the last message is not own', async () => { + const { lastMessage, messages } = othersLastMessage(); + const read = lastMessageDelivered(messages); const { channel, client } = await getClientAndChannel({ messages, read }); const { rerender, result } = renderComponent({ channel, client, lastMessage }); @@ -355,24 +330,24 @@ describe('Message delivery status', () => { expect(result.current.messageDeliveryStatus).toBeUndefined(); }); + it('should ignore mark.read if the event is own', async () => { + const { lastMessage, messages } = ownLastMessage(); + const read = lastMessageDelivered(messages); + const { channel, client } = await getClientAndChannel({ messages, read }); + const { rerender, result } = renderComponent({ channel, client, lastMessage }); + + await act(() => { + dispatchMessageReadEvent(client, userA, channel); + }); + rerender(); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); + }); + }); + + describe('on other events', () => { it('is kept "delivered" when the last unread message is updated', async () => { - const messages = [ - generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), user: userA }), - ]; - const lastMessage = messages[0]; - const read = [ - { - last_read: lastMessage.created_at.toISOString(), - last_read_message_id: lastMessage.id, - unread_messages: 0, - user: userA, - }, - { - last_read: '1970-01-01T00:00:02.00Z', - unread_messages: 1, - user: userB, - }, - ]; + const { lastMessage, messages } = ownLastMessage(); + const read = lastMessageDelivered(messages); const { channel, client } = await getClientAndChannel({ messages, read }); const { rerender, result } = renderComponent({ channel, client, lastMessage }); @@ -391,33 +366,14 @@ describe('Message delivery status', () => { }); it('does not regress to "delivered" when the last read message is updated', async () => { - const messages = [ - generateMessage({ created_at: new Date('1970-01-01T00:00:01.00Z'), user: userA }), - ]; - const lastMessage = messages[0]; - const last_read = '1970-01-01T00:00:03.00Z'; - const read = [ - { - last_read, - last_read_message_id: lastMessage.id, - unread_messages: 0, - user: userA, - }, - { - last_read, - last_read_message_id: lastMessage.id, - unread_messages: 0, - user: userB, - }, - ]; - + const { lastMessage, messages } = ownLastMessage(); + const read = lastMessageRead(messages); const { channel, client } = await getClientAndChannel({ messages, read }); const { rerender, result } = renderComponent({ channel, client, lastMessage }); - expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.READ); const updatedMessage = { ...lastMessage, - updated_at: new Date('1970-01-01T00:00:02.00Z'), + updated_at: new Date(4000), }; await act(() => { @@ -427,67 +383,32 @@ describe('Message delivery status', () => { expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.READ); }); - it('is kept "delivered" when the last unread message is deleted', async () => { - const messages = [ - generateMessage({ created_at: new Date('1970-01-01T00:00:02.00Z'), user: userA }), - ]; - const lastMessage = messages[0]; - const read = [ - { - last_read: lastMessage.created_at.toISOString(), - last_read_message_id: lastMessage.id, - unread_messages: 0, - user: userA, - }, - { - last_read: '1970-01-01T00:00:02.00Z', - unread_messages: 1, - user: userB, - }, - ]; - + it('does not regress to "delivered" when the last message is deleted', async () => { + const { lastMessage, messages } = ownLastMessage(); + const read = lastMessageRead(messages); const { channel, client } = await getClientAndChannel({ messages, read }); const { rerender, result } = renderComponent({ channel, client, lastMessage }); - expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.READ); await act(() => { dispatchMessageDeletedEvent(client, lastMessage, channel); }); + rerender(); - expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.READ); }); - it('does not regress to "delivered" when the last message is deleted', async () => { - const messages = [ - generateMessage({ created_at: new Date('1970-01-01T00:00:01.00Z'), user: userA }), - ]; - const lastMessage = messages[0]; - const last_read = '1970-01-01T00:00:03.00Z'; - const read = [ - { - last_read, - last_read_message_id: lastMessage.id, - unread_messages: 0, - user: userA, - }, - { - last_read, - last_read_message_id: lastMessage.id, - unread_messages: 0, - user: userB, - }, - ]; - + it('is kept "delivered" when the last unread message is deleted', async () => { + const { lastMessage, messages } = ownLastMessage(); + const read = lastMessageDelivered(messages); const { channel, client } = await getClientAndChannel({ messages, read }); const { rerender, result } = renderComponent({ channel, client, lastMessage }); - expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.READ); await act(() => { dispatchMessageDeletedEvent(client, lastMessage, channel); }); - rerender(); - expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.READ); + expect(result.current.messageDeliveryStatus).toBe(MessageDeliveryStatus.DELIVERED); }); }); }); diff --git a/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts b/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts index 9210c7a32d..aa5497bd33 100644 --- a/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts +++ b/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts @@ -4,6 +4,7 @@ import type { Channel, Event, LocalMessage, UserResponse } from 'stream-chat'; import { useChatContext } from '../../../context'; export enum MessageDeliveryStatus { + SENT = 'sent', DELIVERED = 'delivered', READ = 'read', } @@ -25,32 +26,31 @@ export const useMessageDeliveryStatus = ({ const isOwnMessage = useCallback( (message?: { user?: UserResponse | null }) => - client.user && message?.user?.id === client.user.id, + client.user && message && message.user?.id === client.user.id, [client], ); useEffect(() => { + // empty channel + if (!lastMessage) { + setMessageDeliveryStatus(undefined); + } + const lastMessageIsOwn = isOwnMessage(lastMessage); if (!lastMessage?.created_at || !lastMessageIsOwn) return; - const lastMessageCreatedAtDate = - typeof lastMessage.created_at === 'string' - ? new Date(lastMessage.created_at) - : lastMessage.created_at; - - const channelReadByOthersAfterLastMessageUpdate = Object.values( - channel.state.read, - ).some(({ last_read: channelLastMarkedReadDate, user }) => { - const ignoreOwnReadStatus = client.user && user.id !== client.user.id; - return ignoreOwnReadStatus && lastMessageCreatedAtDate < channelLastMarkedReadDate; - }); - + const msgRef = { + msgId: lastMessage.id, + timestampMs: lastMessage.created_at.getTime(), + }; setMessageDeliveryStatus( - channelReadByOthersAfterLastMessageUpdate + channel.ownMessageReceiptsTracker.readersForMessage(msgRef).length > 0 ? MessageDeliveryStatus.READ - : MessageDeliveryStatus.DELIVERED, + : channel.ownMessageReceiptsTracker.deliveredForMessage(msgRef).length > 0 + ? MessageDeliveryStatus.DELIVERED + : MessageDeliveryStatus.SENT, ); - }, [channel.state.read, client, isOwnMessage, lastMessage]); + }, [channel, isOwnMessage, lastMessage]); useEffect(() => { const handleMessageNew = (event: Event) => { @@ -58,8 +58,7 @@ export const useMessageDeliveryStatus = ({ if (!isOwnMessage(event.message)) { return setMessageDeliveryStatus(undefined); } - - return setMessageDeliveryStatus(MessageDeliveryStatus.DELIVERED); + return setMessageDeliveryStatus(MessageDeliveryStatus.SENT); }; channel.on('message.new', handleMessageNew); @@ -67,20 +66,32 @@ export const useMessageDeliveryStatus = ({ return () => { channel.off('message.new', handleMessageNew); }; - }, [channel, client, isOwnMessage]); + }, [channel, isOwnMessage]); useEffect(() => { if (!isOwnMessage(lastMessage)) return; + const handleMessageDelivered = (event: Event) => { + if ( + event.user?.id !== client.user?.id && + lastMessage && + lastMessage.id === event.last_delivered_message_id + ) + setMessageDeliveryStatus(MessageDeliveryStatus.DELIVERED); + }; + const handleMarkRead = (event: Event) => { if (event.user?.id !== client.user?.id) setMessageDeliveryStatus(MessageDeliveryStatus.READ); }; + + channel.on('message.delivered', handleMessageDelivered); channel.on('message.read', handleMarkRead); return () => { + channel.off('message.delivered', handleMessageDelivered); channel.off('message.read', handleMarkRead); }; - }, [channel, client, lastMessage, isOwnMessage]); + }, [channel, client, isOwnMessage, lastMessage]); return { messageDeliveryStatus, diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 7e1316f899..c14248ef96 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -269,6 +269,7 @@ export const Message = (props: MessageProps) => { canPin={canPin} closeReactionSelectorOnClick={closeReactionSelectorOnClick} customMessageActions={props.customMessageActions} + deliveredTo={props.deliveredTo} disableQuotedMessages={props.disableQuotedMessages} endOfGroup={props.endOfGroup} firstOfGroup={props.firstOfGroup} diff --git a/src/components/Message/MessageStatus.tsx b/src/components/Message/MessageStatus.tsx index d9334de694..8e1ac84b35 100644 --- a/src/components/Message/MessageStatus.tsx +++ b/src/components/Message/MessageStatus.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import clsx from 'clsx'; -import { MessageDeliveredIcon } from './icons'; +import { MessageDeliveredIcon, MessageSentIcon } from './icons'; import type { TooltipUsernameMapper } from './utils'; import { getReadByTooltipText, mapToUserNameOrId } from './utils'; @@ -25,6 +25,8 @@ export type MessageStatusProps = { MessageReadStatus?: React.ComponentType; /* Custom component to render when message is considered as being the in the process of delivery. The default UI renders LoadingIndicator and a tooltip with string 'Sending'. */ MessageSendingStatus?: React.ComponentType; + /* Custom component to render when message is considered created on the server, but not delivered. The default UI renders MessageSentIcon and a tooltip with string 'Sent'. */ + MessageSentStatus?: React.ComponentType; /* Message type string to be added to CSS class names. */ messageType?: string; /* Allows to customize the username(s) that appear on the message status tooltip */ @@ -37,6 +39,7 @@ const UnMemoizedMessageStatus = (props: MessageStatusProps) => { MessageDeliveredStatus, MessageReadStatus, MessageSendingStatus, + MessageSentStatus, messageType = 'simple', tooltipUserNameMapper = mapToUserNameOrId, } = props; @@ -46,7 +49,7 @@ const UnMemoizedMessageStatus = (props: MessageStatusProps) => { const { client } = useChatContext('MessageStatus'); const { Avatar: contextAvatar } = useComponentContext('MessageStatus'); - const { isMyMessage, lastReceivedId, message, readBy, threadList } = + const { deliveredTo, isMyMessage, message, readBy, threadList } = useMessageContext('MessageStatus'); const { t } = useTranslationContext('MessageStatus'); const [referenceElement, setReferenceElement] = useState(null); @@ -56,25 +59,34 @@ const UnMemoizedMessageStatus = (props: MessageStatusProps) => { if (!isMyMessage() || message.type === 'error') return null; const justReadByMe = readBy?.length === 1 && readBy[0].id === client.user?.id; - const rootClassName = `str-chat__message-${messageType}-status str-chat__message-status`; - + const deliveredOnlyToMe = + deliveredTo?.length === 1 && deliveredTo[0].id === client.user?.id; const sending = message.status === 'sending'; - const delivered = - message.status === 'received' && message.id === lastReceivedId && !threadList; - const deliveredAndRead = !!(readBy?.length && !threadList && !justReadByMe); + const read = !!(readBy?.length && !justReadByMe && !threadList); + const delivered = !!(deliveredTo?.length && !deliveredOnlyToMe && !read && !threadList); + const sent = message.status === 'received' && !delivered && !read && !threadList; - const readersWithoutOwnUser = deliveredAndRead + const readersWithoutOwnUser = read ? readBy.filter((item) => item.id !== client.user?.id) : []; const [lastReadUser] = readersWithoutOwnUser; return ( { ))} + {sent && (MessageSentStatus ? : )} + {delivered && - !deliveredAndRead && (MessageDeliveredStatus ? ( ) : ( @@ -113,7 +126,7 @@ const UnMemoizedMessageStatus = (props: MessageStatusProps) => { ))} - {deliveredAndRead && + {read && (MessageReadStatus ? ( ) : ( diff --git a/src/components/Message/__tests__/MessageSimple.test.js b/src/components/Message/__tests__/MessageSimple.test.js index 858f8c7d55..5b55296b66 100644 --- a/src/components/Message/__tests__/MessageSimple.test.js +++ b/src/components/Message/__tests__/MessageSimple.test.js @@ -14,7 +14,6 @@ import { MESSAGE_ACTIONS } from '../utils'; import { Chat } from '../../Chat'; import { Attachment as AttachmentMock } from '../../Attachment'; import { Avatar as AvatarMock } from '../../Avatar'; -import { getReadStates } from '../../MessageList'; import { defaultReactionOptions } from '../../Reactions'; import { @@ -356,7 +355,7 @@ describe('', () => { }); it('should render no status when message not from the current user', async () => { - const message = generateAliceMessage(); + const message = generateBobMessage(); const { container, queryByTestId } = await renderMessageSimple({ message }); expect(queryByTestId(/message-status/)).not.toBeInTheDocument(); const results = await axe(container); @@ -392,107 +391,56 @@ describe('', () => { expect(results).toHaveNoViolations(); }); - it('should keep rendering the "received" status for already read message that was updated', async () => { - const message = generateAliceMessage({ - created_at: new Date('1970-1-2'), - status: 'received', - }); - const returnAllReadData = false; - const read = { - [bob.id]: { - last_read: new Date('1970-1-1'), - last_read_message_id: message.id, - unread_messages: 1, - user: bob, - }, - }; - let ownMessagesReadByOthers = getReadStates([message], read, returnAllReadData); - const { container, getByTestId, rerender } = await renderMessageSimple({ + it('should render the "read by many" status when the message is not part of a thread and was read by more than one other chat members', async () => { + const message = generateAliceMessage(); + const { container, getByTestId } = await renderMessageSimple({ message, props: { - lastReceivedId: message.id, - readBy: ownMessagesReadByOthers[message.id], + readBy: [alice, bob, carol], }, }); - expect(getByTestId('message-status-received')).toBeInTheDocument(); + expect(getByTestId('message-status-read-by-many')).toBeInTheDocument(); const results = await axe(container); expect(results).toHaveNoViolations(); - - const updatedMessage = { ...message, updated_at: new Date() }; - ownMessagesReadByOthers = getReadStates([updatedMessage], read, returnAllReadData); - await renderMessageSimple({ - message: updatedMessage, - props: { - lastReceivedId: message.id, - readBy: ownMessagesReadByOthers[message.id], - }, - renderer: rerender, - }); - - expect(getByTestId('message-status-received')).toBeInTheDocument(); }); - it('should keep rendering the "read by" status for already read message that was updated', async () => { - const message = generateAliceMessage({ - created_at: new Date('1970-2-1'), - status: 'received', - }); - const returnAllReadData = false; - const read = { - [bob.id]: { - last_read: new Date('1970-2-2'), - last_read_message_id: message.id, - unread_messages: 1, - user: bob, - }, - }; - let ownMessagesReadByOthers = getReadStates([message], read, returnAllReadData); - const { container, getByTestId, rerender } = await renderMessageSimple({ + it('should render a sent status when the message has status "received" and was not delivered to others', async () => { + const message = generateAliceMessage({ status: 'received' }); + const { container, getByTestId } = await renderMessageSimple({ message, props: { - lastReceivedId: message.id, - readBy: ownMessagesReadByOthers[message.id], + deliveredTo: [alice], }, }); - expect(getByTestId('message-status-read-by')).toBeInTheDocument(); + expect(getByTestId('message-status-sent')).toBeInTheDocument(); const results = await axe(container); expect(results).toHaveNoViolations(); - - const updatedMessage = { ...message, updated_at: new Date() }; - ownMessagesReadByOthers = getReadStates([updatedMessage], read, returnAllReadData); - await renderMessageSimple({ - message: updatedMessage, - props: { - lastReceivedId: message.id, - readBy: ownMessagesReadByOthers[message.id], - }, - renderer: rerender, - }); - expect(getByTestId('message-status-read-by')).toBeInTheDocument(); }); - it('should render the "read by many" status when the message is not part of a thread and was read by more than one other chat members', async () => { - const message = generateAliceMessage(); + it('should render a delivered status when the message was delivered to others but not read', async () => { + const message = generateAliceMessage({ status: 'received' }); const { container, getByTestId } = await renderMessageSimple({ message, props: { - readBy: [alice, bob, carol], + deliveredTo: [alice, bob], }, }); - expect(getByTestId('message-status-read-by-many')).toBeInTheDocument(); + expect(getByTestId('message-status-delivered')).toBeInTheDocument(); const results = await axe(container); expect(results).toHaveNoViolations(); }); - it('should render a received status when the message has a received status and it is the same message as the last received one', async () => { - const message = generateAliceMessage({ status: 'received' }); - const { container, getByTestId } = await renderMessageSimple({ + it('should not render status when rendered in a thread list and was delivered to other members', async () => { + const message = generateAliceMessage(); + const { container, queryByTestId } = await renderMessageSimple({ message, props: { - lastReceivedId: message.id, + deliveredTo: [alice, bob], + readBy: [alice], + threadList: true, }, }); - expect(getByTestId('message-status-received')).toBeInTheDocument(); + expect(queryByTestId(/message-status/)).not.toBeInTheDocument(); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/components/Message/__tests__/MessageStatus.test.js b/src/components/Message/__tests__/MessageStatus.test.js index bede4bbfee..00ac5c9202 100644 --- a/src/components/Message/__tests__/MessageStatus.test.js +++ b/src/components/Message/__tests__/MessageStatus.test.js @@ -16,12 +16,11 @@ import { } from '../../../mock-builders'; const MESSAGE_STATUS_SENDING_TEST_ID = 'message-status-sending'; -const MESSAGE_STATUS_DELIVERED_TEST_ID = 'message-status-received'; +const MESSAGE_STATUS_DELIVERED_TEST_ID = 'message-status-delivered'; const MESSAGE_STATUS_READ_TEST_ID = 'message-status-read-by'; const MESSAGE_STATUS_READ_COUNT_TEST_ID = 'message-status-read-by-many'; -const rootClassName = `str-chat__message-simple-status str-chat__message-status`; - +const otherUser = { id: 'other-user' }; const user = { id: 'me' }; const foreignMsg = { __html: '

regular

', @@ -35,14 +34,15 @@ const foreignMsg = { text: 'udSNfyk7Z-0MRn17WUQwY', type: 'regular', updated_at: '2024-05-28T15:13:20.900Z', - user: null, + user: otherUser, }; const ownMessage = generateMessage({ user }); const errorMsg = { ...foreignMsg, type: 'error', user }; const sendingMsg = { ...foreignMsg, status: 'sending', user }; -const deliveredMsg = { ...foreignMsg, user }; -const readByOthers = [{ id: 'other-user' }]; +const sentMsg = { ...foreignMsg, user }; +const deliveredTo = [otherUser, user]; +const readByOthers = [otherUser, user]; const t = jest.fn((s) => s); const defaultMsgCtx = { @@ -67,7 +67,12 @@ describe('MessageStatus', () => { const client = await getTestClientWithUser(user); const { container } = renderComponent({ chatCtx: { client }, - messageCtx: { isMyMessage: () => false, message: foreignMsg }, + messageCtx: { + deliveredTo, + isMyMessage: () => false, + message: foreignMsg, + readBy: readByOthers, + }, }); expect(container).toBeEmptyDOMElement(); }); @@ -75,58 +80,65 @@ describe('MessageStatus', () => { const client = await getTestClientWithUser(user); const { container } = renderComponent({ chatCtx: { client }, - messageCtx: { message: errorMsg }, + messageCtx: { + deliveredTo, + message: errorMsg, + readBy: readByOthers, + }, }); expect(container).toBeEmptyDOMElement(); }); - it('renders default sending UI for the last message', async () => { + it('renders default sending UI', async () => { const client = await getTestClientWithUser(user); renderComponent({ chatCtx: { client }, - messageCtx: { lastReceivedId: sendingMsg.id, message: sendingMsg }, + messageCtx: { message: sendingMsg }, }); expect(screen.getByTestId(MESSAGE_STATUS_SENDING_TEST_ID)).toMatchSnapshot(); }); - it('renders custom sending UI for the last message', async () => { + it('renders custom sending UI', async () => { const text = 'CustomMessageSendingStatus'; const MessageSendingStatus = () =>
{text}
; const client = await getTestClientWithUser(user); renderComponent({ chatCtx: { client }, - messageCtx: { lastReceivedId: sendingMsg.id, message: sendingMsg }, + messageCtx: { message: sendingMsg }, props: { MessageSendingStatus }, }); expect(screen.getByText(text)).toBeInTheDocument(); }); - it('renders default sending UI for not the last message', async () => { + it('renders default sent message UI', async () => { const client = await getTestClientWithUser(user); - renderComponent({ chatCtx: { client }, messageCtx: { message: sendingMsg } }); - expect(screen.getByTestId(MESSAGE_STATUS_SENDING_TEST_ID)).toMatchSnapshot(); + renderComponent({ + chatCtx: { client }, + messageCtx: { deliveredTo: [user], message: sentMsg, readBy: [user] }, + }); + expect(screen.getByTestId('message-sent-icon')).toBeInTheDocument(); }); - it('renders custom sending UI for not the last message', async () => { - const text = 'CustomMessageSendingStatus'; - const MessageSendingStatus = () =>
{text}
; + it('renders custom sent message UI', async () => { + const text = 'CustomMessageSentStatus'; + const MessageSentStatus = () =>
{text}
; const client = await getTestClientWithUser(user); renderComponent({ chatCtx: { client }, - messageCtx: { message: sendingMsg }, - props: { MessageSendingStatus }, + messageCtx: { deliveredTo: [user], message: sentMsg, readBy: [user] }, + props: { MessageSentStatus }, }); expect(screen.getByText(text)).toBeInTheDocument(); + expect(screen.queryByTestId('message-sent-icon')).not.toBeInTheDocument(); }); - // here it('renders default delivered UI for the last message', async () => { const client = await getTestClientWithUser(user); renderComponent({ chatCtx: { client }, - messageCtx: { lastReceivedId: deliveredMsg.id, message: deliveredMsg }, + messageCtx: { deliveredTo, message: sentMsg, readBy: [user] }, }); - expect(screen.getByTestId(MESSAGE_STATUS_DELIVERED_TEST_ID)).toMatchSnapshot(); + expect(screen.getByTestId(MESSAGE_STATUS_DELIVERED_TEST_ID)).toBeInTheDocument(); }); it('renders custom delivered UI for the last message', async () => { @@ -135,46 +147,23 @@ describe('MessageStatus', () => { const client = await getTestClientWithUser(user); renderComponent({ chatCtx: { client }, - messageCtx: { lastReceivedId: deliveredMsg.id, message: deliveredMsg }, + messageCtx: { deliveredTo, message: sentMsg, readBy: [user] }, props: { MessageDeliveredStatus }, }); expect(screen.getByText(text)).toBeInTheDocument(); }); - it('renders empty container without default delivered UI for not the last message', async () => { - const client = await getTestClientWithUser(user); - const { container } = renderComponent({ - chatCtx: { client }, - messageCtx: { message: deliveredMsg }, - }); - expect(container.children[0]).toHaveClass(rootClassName); - expect(container.children[0]).toBeEmptyDOMElement(); - }); - - it('renders empty container without custom delivered UI for not the last message', async () => { - const text = 'CustomMessageDeliveredStatus'; - const MessageDeliveredStatus = () =>
{text}
; - const client = await getTestClientWithUser(user); - const { container } = renderComponent({ - chatCtx: { client }, - messageCtx: { message: deliveredMsg }, - props: { MessageDeliveredStatus }, - }); - expect(container.children[0]).toHaveClass(rootClassName); - expect(container.children[0]).toBeEmptyDOMElement(); - }); - it('renders default read UI for the last message', async () => { const client = await getTestClientWithUser(user); renderComponent({ chatCtx: { client }, messageCtx: { - lastReceivedId: deliveredMsg.id, - message: deliveredMsg, + deliveredTo, + message: sentMsg, readBy: readByOthers, }, }); - expect(screen.getByTestId(MESSAGE_STATUS_READ_TEST_ID)).toMatchSnapshot(); + expect(screen.getByTestId(MESSAGE_STATUS_READ_TEST_ID)).toBeInTheDocument(); }); it('renders custom read UI for the last message', async () => { const text = 'CustomMessageReadStatus'; @@ -183,36 +172,14 @@ describe('MessageStatus', () => { renderComponent({ chatCtx: { client }, messageCtx: { - lastReceivedId: deliveredMsg.id, - message: deliveredMsg, + deliveredTo, + message: sentMsg, readBy: readByOthers, }, props: { MessageReadStatus }, }); expect(screen.getByText(text)).toBeInTheDocument(); }); - it('renders empty container without default read UI for not the last message', async () => { - const client = await getTestClientWithUser(user); - const { container } = renderComponent({ - chatCtx: { client }, - messageCtx: { message: deliveredMsg }, - }); - expect(container.children[0]).toHaveClass(rootClassName); - expect(container.children[0]).toBeEmptyDOMElement(); - }); - - it('renders empty container without custom read UI for not the last message', async () => { - const text = 'CustomMessageReadStatus'; - const MessageReadStatus = () =>
{text}
; - const client = await getTestClientWithUser(user); - const { container } = renderComponent({ - chatCtx: { client }, - messageCtx: { message: deliveredMsg }, - props: { MessageReadStatus }, - }); - expect(container.children[0]).toHaveClass(rootClassName); - expect(container.children[0]).toBeEmptyDOMElement(); - }); it('renders custom Avatar in default read status', async () => { const text = 'CustomAvatar'; @@ -221,8 +188,8 @@ describe('MessageStatus', () => { renderComponent({ chatCtx: { client }, messageCtx: { - lastReceivedId: deliveredMsg.id, - message: deliveredMsg, + deliveredTo, + message: sentMsg, readBy: readByOthers, }, props: { Avatar }, @@ -235,7 +202,7 @@ describe('MessageStatus', () => { const client = await getTestClientWithUser(user); const { container } = renderComponent({ chatCtx: { client }, - messageCtx: { message: deliveredMsg }, + messageCtx: { message: sentMsg }, props: { messageType: 'XXX' }, }); expect(container.children[0]).not.toHaveClass('str-chat__message-simple-status'); @@ -257,7 +224,7 @@ describe('MessageStatus', () => { const client = await getTestClientWithUser(user); renderComponent({ chatCtx: { client }, - messageCtx: { message: ownMessage, readBy: [generateUser()] }, + messageCtx: { message: ownMessage, readBy: [otherUser] }, }); expect( screen.queryByTestId(MESSAGE_STATUS_READ_COUNT_TEST_ID), diff --git a/src/components/Message/__tests__/__snapshots__/MessageStatus.test.js.snap b/src/components/Message/__tests__/__snapshots__/MessageStatus.test.js.snap index 9b9575f259..062a4ea48d 100644 --- a/src/components/Message/__tests__/__snapshots__/MessageStatus.test.js.snap +++ b/src/components/Message/__tests__/__snapshots__/MessageStatus.test.js.snap @@ -1,100 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`MessageStatus renders default delivered UI for the last message 1`] = ` +exports[`MessageStatus renders default sending UI 1`] = ` - - - - -`; - -exports[`MessageStatus renders default read UI for the last message 1`] = ` - -
-
- o -
-
-
-`; - -exports[`MessageStatus renders default sending UI for not the last message 1`] = ` - -
- - - - - - - - - -
-
-`; - -exports[`MessageStatus renders default sending UI for the last message 1`] = ` -
{ ); }; +export const MessageSentIcon = () => ( + + + +); + export const MessageDeliveredIcon = () => ( diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts index e598b94a80..53dfe375f1 100644 --- a/src/components/Message/types.ts +++ b/src/components/Message/types.ts @@ -27,6 +27,8 @@ export type MessageProps = { closeReactionSelectorOnClick?: boolean; /** Object containing custom message actions and function handlers */ customMessageActions?: MessageContextValue['customMessageActions']; + /** An array of user IDs that have confirmed the message delivery to their device */ + deliveredTo?: UserResponse[]; /** If true, disables the ability for users to quote messages, defaults to false */ disableQuotedMessages?: boolean; /** When true, the message is the last one in a group sent by a specific user (only used in the `VirtualizedMessageList`) */ diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index 7c7d7720e3..c751d15092 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -338,6 +338,7 @@ export const areMessagePropsEqual = ( const deepEqualProps = deepequal(nextProps.messageActions, prevProps.messageActions) && deepequal(nextProps.readBy, prevProps.readBy) && + deepequal(nextProps.deliveredTo, prevProps.deliveredTo) && deepequal(nextProps.highlighted, prevProps.highlighted) && deepequal(nextProps.groupStyles, prevProps.groupStyles) && // last 3 messages can have different group styles deepequal(nextProps.mutes, prevProps.mutes) && @@ -366,6 +367,7 @@ export const areMessageUIPropsEqual = ( if (prevProps.endOfGroup !== nextProps.endOfGroup) return false; if (prevProps.mutes?.length !== nextProps.mutes?.length) return false; if (prevProps.readBy?.length !== nextProps.readBy?.length) return false; + if (prevProps.deliveredTo?.length !== nextProps.deliveredTo?.length) return false; if (prevProps.groupStyles !== nextProps.groupStyles) return false; if (prevProps.showDetailedReactions !== nextProps.showDetailedReactions) { diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index de75dcf1ba..6faf8732c5 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -74,7 +74,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { notifications, pinPermissions = defaultPinPermissions, reactionDetailsSort, - read, renderMessages = defaultRenderMessages, returnAllReadData = false, reviewProcessedMessage, @@ -177,7 +176,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { unsafeHTML, }, messageGroupStyles, - read, + messages, renderMessages, returnAllReadData, threadList, diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index f9187777c3..ba7a66cdb2 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -67,6 +67,7 @@ import type { import type { UnknownType } from '../../types/types'; import { DEFAULT_NEXT_CHANNEL_PAGE_SIZE } from '../../constants/limits'; import { useStableId } from '../UtilityComponents/useStableId'; +import { useLastDeliveredData } from './hooks/useLastDeliveredData'; type PropsDrilledToMessage = | 'additionalMessageInputProps' @@ -105,6 +106,8 @@ export type VirtuosoContext = Required< messageGroupStyles: Record; /** Number of messages prepended before the first page of messages. This is needed to calculate the virtual position in the virtual list. */ numItemsPrepended: number; + /** Mapping of message ID of own messages to the array of users, who were delivered the given message */ + ownMessagesDeliveredToOthers: Record; /** Mapping of message ID of own messages to the array of users, who read the given message */ ownMessagesReadByOthers: Record; /** The original message list enriched with date separators, omitted deleted messages or giphy previews. */ @@ -207,7 +210,6 @@ const VirtualizedMessageListWithContext = ( // TODO: refactor to scrollSeekPlaceHolderConfiguration and components.ScrollSeekPlaceholder, like the Virtuoso Component overscan = 0, reactionDetailsSort, - read, returnAllReadData = false, reviewProcessedMessage, scrollSeekPlaceHolder, @@ -296,10 +298,15 @@ const VirtualizedMessageListWithContext = ( // get the mapping of own messages to array of users who read them const ownMessagesReadByOthers = useLastReadData({ - messages: processedMessages, - read, + channel, + messages: messages || [], + returnAllReadData, + }); + + const ownMessagesDeliveredToOthers = useLastDeliveredData({ + channel, + messages: messages || [], returnAllReadData, - userID: client.userID, }); const lastReceivedMessageId = useMemo( @@ -491,6 +498,7 @@ const VirtualizedMessageListWithContext = ( MessageSystem, numItemsPrepended, openThread, + ownMessagesDeliveredToOthers, ownMessagesReadByOthers, processedMessages, reactionDetailsSort, diff --git a/src/components/MessageList/VirtualizedMessageListComponents.tsx b/src/components/MessageList/VirtualizedMessageListComponents.tsx index ce52d7f811..cb2a1f76a1 100644 --- a/src/components/MessageList/VirtualizedMessageListComponents.tsx +++ b/src/components/MessageList/VirtualizedMessageListComponents.tsx @@ -125,6 +125,7 @@ export const messageRenderer = ( MessageSystem, numItemsPrepended, openThread, + ownMessagesDeliveredToOthers, ownMessagesReadByOthers, processedMessages: messageList, reactionDetailsSort, @@ -200,6 +201,7 @@ export const messageRenderer = ( autoscrollToBottom={virtuosoRef.current?.autoscrollToBottom} closeReactionSelectorOnClick={closeReactionSelectorOnClick} customMessageActions={customMessageActions} + deliveredTo={ownMessagesDeliveredToOthers[message.id] || []} endOfGroup={endOfGroup} firstOfGroup={firstOfGroup} formatDate={formatDate} diff --git a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js index fd879ab2fe..ad5bddcf5e 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js +++ b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js @@ -278,6 +278,7 @@ describe('VirtualizedMessageComponents', () => { Message, messageGroupStyles, numItemsPrepended, + ownMessagesDeliveredToOthers: {}, ownMessagesReadByOthers: {}, prependOffset, processedMessages, @@ -406,6 +407,7 @@ describe('VirtualizedMessageComponents', () => { Message, messageGroupStyles: {}, numItemsPrepended: 1, + ownMessagesDeliveredToOthers: {}, ownMessagesReadByOthers: {}, processedMessages: messages, unreadMessageCount: 1, @@ -441,6 +443,7 @@ describe('VirtualizedMessageComponents', () => { Message, messageGroupStyles: {}, numItemsPrepended: 1, + ownMessagesDeliveredToOthers: {}, ownMessagesReadByOthers: {}, processedMessages: messages, unreadMessageCount: 1, @@ -466,6 +469,7 @@ describe('VirtualizedMessageComponents', () => { Message, messageGroupStyles: {}, numItemsPrepended: 1, + ownMessagesDeliveredToOthers: {}, ownMessagesReadByOthers: {}, processedMessages: messages, unreadMessageCount: 0, @@ -500,6 +504,7 @@ describe('VirtualizedMessageComponents', () => { Message, messageGroupStyles: {}, numItemsPrepended: 1, + ownMessagesDeliveredToOthers: {}, ownMessagesReadByOthers: {}, processedMessages: messages, unreadMessageCount: 0, @@ -524,6 +529,7 @@ describe('VirtualizedMessageComponents', () => { Message, messageGroupStyles: {}, numItemsPrepended: 0, + ownMessagesDeliveredToOthers: {}, ownMessagesReadByOthers: {}, processedMessages: messages, unreadMessageCount: 1, @@ -567,6 +573,7 @@ describe('VirtualizedMessageComponents', () => { Message: MessageSimple, messageGroupStyles: {}, numItemsPrepended, + ownMessagesDeliveredToOthers: {}, ownMessagesReadByOthers: {}, prependOffset, processedMessages, diff --git a/src/components/MessageList/__tests__/utils.test.js b/src/components/MessageList/__tests__/utils.test.js index 30dff953cf..6e9f7efd23 100644 --- a/src/components/MessageList/__tests__/utils.test.js +++ b/src/components/MessageList/__tests__/utils.test.js @@ -4,12 +4,7 @@ import { generateUser, } from '../../../mock-builders'; -import { - getGroupStyles, - getReadStates, - makeDateMessageId, - processMessages, -} from '../utils'; +import { getGroupStyles, makeDateMessageId, processMessages } from '../utils'; import { CUSTOM_MESSAGE_TYPE } from '../../../constants/messageTypes'; const mockedNanoId = 'V1StGXR8_Z5jdHi6B-myT'; @@ -686,73 +681,3 @@ describe('getGroupStyles', () => { ); }); }); - -describe('getReadStates', () => { - const messages = [ - generateMessage({ - created_at: new Date('2024-05-21T17:57:31.9876Z'), - id: 'u49866124-uJx-xdCYq0zQ9r5VTuJFH', - }), - generateMessage({ - created_at: new Date('2024-05-21T17:57:32.9876Z'), - id: 'u49866124-uJx-xdCYq0zQ9r5VTuJFV', - }), - generateMessage({ - created_at: new Date('2024-07-24T22:49:35.527Z'), - id: 'u49866124-uJx-xdCYq0zQ9r5VTuJFY', - }), - ]; - const read = { - user1: { - last_read: new Date('2024-05-21T17:20:29.402Z'), - last_read_message_id: undefined, - user: { id: 'user1' }, - }, - user2: { - last_read: new Date('2024-07-24T22:49:36.527Z'), - last_read_message_id: 'u96661092-14eb8ca1-a04c-4098-1d96-b1313d0b794b', - user: { id: 'user2' }, - }, - user3: { - last_read: '2024-05-21T17:40:57.794Z', - last_read_message_id: 'user7-dcad8dbd-f234-469e-2a46-bd8405beabb7', - user: { id: 'user3' }, - }, - user5: { - last_read: undefined, - last_read_message_id: undefined, - user: { id: 'user5' }, - }, - user6: { - last_read: undefined, - last_read_message_id: 'u49866124-uJx-xdCYq0zQ9r5VTuJFH', - user: { id: 'user6' }, - }, - user7: { - last_read: new Date('2024-05-21T17:59:04.911Z'), - last_read_message_id: 'u49866124-uJx-xdCYq0zQ9r5VTuJFH', - user: { id: 'user7' }, - }, - user8: { - last_read: new Date('2024-06-24T23:00:12.391Z'), - last_read_message_id: 'u49866124-73190b61-adf7-4e99-0779-565f22239e36', - user: undefined, - }, - }; - - it('returns the list of message readers based on last_read timestamp only for the last read message by user', () => { - expect(getReadStates(messages, read)).toStrictEqual({ - 'u49866124-uJx-xdCYq0zQ9r5VTuJFV': [{ id: 'user7' }, undefined], - 'u49866124-uJx-xdCYq0zQ9r5VTuJFY': [{ id: 'user2' }], - }); - }); - - it("lists the user for each message that was created before the user's last_read timestamp", () => { - const returnAllReadData = true; - expect(getReadStates(messages, read, returnAllReadData)).toStrictEqual({ - 'u49866124-uJx-xdCYq0zQ9r5VTuJFH': [{ id: 'user2' }, { id: 'user7' }, undefined], - 'u49866124-uJx-xdCYq0zQ9r5VTuJFV': [{ id: 'user2' }, { id: 'user7' }, undefined], - 'u49866124-uJx-xdCYq0zQ9r5VTuJFY': [{ id: 'user2' }], - }); - }); -}); diff --git a/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx b/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx index ac186855fa..a09c067496 100644 --- a/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx +++ b/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx @@ -2,18 +2,20 @@ import type React from 'react'; import { useMemo } from 'react'; import { useLastReadData } from '../useLastReadData'; +import type { GroupStyle, RenderedMessage } from '../../utils'; import { getLastReceived } from '../../utils'; import { useChatContext } from '../../../../context/ChatContext'; import { useComponentContext } from '../../../../context/ComponentContext'; -import type { ChannelState as StreamChannelState } from 'stream-chat'; - -import type { GroupStyle, RenderedMessage } from '../../utils'; +import type { LocalMessage } from 'stream-chat'; import type { ChannelUnreadUiState } from '../../../../types/types'; import type { MessageRenderer, SharedMessageProps } from '../../renderMessages'; +import { useChannelStateContext } from '../../../../context'; +import { useLastDeliveredData } from '../useLastDeliveredData'; type UseMessageListElementsProps = { + messages: LocalMessage[]; enrichedMessages: RenderedMessage[]; internalMessageProps: SharedMessageProps; messageGroupStyles: Record; @@ -21,7 +23,6 @@ type UseMessageListElementsProps = { returnAllReadData: boolean; threadList: boolean; channelUnreadUiState?: ChannelUnreadUiState; - read?: StreamChannelState['read']; }; export const useMessageListElements = (props: UseMessageListElementsProps) => { @@ -30,21 +31,27 @@ export const useMessageListElements = (props: UseMessageListElementsProps) => { enrichedMessages, internalMessageProps, messageGroupStyles, - read, + messages, renderMessages, returnAllReadData, threadList, } = props; - const { client, customClasses } = useChatContext('useMessageListElements'); + const { customClasses } = useChatContext('useMessageListElements'); + const { channel } = useChannelStateContext(); const components = useComponentContext('useMessageListElements'); // get the readData, but only for messages submitted by the user themselves const readData = useLastReadData({ - messages: enrichedMessages, - read, + channel, + messages, + returnAllReadData, + }); + + const ownMessagesDeliveredToOthers = useLastDeliveredData({ + channel, + messages, returnAllReadData, - userID: client.userID, }); const lastReceivedMessageId = useMemo( @@ -61,6 +68,7 @@ export const useMessageListElements = (props: UseMessageListElementsProps) => { lastReceivedMessageId, messageGroupStyles, messages: enrichedMessages, + ownMessagesDeliveredToOthers, readData, sharedMessageProps: { ...internalMessageProps, threadList }, }), diff --git a/src/components/MessageList/hooks/useLastDeliveredData.ts b/src/components/MessageList/hooks/useLastDeliveredData.ts new file mode 100644 index 0000000000..55dfb83c4b --- /dev/null +++ b/src/components/MessageList/hooks/useLastDeliveredData.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react'; +import type { Channel, LocalMessage, UserResponse } from 'stream-chat'; + +type UseLastDeliveredDataParams = { + channel: Channel; + messages: LocalMessage[]; + returnAllReadData: boolean; +}; + +export const useLastDeliveredData = (props: UseLastDeliveredDataParams) => { + const { channel, messages, returnAllReadData } = props; + + return useMemo( + () => + returnAllReadData + ? messages.reduce( + (acc, msg) => { + acc[msg.id] = channel.ownMessageReceiptsTracker.deliveredForMessage({ + msgId: msg.id, + timestampMs: msg.created_at.getTime(), + }); + return acc; + }, + {} as Record, + ) + : channel.ownMessageReceiptsTracker.groupUsersByLastDeliveredMessage(), + [channel, messages, returnAllReadData], + ); +}; diff --git a/src/components/MessageList/hooks/useLastReadData.ts b/src/components/MessageList/hooks/useLastReadData.ts index 6e803496c2..138e280228 100644 --- a/src/components/MessageList/hooks/useLastReadData.ts +++ b/src/components/MessageList/hooks/useLastReadData.ts @@ -1,25 +1,29 @@ import { useMemo } from 'react'; - -import { isLocalMessage } from '../utils'; -import { getReadStates } from '../utils'; - -import type { LocalMessage, UserResponse } from 'stream-chat'; -import type { RenderedMessage } from '../utils'; +import type { Channel, LocalMessage, UserResponse } from 'stream-chat'; type UseLastReadDataParams = { - messages: RenderedMessage[]; + channel: Channel; + messages: LocalMessage[]; returnAllReadData: boolean; - userID: string | undefined; - read?: Record; }; export const useLastReadData = (props: UseLastReadDataParams) => { - const { messages, read, returnAllReadData, userID } = props; + const { channel, messages, returnAllReadData } = props; - return useMemo(() => { - const ownLocalMessages = messages.filter( - (msg) => isLocalMessage(msg) && msg.user?.id === userID, - ) as LocalMessage[]; - return getReadStates(ownLocalMessages, read, returnAllReadData); - }, [messages, read, returnAllReadData, userID]); + return useMemo( + () => + returnAllReadData + ? messages.reduce( + (acc, msg) => { + acc[msg.id] = channel.ownMessageReceiptsTracker.readersForMessage({ + msgId: msg.id, + timestampMs: msg.created_at.getTime(), + }); + return acc; + }, + {} as Record, + ) + : channel.ownMessageReceiptsTracker.groupUsersByLastReadMessage(), + [channel, messages, returnAllReadData], + ); }; diff --git a/src/components/MessageList/renderMessages.tsx b/src/components/MessageList/renderMessages.tsx index 50b571d3b7..031b6eb2ed 100644 --- a/src/components/MessageList/renderMessages.tsx +++ b/src/components/MessageList/renderMessages.tsx @@ -1,13 +1,13 @@ +import type { ReactNode } from 'react'; import React, { Fragment } from 'react'; +import type { GroupStyle, RenderedMessage } from './utils'; import { getIsFirstUnreadMessage, isDateSeparatorMessage, isIntroMessage } from './utils'; +import type { MessageProps } from '../Message'; import { Message } from '../Message'; import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; import { EventComponent as DefaultMessageSystem } from '../EventComponent'; import { UnreadMessagesSeparator as DefaultUnreadMessagesSeparator } from './UnreadMessagesSeparator'; -import type { ReactNode } from 'react'; import type { UserResponse } from 'stream-chat'; -import type { GroupStyle, RenderedMessage } from './utils'; -import type { MessageProps } from '../Message'; import type { ComponentContextValue, CustomClasses } from '../../context'; import type { ChannelUnreadUiState } from '../../types'; @@ -16,6 +16,7 @@ export interface RenderMessagesOptions { lastReceivedMessageId: string | null; messageGroupStyles: Record; messages: Array; + ownMessagesDeliveredToOthers: Record; /** * Object mapping message IDs of own messages to the users who read those messages. */ @@ -39,6 +40,7 @@ export type MessageRenderer = (options: RenderMessagesOptions) => Array { return null; }; -export const getReadStates = ( - messages: LocalMessage[], - read: Record = {}, - returnAllReadData: boolean, -) => { - // create object with empty array for each message id - const readData: Record> = {}; - - Object.values(read).forEach((readState) => { - if (!readState.last_read) return; - - let userLastReadMsgId: string | undefined; - - // loop messages sent by current user and add read data for other users in channel - messages.forEach((msg) => { - if (msg.created_at && msg.created_at < readState.last_read) { - userLastReadMsgId = msg.id; - - // if true, save other user's read data for all messages they've read - if (returnAllReadData) { - if (!readData[userLastReadMsgId]) { - readData[userLastReadMsgId] = []; - } - - readData[userLastReadMsgId].push(readState.user); - } - } - }); - - // if true, only save read data for other user's last read message - if (userLastReadMsgId && !returnAllReadData) { - if (!readData[userLastReadMsgId]) { - readData[userLastReadMsgId] = []; - } - - readData[userLastReadMsgId].push(readState.user); - } - }); - - return readData; -}; - export const insertIntro = (messages: RenderedMessage[], headerPosition?: number) => { const newMessages = messages; const intro = makeIntroMessage(); diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index cb7f30c463..8029ce6198 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -97,6 +97,8 @@ export type MessageContextValue = { closeReactionSelectorOnClick?: boolean; /** Object containing custom message actions and function handlers */ customMessageActions?: CustomMessageActions; + /** An array of user IDs that have confirmed the message delivery to their device */ + deliveredTo?: UserResponse[]; /** If true, the message is the last one in a group sent by a specific user (only used in the `VirtualizedMessageList`) */ endOfGroup?: boolean; /** If true, the message is the first one in a group sent by a specific user (only used in the `VirtualizedMessageList`) */ diff --git a/src/mock-builders/event/messageDelivered.ts b/src/mock-builders/event/messageDelivered.ts new file mode 100644 index 0000000000..a9ad060202 --- /dev/null +++ b/src/mock-builders/event/messageDelivered.ts @@ -0,0 +1,72 @@ +import type { + Channel, + CustomChannelData, + Event, + StreamChat, + UserResponse, +} from 'stream-chat'; + +type MessageDeliveredEvent = { + channel_custom: CustomChannelData; + channel_id: string; + channel_member_count: number; + channel_type: string; + cid: string; + created_at: string; + last_delivered_at: string; + last_delivered_message_id: string; + user: UserResponse; +}; +export const makeMessageDeliveredEvent = ( + event: Partial = {}, +): Event => ({ + channel_custom: { + name: 'Test', + }, + channel_id: 'test', + channel_member_count: 2, + channel_type: 'messaging', + cid: 'messaging:test', + created_at: '2025-09-16T13:25:57.996011272Z', + last_delivered_at: '2025-09-16T13:25:57Z', + last_delivered_message_id: 'aefbf38a-0e02-4ba6-a480-e595c37ec78a', + type: 'message.delivered', + user: { + banned: false, + blocked_user_ids: [], + created_at: '2025-09-16T09:01:40.650479Z', + id: 'test1', + last_active: '2025-09-16T13:22:52.69594176Z', + online: true, + role: 'user', + teams: [], + updated_at: '2025-09-16T12:40:29.86597Z', + }, + ...event, +}); + +export const dispatchMessageDeliveredEvent = ({ + channel, + client, + deliveredAt, + lastDeliveredMessageId, + user, +}: { + channel: Channel; + client: StreamChat; + deliveredAt: string; + lastDeliveredMessageId: string; + user?: UserResponse; +}) => + client.dispatchEvent( + makeMessageDeliveredEvent({ + channel_id: channel.id, + channel_member_count: channel.data?.member_count || 0, + channel_type: channel.type, + cid: channel.cid, + created_at: new Date().toISOString(), + last_delivered_at: deliveredAt, + last_delivered_message_id: lastDeliveredMessageId, + user, + }), + ); From 92180ce8e7d6875e0e82fa408a9c0233a0bbca5a Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 26 Sep 2025 12:53:42 +0200 Subject: [PATCH 2/4] chore: add todo --- src/components/Message/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts index 53dfe375f1..c085fe8c6d 100644 --- a/src/components/Message/types.ts +++ b/src/components/Message/types.ts @@ -63,6 +63,7 @@ export type MessageProps = { highlighted?: boolean; /** Whether the threaded message is the first in the thread list */ initialMessage?: boolean; + // todo: could be moved to the Channel instance reactive state as lastReceivedMessage keeping the the receipt status as well (useful for channel preview) /** Latest message id on current channel */ lastReceivedId?: string | null; /** UI component to display a Message in MessageList, overrides value in [ComponentContext](https://getstream.io/chat/docs/sdk/react/contexts/component_context/#message) */ From 17a07c726ef275f432f5ea13db5293d25017145c Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 7 Oct 2025 09:34:17 +0200 Subject: [PATCH 3/4] feat: add tooltip to the "Sent" message status --- src/components/Message/MessageStatus.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/Message/MessageStatus.tsx b/src/components/Message/MessageStatus.tsx index 8e1ac84b35..789f779d3e 100644 --- a/src/components/Message/MessageStatus.tsx +++ b/src/components/Message/MessageStatus.tsx @@ -108,7 +108,21 @@ const UnMemoizedMessageStatus = (props: MessageStatusProps) => { ))} - {sent && (MessageSentStatus ? : )} + {sent && + (MessageSentStatus ? ( + + ) : ( + <> + + {t('Sent')} + + + + ))} {delivered && (MessageDeliveredStatus ? ( From 4116d6fbc6a641782bad93b61b5d034b632f130a Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 7 Oct 2025 09:36:23 +0200 Subject: [PATCH 4/4] chore(deps): upgrade stream-chat to v9.20.3 --- package.json | 4 ++-- yarn.lock | 30 +++++++++++++++--------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 6f6e7753ed..affcb1e695 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.19.0" + "stream-chat": "^9.20.3" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -236,7 +236,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "semantic-release": "^24.2.3", - "stream-chat": "^9.19.0", + "stream-chat": "^9.20.3", "ts-jest": "^29.2.5", "typescript": "^5.4.5", "typescript-eslint": "^8.17.0" diff --git a/yarn.lock b/yarn.lock index 5432a3cd54..a4eae308eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3557,13 +3557,13 @@ axe-core@4.7.2, axe-core@^4.3.3, axe-core@^4.4.1: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.7.2.tgz#040a7342b20765cb18bb50b628394c21bccc17a0" integrity sha512-zIURGIS1E1Q4pcrMjp+nnEh+16G56eG/MUllJH8yEvw7asDo7Ac9uhC9KIH5jzpITueEZolfYglnCGIuSBz39g== -axios@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.0.tgz#f1e5292f26b2fd5c2e66876adc5b06cdbd7d2102" - integrity sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg== +axios@^1.12.2: + version "1.12.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.2.tgz#6c307390136cf7a2278d09cec63b136dfc6e6da7" + integrity sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw== dependencies: - follow-redirects "^1.15.0" - form-data "^4.0.0" + follow-redirects "^1.15.6" + form-data "^4.0.4" proxy-from-env "^1.1.0" b4a@^1.6.4: @@ -6187,10 +6187,10 @@ flow-remove-types@^2.176.3: pirates "^3.0.2" vlq "^0.2.1" -follow-redirects@^1.15.0: - version "1.15.3" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" - integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q== +follow-redirects@^1.15.6: + version "1.15.11" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" + integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== for-each@^0.3.3: version "0.3.3" @@ -12042,14 +12042,14 @@ 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.19.0: - version "9.19.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.19.0.tgz#8a2055be0f7c073ee8ca10cbc40af7d36648c476" - integrity sha512-ooRLubHPWxVr8Ws3fZvR30BFhVNM1xcrEgRnGGBxNINYYH/Wq+uc6AWONYIeu+n8crwRp3NSrtNfGWpWhLSy7Q== +stream-chat@^9.20.3: + version "9.20.3" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.20.3.tgz#5f47d6f46d146202c743282f5fb7350f4a640922" + integrity sha512-206Lea0ZAVWbfYZkIwLG5m+++ELD3f8EAEL/YzbMDL++E2vU2WhQ2d1HNb1ROXURZUF0Sy845htTw1rwnahomw== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" - axios "^1.6.0" + axios "^1.12.2" base64-js "^1.5.1" form-data "^4.0.4" isomorphic-ws "^5.0.0"