diff --git a/src/components/Channel/hooks/useCreateChannelStateContext.ts b/src/components/Channel/hooks/useCreateChannelStateContext.ts index d53c17221..398659279 100644 --- a/src/components/Channel/hooks/useCreateChannelStateContext.ts +++ b/src/components/Channel/hooks/useCreateChannelStateContext.ts @@ -48,9 +48,12 @@ export const useCreateChannelStateContext = ( const notificationsLength = notifications.length; const readUsers = Object.values(read); const readUsersLength = readUsers.length; - const readUsersLastReads = readUsers - .map(({ last_read }) => last_read.toISOString()) - .join(); + const readUsersLastReadDateStrings: string[] = []; + for (const { last_read } of readUsers) { + if (!lastRead) continue; + readUsersLastReadDateStrings.push(last_read?.toISOString()); + } + const readUsersLastReads = readUsersLastReadDateStrings.join(); const threadMessagesLength = threadMessages?.length; const channelCapabilities: Record = {}; diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index c14248ef9..39b0e3f03 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -288,6 +288,7 @@ export const Message = (props: MessageProps) => { handleRetry={handleRetry} highlighted={highlighted} initialMessage={props.initialMessage} + lastOwnMessage={props.lastOwnMessage} lastReceivedId={props.lastReceivedId} message={message} Message={props.Message} @@ -302,6 +303,7 @@ export const Message = (props: MessageProps) => { reactionDetailsSort={reactionDetailsSort} readBy={props.readBy} renderText={props.renderText} + returnAllReadData={props.returnAllReadData} sortReactionDetails={sortReactionDetails} sortReactions={sortReactions} threadList={props.threadList} diff --git a/src/components/Message/MessageStatus.tsx b/src/components/Message/MessageStatus.tsx index 789f779d3..06dd507aa 100644 --- a/src/components/Message/MessageStatus.tsx +++ b/src/components/Message/MessageStatus.tsx @@ -49,8 +49,15 @@ const UnMemoizedMessageStatus = (props: MessageStatusProps) => { const { client } = useChatContext('MessageStatus'); const { Avatar: contextAvatar } = useComponentContext('MessageStatus'); - const { deliveredTo, isMyMessage, message, readBy, threadList } = - useMessageContext('MessageStatus'); + const { + deliveredTo, + isMyMessage, + lastOwnMessage, + message, + readBy, + returnAllReadData, + threadList, + } = useMessageContext('MessageStatus'); const { t } = useTranslationContext('MessageStatus'); const [referenceElement, setReferenceElement] = useState(null); @@ -64,7 +71,12 @@ const UnMemoizedMessageStatus = (props: MessageStatusProps) => { const sending = message.status === 'sending'; const read = !!(readBy?.length && !justReadByMe && !threadList); const delivered = !!(deliveredTo?.length && !deliveredOnlyToMe && !read && !threadList); - const sent = message.status === 'received' && !delivered && !read && !threadList; + const sent = + (returnAllReadData || lastOwnMessage?.id === message.id) && + message.status === 'received' && + !delivered && + !read && + !threadList; const readersWithoutOwnUser = read ? readBy.filter((item) => item.id !== client.user?.id) diff --git a/src/components/Message/__tests__/MessageSimple.test.js b/src/components/Message/__tests__/MessageSimple.test.js index 3de2d9fad..83bd3c80e 100644 --- a/src/components/Message/__tests__/MessageSimple.test.js +++ b/src/components/Message/__tests__/MessageSimple.test.js @@ -354,111 +354,146 @@ describe('', () => { expect(results).toHaveNoViolations(); }); - it('should render no status when message not from the current user', async () => { - const message = generateBobMessage(); - const { container, queryByTestId } = await renderMessageSimple({ message }); - expect(queryByTestId(/message-status/)).not.toBeInTheDocument(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); + describe('delivery status', () => { + it('should render no status when message not from the current user', async () => { + const message = generateBobMessage(); + const { container, queryByTestId } = await renderMessageSimple({ message }); + expect(queryByTestId(/message-status/)).not.toBeInTheDocument(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should not render status when message is an error message', async () => { + const message = generateAliceMessage({ type: 'error' }); + const { container, queryByTestId } = await renderMessageSimple({ + message, + props: { + readBy: [alice, bob], + }, + }); + expect(queryByTestId(/message-status/)).not.toBeInTheDocument(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); - it('should not render status when message is an error message', async () => { - const message = generateAliceMessage({ type: 'error' }); - const { container, queryByTestId } = await renderMessageSimple({ message }); - expect(queryByTestId(/message-status/)).not.toBeInTheDocument(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); + it('should render sending status when sending message', async () => { + const message = generateAliceMessage({ status: 'sending' }); + const { container, getByTestId } = await renderMessageSimple({ message }); + expect(getByTestId('message-status-sending')).toBeInTheDocument(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); - it('should render sending status when sending message', async () => { - const message = generateAliceMessage({ status: 'sending' }); - const { container, getByTestId } = await renderMessageSimple({ message }); - expect(getByTestId('message-status-sending')).toBeInTheDocument(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); + it('should render the "read by" status when the message is not part of a thread and was read by another chat members', async () => { + const message = generateAliceMessage(); + const { container, getByTestId } = await renderMessageSimple({ + message, + props: { + readBy: [alice, bob], + }, + }); + expect(getByTestId('message-status-read-by')).toBeInTheDocument(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); - it('should render the "read by" status when the message is not part of a thread and was read by another chat members', async () => { - const message = generateAliceMessage(); - const { container, getByTestId } = await renderMessageSimple({ - message, - props: { - readBy: [alice, bob], - }, + 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: { + readBy: [alice, bob, carol], + }, + }); + expect(getByTestId('message-status-read-by-many')).toBeInTheDocument(); + const results = await axe(container); + expect(results).toHaveNoViolations(); }); - expect(getByTestId('message-status-read-by')).toBeInTheDocument(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - 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: { - readBy: [alice, bob, carol], - }, + it('should render a sent status when the message has status "received" and was not delivered to others and returnAllReadData=true', async () => { + const message = generateAliceMessage({ status: 'received' }); + const { container, getByTestId } = await renderMessageSimple({ + message, + props: { + deliveredTo: [alice], + returnAllReadData: true, + }, + }); + expect(getByTestId('message-status-sent')).toBeInTheDocument(); + const results = await axe(container); + expect(results).toHaveNoViolations(); }); - expect(getByTestId('message-status-read-by-many')).toBeInTheDocument(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - 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: { - deliveredTo: [alice], - }, + it('should not render sent status when the message is not lastOwnMessage and returnAllReadData=false', async () => { + const message = generateAliceMessage({ status: 'received' }); + const { container } = await renderMessageSimple({ + message, + props: { + deliveredTo: [alice], + lastOwnMessage: generateAliceMessage({ status: 'received' }), + }, + }); + expect(screen.queryByTestId('message-status-sent')).not.toBeInTheDocument(); + const results = await axe(container); + expect(results).toHaveNoViolations(); }); - expect(getByTestId('message-status-sent')).toBeInTheDocument(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - 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: { - deliveredTo: [alice, bob], - }, + it('should render sent status when the message is not lastOwnMessage and returnAllReadData=false', async () => { + const message = generateAliceMessage({ status: 'received' }); + const { container } = await renderMessageSimple({ + message, + props: { + deliveredTo: [alice], + lastOwnMessage: message, + }, + }); + expect(screen.queryByTestId('message-status-sent')).toBeInTheDocument(); + const results = await axe(container); + expect(results).toHaveNoViolations(); }); - expect(getByTestId('message-status-delivered')).toBeInTheDocument(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - 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: { - deliveredTo: [alice, bob], - readBy: [alice], - threadList: true, - }, + 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: { + deliveredTo: [alice, bob], + }, + }); + expect(getByTestId('message-status-delivered')).toBeInTheDocument(); + const results = await axe(container); + expect(results).toHaveNoViolations(); }); - expect(queryByTestId(/message-status/)).not.toBeInTheDocument(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - it('should not render status when rendered in a thread list and was read by other members', async () => { - const message = generateAliceMessage(); - const { container, queryByTestId } = await renderMessageSimple({ - message, - props: { - readBy: [alice, bob, carol], - threadList: true, - }, + 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: { + deliveredTo: [alice, bob], + readBy: [alice], + threadList: true, + }, + }); + expect(queryByTestId(/message-status/)).not.toBeInTheDocument(); + const results = await axe(container); + expect(results).toHaveNoViolations(); }); - expect(queryByTestId(/message-status/)).not.toBeInTheDocument(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); + it('should not render status when rendered in a thread list and was read by other members', async () => { + const message = generateAliceMessage(); + const { container, queryByTestId } = await renderMessageSimple({ + message, + props: { + readBy: [alice, bob, carol], + threadList: true, + }, + }); + expect(queryByTestId(/message-status/)).not.toBeInTheDocument(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); it("should render the message user's avatar", async () => { const message = generateBobMessage(); const { container } = await renderMessageSimple({ diff --git a/src/components/Message/__tests__/MessageStatus.test.js b/src/components/Message/__tests__/MessageStatus.test.js index 00ac5c920..77a1c9c06 100644 --- a/src/components/Message/__tests__/MessageStatus.test.js +++ b/src/components/Message/__tests__/MessageStatus.test.js @@ -110,28 +110,102 @@ describe('MessageStatus', () => { expect(screen.getByText(text)).toBeInTheDocument(); }); - it('renders default sent message UI', async () => { + it('renders default sent message UI (returnAllReadData=true)', async () => { const client = await getTestClientWithUser(user); renderComponent({ chatCtx: { client }, - messageCtx: { deliveredTo: [user], message: sentMsg, readBy: [user] }, + messageCtx: { + deliveredTo: [user], + message: sentMsg, + readBy: [user], + returnAllReadData: true, + }, }); expect(screen.getByTestId('message-sent-icon')).toBeInTheDocument(); }); - it('renders custom sent message UI', async () => { + it('renders custom sent message UI (returnAllReadData=true)', async () => { const text = 'CustomMessageSentStatus'; const MessageSentStatus = () =>
{text}
; const client = await getTestClientWithUser(user); renderComponent({ chatCtx: { client }, - messageCtx: { deliveredTo: [user], message: sentMsg, readBy: [user] }, + messageCtx: { + deliveredTo: [user], + message: sentMsg, + readBy: [user], + returnAllReadData: true, + }, props: { MessageSentStatus }, }); expect(screen.getByText(text)).toBeInTheDocument(); expect(screen.queryByTestId('message-sent-icon')).not.toBeInTheDocument(); }); + it('renders default sent message UI (returnAllReadData=false)', async () => { + const client = await getTestClientWithUser(user); + renderComponent({ + chatCtx: { client }, + messageCtx: { + deliveredTo: [user], + lastOwnMessage: ownMessage, + message: ownMessage, + readBy: [user], + }, + }); + expect(screen.getByTestId('message-sent-icon')).toBeInTheDocument(); + }); + + it('renders custom sent message UI (returnAllReadData=false)', async () => { + const text = 'CustomMessageSentStatus'; + const MessageSentStatus = () =>
{text}
; + const client = await getTestClientWithUser(user); + renderComponent({ + chatCtx: { client }, + messageCtx: { + deliveredTo: [user], + lastOwnMessage: ownMessage, + message: ownMessage, + readBy: [user], + }, + props: { MessageSentStatus }, + }); + expect(screen.getByText(text)).toBeInTheDocument(); + expect(screen.queryByTestId('message-sent-icon')).not.toBeInTheDocument(); + }); + + it('does not render default sent message UI (returnAllReadData=false) if the message is not the last own', async () => { + const client = await getTestClientWithUser(user); + renderComponent({ + chatCtx: { client }, + messageCtx: { + deliveredTo: [user], + lastOwnMessage: sentMsg, + message: ownMessage, + readBy: [user], + }, + }); + expect(screen.queryByTestId('message-sent-icon')).not.toBeInTheDocument(); + }); + + it('does not render custom sent message UI (returnAllReadData=false) if the message is not the last own', async () => { + const text = 'CustomMessageSentStatus'; + const MessageSentStatus = () =>
{text}
; + const client = await getTestClientWithUser(user); + renderComponent({ + chatCtx: { client }, + messageCtx: { + deliveredTo: [user], + lastOwnMessage: sentMsg, + message: ownMessage, + readBy: [user], + }, + props: { MessageSentStatus }, + }); + expect(screen.queryByText(text)).not.toBeInTheDocument(); + expect(screen.queryByTestId('message-sent-icon')).not.toBeInTheDocument(); + }); + it('renders default delivered UI for the last message', async () => { const client = await getTestClientWithUser(user); renderComponent({ diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts index c085fe8c6..c32669cb6 100644 --- a/src/components/Message/types.ts +++ b/src/components/Message/types.ts @@ -63,6 +63,9 @@ export type MessageProps = { highlighted?: boolean; /** Whether the threaded message is the first in the thread list */ initialMessage?: boolean; + // todo: move to LLC Channel state + /** Latest own message in currently displayed message set. */ + lastOwnMessage?: LocalMessage; // 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; @@ -98,6 +101,8 @@ export type MessageProps = { ) => ReactNode; /** Custom retry send message handler to override default in [ChannelActionContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_action_context/) */ retrySendMessage?: ChannelActionContextValue['retrySendMessage']; + /** Keep track of read receipts for each message sent by the user. When disabled, only the last own message delivery / read status is rendered. */ + returnAllReadData?: boolean; /** Comparator function to sort the list of reacted users * @deprecated use `reactionDetailsSort` instead */ diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 6faf8732c..52c6d7a57 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -41,6 +41,7 @@ import { DEFAULT_LOAD_PAGE_SCROLL_THRESHOLD, DEFAULT_NEXT_CHANNEL_PAGE_SIZE, } from '../../constants/limits'; +import { useLastOwnMessage } from './hooks/useLastOwnMessage'; type MessageListWithContextProps = Omit< ChannelStateContextValue, @@ -140,6 +141,11 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { reviewProcessedMessage, }); + const lastOwnMessage = useLastOwnMessage({ + messages, + ownUserId: channel.getClient().user?.id, + }); + const elements = useMessageListElements({ channelUnreadUiState, enrichedMessages, @@ -175,6 +181,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { sortReactions, unsafeHTML, }, + lastOwnMessage, messageGroupStyles, messages, renderMessages, diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index ba7a66cdb..185748295 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -68,6 +68,7 @@ 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'; +import { useLastOwnMessage } from './hooks/useLastOwnMessage'; type PropsDrilledToMessage = | 'additionalMessageInputProps' @@ -86,6 +87,7 @@ type VirtualizedMessageListPropsForContext = | 'head' | 'loadingMore' | 'Message' + | 'returnAllReadData' | 'shouldGroupByUser' | 'threadList'; @@ -114,6 +116,8 @@ export type VirtuosoContext = Required< processedMessages: RenderedMessage[]; /** Instance of VirtuosoHandle object providing the API to navigate in the virtualized list by various scroll actions. */ virtuosoRef: RefObject; + /** Latest own message in currently displayed message set. */ + lastOwnMessage?: LocalMessage; /** Message id which was marked as unread. ALl the messages following this message are considered unrea. */ firstUnreadMessageId?: string; lastReadDate?: Date; @@ -296,15 +300,19 @@ const VirtualizedMessageListWithContext = ( client.userID, ]); + const lastOwnMessage = useLastOwnMessage({ messages, ownUserId: client.user?.id }); + // get the mapping of own messages to array of users who read them const ownMessagesReadByOthers = useLastReadData({ channel, + lastOwnMessage, messages: messages || [], returnAllReadData, }); const ownMessagesDeliveredToOthers = useLastDeliveredData({ channel, + lastOwnMessage, messages: messages || [], returnAllReadData, }); @@ -488,6 +496,7 @@ const VirtualizedMessageListWithContext = ( firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id, formatDate, head, + lastOwnMessage, lastReadDate: channelUnreadUiState?.last_read, lastReadMessageId: channelUnreadUiState?.last_read_message_id, lastReceivedMessageId, @@ -502,6 +511,7 @@ const VirtualizedMessageListWithContext = ( ownMessagesReadByOthers, processedMessages, reactionDetailsSort, + returnAllReadData, shouldGroupByUser, sortReactionDetails, sortReactions, diff --git a/src/components/MessageList/VirtualizedMessageListComponents.tsx b/src/components/MessageList/VirtualizedMessageListComponents.tsx index cb2a1f76a..71cf6b712 100644 --- a/src/components/MessageList/VirtualizedMessageListComponents.tsx +++ b/src/components/MessageList/VirtualizedMessageListComponents.tsx @@ -116,6 +116,7 @@ export const messageRenderer = ( DateSeparator, firstUnreadMessageId, formatDate, + lastOwnMessage, lastReadDate, lastReadMessageId, lastReceivedMessageId, @@ -129,6 +130,7 @@ export const messageRenderer = ( ownMessagesReadByOthers, processedMessages: messageList, reactionDetailsSort, + returnAllReadData, shouldGroupByUser, sortReactionDetails, sortReactions, @@ -207,6 +209,7 @@ export const messageRenderer = ( formatDate={formatDate} groupedByUser={groupedByUser} groupStyles={[messageGroupStyles[message.id] ?? '']} + lastOwnMessage={lastOwnMessage} lastReceivedId={lastReceivedMessageId} message={message} Message={MessageUIComponent} @@ -214,6 +217,7 @@ export const messageRenderer = ( openThread={openThread} reactionDetailsSort={reactionDetailsSort} readBy={ownMessagesReadByOthers[message.id] || []} + returnAllReadData={returnAllReadData} sortReactionDetails={sortReactionDetails} sortReactions={sortReactions} threadList={threadList} diff --git a/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx b/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx index a09c06749..431c846a2 100644 --- a/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx +++ b/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx @@ -23,6 +23,7 @@ type UseMessageListElementsProps = { returnAllReadData: boolean; threadList: boolean; channelUnreadUiState?: ChannelUnreadUiState; + lastOwnMessage?: LocalMessage; }; export const useMessageListElements = (props: UseMessageListElementsProps) => { @@ -30,6 +31,7 @@ export const useMessageListElements = (props: UseMessageListElementsProps) => { channelUnreadUiState, enrichedMessages, internalMessageProps, + lastOwnMessage, messageGroupStyles, messages, renderMessages, @@ -44,12 +46,14 @@ export const useMessageListElements = (props: UseMessageListElementsProps) => { // get the readData, but only for messages submitted by the user themselves const readData = useLastReadData({ channel, + lastOwnMessage, messages, returnAllReadData, }); const ownMessagesDeliveredToOthers = useLastDeliveredData({ channel, + lastOwnMessage, messages, returnAllReadData, }); @@ -65,22 +69,25 @@ export const useMessageListElements = (props: UseMessageListElementsProps) => { channelUnreadUiState, components, customClasses, + lastOwnMessage, lastReceivedMessageId, messageGroupStyles, messages: enrichedMessages, ownMessagesDeliveredToOthers, readData, - sharedMessageProps: { ...internalMessageProps, threadList }, + sharedMessageProps: { ...internalMessageProps, returnAllReadData, threadList }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ enrichedMessages, internalMessageProps, + lastOwnMessage, lastReceivedMessageId, messageGroupStyles, channelUnreadUiState, readData, renderMessages, + returnAllReadData, threadList, ], ); diff --git a/src/components/MessageList/hooks/useLastDeliveredData.ts b/src/components/MessageList/hooks/useLastDeliveredData.ts index 35b9383bc..c18afbcb7 100644 --- a/src/components/MessageList/hooks/useLastDeliveredData.ts +++ b/src/components/MessageList/hooks/useLastDeliveredData.ts @@ -5,28 +5,35 @@ type UseLastDeliveredDataParams = { channel: Channel; messages: LocalMessage[]; returnAllReadData: boolean; + lastOwnMessage?: LocalMessage; }; export const useLastDeliveredData = ( props: UseLastDeliveredDataParams, ): Record => { - const { channel, messages, returnAllReadData } = props; - const calculate = useCallback( - () => - returnAllReadData - ? messages.reduce( - (acc, msg) => { - acc[msg.id] = channel.messageReceiptsTracker.deliveredForMessage({ - msgId: msg.id, - timestampMs: msg.created_at.getTime(), - }); - return acc; - }, - {} as Record, - ) - : channel.messageReceiptsTracker.groupUsersByLastDeliveredMessage(), - [channel, messages, returnAllReadData], - ); + const { channel, lastOwnMessage, messages, returnAllReadData } = props; + + const calculate = useCallback(() => { + if (returnAllReadData) { + return messages.reduce( + (acc, msg) => { + acc[msg.id] = channel.messageReceiptsTracker.deliveredForMessage({ + msgId: msg.id, + timestampMs: msg.created_at.getTime(), + }); + return acc; + }, + {} as Record, + ); + } + if (!lastOwnMessage) return {}; + return { + [lastOwnMessage.id]: channel.messageReceiptsTracker.deliveredForMessage({ + msgId: lastOwnMessage.id, + timestampMs: lastOwnMessage.created_at.getTime(), + }), + }; + }, [channel, lastOwnMessage, messages, returnAllReadData]); const [deliveredTo, setDeliveredTo] = useState>(calculate); diff --git a/src/components/MessageList/hooks/useLastOwnMessage.ts b/src/components/MessageList/hooks/useLastOwnMessage.ts new file mode 100644 index 000000000..94b8b7bb6 --- /dev/null +++ b/src/components/MessageList/hooks/useLastOwnMessage.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; +import { findReverse } from '../../../utils/findReverse'; +import type { LocalMessage } from 'stream-chat'; + +// fixme: we should be able to retrieve last own message quickly from the LLC. Should be done when refactoring the LLC Channel state to reactive. +export const useLastOwnMessage = ({ + messages, + ownUserId, +}: { + messages?: LocalMessage[]; + ownUserId?: string; +}) => + useMemo( + () => + messages && findReverse(messages, (msg) => (msg.user && msg.user.id) === ownUserId), + [messages, ownUserId], + ); diff --git a/src/components/MessageList/hooks/useLastReadData.ts b/src/components/MessageList/hooks/useLastReadData.ts index 85edfb479..c1a6636a0 100644 --- a/src/components/MessageList/hooks/useLastReadData.ts +++ b/src/components/MessageList/hooks/useLastReadData.ts @@ -5,25 +5,32 @@ type UseLastReadDataParams = { channel: Channel; messages: LocalMessage[]; returnAllReadData: boolean; + lastOwnMessage?: LocalMessage; }; export const useLastReadData = (props: UseLastReadDataParams) => { - const { channel, messages, returnAllReadData } = props; + const { channel, lastOwnMessage, messages, returnAllReadData } = props; - return useMemo( - () => - returnAllReadData - ? messages.reduce( - (acc, msg) => { - acc[msg.id] = channel.messageReceiptsTracker.readersForMessage({ - msgId: msg.id, - timestampMs: msg.created_at.getTime(), - }); - return acc; - }, - {} as Record, - ) - : channel.messageReceiptsTracker.groupUsersByLastReadMessage(), - [channel, messages, returnAllReadData], - ); + return useMemo(() => { + if (returnAllReadData) { + return messages.reduce( + (acc, msg) => { + acc[msg.id] = channel.messageReceiptsTracker.readersForMessage({ + msgId: msg.id, + timestampMs: msg.created_at.getTime(), + }); + return acc; + }, + {} as Record, + ); + } + + if (!lastOwnMessage) return {}; + return { + [lastOwnMessage.id]: channel.messageReceiptsTracker.readersForMessage({ + msgId: lastOwnMessage.id, + timestampMs: lastOwnMessage.created_at.getTime(), + }), + }; + }, [channel, lastOwnMessage, messages, returnAllReadData]); }; diff --git a/src/components/MessageList/renderMessages.tsx b/src/components/MessageList/renderMessages.tsx index 031b6eb2e..43e526e72 100644 --- a/src/components/MessageList/renderMessages.tsx +++ b/src/components/MessageList/renderMessages.tsx @@ -7,7 +7,7 @@ import { Message } from '../Message'; import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; import { EventComponent as DefaultMessageSystem } from '../EventComponent'; import { UnreadMessagesSeparator as DefaultUnreadMessagesSeparator } from './UnreadMessagesSeparator'; -import type { UserResponse } from 'stream-chat'; +import type { LocalMessage, UserResponse } from 'stream-chat'; import type { ComponentContextValue, CustomClasses } from '../../context'; import type { ChannelUnreadUiState } from '../../types'; @@ -25,6 +25,8 @@ export interface RenderMessagesOptions { * Props forwarded to the Message component. */ sharedMessageProps: SharedMessageProps; + /** Latest own message in currently displayed message set. */ + lastOwnMessage?: LocalMessage; /** * Current user's channel read state used to render components reflecting unread state. * It does not reflect the back-end state if a channel is marked read on mount. @@ -51,6 +53,7 @@ export function defaultRenderMessages({ channelUnreadUiState, components, customClasses, + lastOwnMessage, lastReceivedMessageId: lastReceivedId, messageGroupStyles, messages, @@ -132,6 +135,7 @@ export function defaultRenderMessages({ boolean; + /** Latest own message in currently displayed message set. */ + lastOwnMessage?: LocalMessage; /** Latest message id on current channel */ lastReceivedId?: string | null; /** DOMRect object for parent MessageList component */ @@ -139,6 +141,8 @@ export type MessageContextValue = { mentioned_users?: UserResponse[], options?: RenderTextOptions, ) => ReactNode; + /** Keep track of read receipts for each message sent by the user. When disabled, only the last own message delivery / read status is rendered. */ + returnAllReadData?: boolean; /** Comparator function to sort the list of reacted users * @deprecated use `reactionDetailsSort` instead */ diff --git a/src/utils/findReverse.ts b/src/utils/findReverse.ts new file mode 100644 index 000000000..b097894fe --- /dev/null +++ b/src/utils/findReverse.ts @@ -0,0 +1,13 @@ +// last own message should be tracked in the low-level client +export const findReverse = ( + items: T[], + matches: (items: T) => boolean, +): T | undefined => { + for (let i = items.length - 1; i > 0; i -= 1) { + if (matches(items[i])) { + return items[i]; + } + } + + return undefined; +};