diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 56a64bcaf9..3bf22eccdb 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -163,7 +163,9 @@ const App = () => { messageListImplementationStoredValue?.id as MessageListImplementationConfigItem['id'], ); setMessageListMode(messageListModeStoredValue?.mode as MessageListModeConfigItem['mode']); - setMessageListPruning(messageListPruningStoredValue?.value as MessageListPruningConfigItem['value']); + setMessageListPruning( + messageListPruningStoredValue?.value as MessageListPruningConfigItem['value'], + ); }; getMessageListConfig(); return () => { diff --git a/examples/SampleApp/index.js b/examples/SampleApp/index.js index e48125176c..80c27dd351 100644 --- a/examples/SampleApp/index.js +++ b/examples/SampleApp/index.js @@ -1,3 +1,4 @@ +import './src/utils/bootstrapBackgroundMessageHandler'; import 'react-native-gesture-handler'; import { AppRegistry } from 'react-native'; import { enableScreens } from 'react-native-screens'; diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index d2f38c4db0..0bf23feec8 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -3458,7 +3458,7 @@ SPEC CHECKSUMS: op-sqlite: a7e46cfdaebeef219fd0e939332967af9fe6d406 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f + RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: 300c5eb91114d4339b0bb39505d0f4824d7299b7 RCTRequired: e0446b01093475b7082fbeee5d1ef4ad1fe20ac4 RCTTypeSafety: cb974efcdc6695deedf7bf1eb942f2a0603a063f diff --git a/examples/SampleApp/src/hooks/useChatClient.ts b/examples/SampleApp/src/hooks/useChatClient.ts index 7e11e8235a..3db2874e1b 100644 --- a/examples/SampleApp/src/hooks/useChatClient.ts +++ b/examples/SampleApp/src/hooks/useChatClient.ts @@ -1,6 +1,10 @@ import { useEffect, useRef, useState } from 'react'; import { StreamChat, PushProvider } from 'stream-chat'; -import { getMessaging, AuthorizationStatus } from '@react-native-firebase/messaging'; +import { + getMessaging, + AuthorizationStatus, + FirebaseMessagingTypes, +} from '@react-native-firebase/messaging'; import notifee from '@notifee/react-native'; import { SqliteClient } from 'stream-chat-react-native'; import { USER_TOKENS, USERS } from '../ChatUsers'; @@ -11,6 +15,30 @@ import { PermissionsAndroid, Platform } from 'react-native'; const messaging = getMessaging(); +const displayNotification = async ( + remoteMessage: FirebaseMessagingTypes.RemoteMessage, + channelId: string, +) => { + const { stream, ...rest } = remoteMessage.data ?? {}; + const data = { + ...rest, + ...((stream as unknown as Record | undefined) ?? {}), // extract and merge stream object if present + }; + if (data.body && data.title) { + await notifee.displayNotification({ + android: { + channelId, + pressAction: { + id: 'default', + }, + }, + body: data.body as string, + title: data.title as string, + data, + }); + } +}; + // Request Push Notification permission from device. const requestNotificationPermission = async () => { const authStatus = await messaging.requestPermission(); @@ -94,34 +122,12 @@ export const useChatClient = () => { }); // show notifications when on foreground const unsubscribeForegroundMessageReceive = messaging.onMessage(async (remoteMessage) => { - const { stream, ...rest } = remoteMessage.data ?? {}; - const data = { - ...rest, - ...((stream as unknown as Record | undefined) ?? {}), // extract and merge stream object if present - }; const channelId = await notifee.createChannel({ id: 'foreground', name: 'Foreground Messages', }); - // create the android channel to send the notification to - // display the notification on foreground - const notification = remoteMessage.notification ?? {}; - const body = (data.body ?? notification.body) as string; - const title = (data.title ?? notification.title) as string; - - if (body && title) { - await notifee.displayNotification({ - android: { - channelId, - pressAction: { - id: 'default', - }, - }, - body, - title, - data, - }); - } + + await displayNotification(remoteMessage, channelId); }); unsubscribePushListenersRef.current = () => { diff --git a/examples/SampleApp/src/utils/bootstrapBackgroundMessageHandler.ts b/examples/SampleApp/src/utils/bootstrapBackgroundMessageHandler.ts new file mode 100644 index 0000000000..211c8e8316 --- /dev/null +++ b/examples/SampleApp/src/utils/bootstrapBackgroundMessageHandler.ts @@ -0,0 +1,71 @@ +import { LoginConfig } from '../types'; +import AsyncStore from './AsyncStore'; +import { + FirebaseMessagingTypes, + setBackgroundMessageHandler, +} from '@react-native-firebase/messaging'; +import { DeliveredMessageConfirmation, StreamChat } from 'stream-chat'; +import notifee from '@notifee/react-native'; +import { getMessaging } from '@react-native-firebase/messaging'; + +const messaging = getMessaging(); + +const displayNotification = async ( + remoteMessage: FirebaseMessagingTypes.RemoteMessage, + channelId: string, +) => { + const { stream, ...rest } = remoteMessage.data ?? {}; + const data = { + ...rest, + ...((stream as unknown as Record | undefined) ?? {}), // extract and merge stream object if present + }; + if (data.body && data.title) { + await notifee.displayNotification({ + android: { + channelId, + pressAction: { + id: 'default', + }, + }, + body: data.body as string, + title: data.title as string, + data, + }); + } +}; + +setBackgroundMessageHandler(messaging, async (remoteMessage) => { + try { + const loginConfig = await AsyncStore.getItem( + '@stream-rn-sampleapp-login-config', + null, + ); + if (!loginConfig) { + return; + } + const chatClient = StreamChat.getInstance(loginConfig.apiKey); + await chatClient._setToken({ id: loginConfig.userId }, loginConfig.userToken); + + const notification = remoteMessage.data; + + const deliverMessageConfirmation = [ + { + cid: notification?.cid, + id: notification?.id, + }, + ]; + + await chatClient?.markChannelsDelivered({ + latest_delivered_messages: deliverMessageConfirmation as DeliveredMessageConfirmation[], + }); + // create the android channel to send the notification to + const channelId = await notifee.createChannel({ + id: 'chat-messages', + name: 'Chat Messages', + }); + // display the notification + await displayNotification(remoteMessage, channelId); + } catch (error) { + console.error(error); + } +}); diff --git a/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx b/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx index 7857068d49..bb98581f67 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx @@ -3,7 +3,8 @@ import { StyleSheet, Text, View } from 'react-native'; import { ChannelPreviewProps } from './ChannelPreview'; import type { ChannelPreviewMessengerPropsWithContext } from './ChannelPreviewMessenger'; -import { MessageReadStatus } from './hooks/useLatestMessagePreview'; + +import { MessageDeliveryStatus } from './hooks/useMessageDeliveryStatus'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; @@ -38,7 +39,7 @@ export const ChannelPreviewStatus = (props: ChannelPreviewStatusProps) => { }, } = useTheme(); - const created_at = latestMessagePreview.messageObject?.created_at; + const created_at = latestMessagePreview?.created_at; const latestMessageDate = created_at ? new Date(created_at) : new Date(); const formattedDate = useMemo( @@ -55,9 +56,11 @@ export const ChannelPreviewStatus = (props: ChannelPreviewStatusProps) => { return ( - {status === MessageReadStatus.READ ? ( + {status === MessageDeliveryStatus.READ ? ( - ) : status === MessageReadStatus.UNREAD ? ( + ) : status === MessageDeliveryStatus.DELIVERED ? ( + + ) : status === MessageDeliveryStatus.SENT ? ( ) : null} diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts index a902844f59..8222e746f7 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts @@ -1,7 +1,7 @@ import { type SetStateAction, useEffect, useMemo, useState } from 'react'; import throttle from 'lodash/throttle'; -import type { Channel, ChannelState, Event, MessageResponse, StreamChat } from 'stream-chat'; +import type { Channel, Event, LocalMessage, MessageResponse, StreamChat } from 'stream-chat'; import { useIsChannelMuted } from './useIsChannelMuted'; @@ -16,7 +16,7 @@ const setLastMessageThrottleOptions = { leading: true, trailing: true }; const refreshUnreadCountThrottleTimeout = 400; const refreshUnreadCountThrottleOptions = setLastMessageThrottleOptions; -type LastMessageType = ReturnType | MessageResponse; +type LastMessageType = LocalMessage | MessageResponse; export const useChannelPreviewData = ( channel: Channel, @@ -172,7 +172,11 @@ export const useChannelPreviewData = ( return () => listeners.forEach((l) => l.unsubscribe()); }, [channel, refreshUnreadCount, forceUpdate, channelListForceUpdate, setLastMessage]); - const latestMessagePreview = useLatestMessagePreview(channel, forceUpdate, lastMessage); + const latestMessagePreview = useLatestMessagePreview( + channel, + forceUpdate, + lastMessage as LocalMessage, + ); return { latestMessagePreview, muted, unread }; }; diff --git a/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts b/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts index 548b1f6094..c731344c7b 100644 --- a/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts +++ b/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts @@ -4,9 +4,8 @@ import { TFunction } from 'i18next'; import type { AttachmentManagerState, Channel, - ChannelState, DraftMessage, - MessageResponse, + LocalMessage, PollState, PollVote, StreamChat, @@ -14,6 +13,8 @@ import type { UserResponse, } from 'stream-chat'; +import { MessageDeliveryStatus, useMessageDeliveryStatus } from './useMessageDeliveryStatus'; + import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; @@ -22,16 +23,14 @@ import { useTranslatedMessage } from '../../../hooks/useTranslatedMessage'; import { stringifyMessage } from '../../../utils/utils'; -type LatestMessage = ReturnType | MessageResponse; - export type LatestMessagePreview = { - messageObject: LatestMessage | undefined; + messageObject: LocalMessage | undefined; previews: { bold: boolean; text: string; draft?: boolean; }[]; - status: number; + status?: MessageDeliveryStatus; created_at?: string | Date; }; @@ -48,7 +47,7 @@ const selector = (nextValue: PollState): LatestMessagePreviewSelectorReturnType }); const getMessageSenderName = ( - message: LatestMessage | undefined, + message: LocalMessage | undefined, currentUserId: string | undefined, t: (key: string) => string, membersLength: number, @@ -87,7 +86,7 @@ const getLatestMessageDisplayText = ( channel: Channel, client: StreamChat, draftMessage: DraftMessage | undefined, - message: LatestMessage | undefined, + message: LocalMessage | undefined, t: (key: string) => string, pollState: LatestMessagePreviewSelectorReturnType | undefined, ) => { @@ -194,47 +193,23 @@ export enum MessageReadStatus { NOT_SENT_BY_CURRENT_USER = 0, UNREAD = 1, READ = 2, + DELIVERED = 3, } -const getLatestMessageReadStatus = ( - channel: Channel, - client: StreamChat, - message: LatestMessage | undefined, - readEvents: boolean, -): MessageReadStatus => { - const currentUserId = client.userID; - if (!message || currentUserId !== message.user?.id || readEvents === false) { - return MessageReadStatus.NOT_SENT_BY_CURRENT_USER; - } - - const readList = { ...channel.state.read }; - if (currentUserId) { - delete readList[currentUserId]; - } - - const messageUpdatedAt = message.updated_at - ? typeof message.updated_at === 'string' - ? new Date(message.updated_at) - : message.updated_at - : undefined; - - return Object.values(readList).some( - ({ last_read }) => messageUpdatedAt && messageUpdatedAt < last_read, - ) - ? MessageReadStatus.READ - : MessageReadStatus.UNREAD; -}; - const getLatestMessagePreview = (params: { channel: Channel; client: StreamChat; draftMessage?: DraftMessage; pollState: LatestMessagePreviewSelectorReturnType | undefined; - readEvents: boolean; + /** + * @deprecated This parameter is no longer used and will be removed in the next major release. + */ + readEvents?: boolean; + lastMessage?: LocalMessage; + status?: MessageDeliveryStatus; t: TFunction; - lastMessage?: ReturnType | MessageResponse; }) => { - const { channel, client, draftMessage, lastMessage, pollState, readEvents, t } = params; + const { channel, client, draftMessage, lastMessage, pollState, status, t } = params; const messages = channel.state.messages; @@ -248,7 +223,7 @@ const getLatestMessagePreview = (params: { text: t('Nothing yet...'), }, ], - status: MessageReadStatus.NOT_SENT_BY_CURRENT_USER, + status: MessageDeliveryStatus.NOT_SENT_BY_CURRENT_USER, }; } @@ -260,7 +235,7 @@ const getLatestMessagePreview = (params: { created_at: message?.created_at, messageObject: message, previews: getLatestMessageDisplayText(channel, client, draftMessage, message, t, pollState), - status: getLatestMessageReadStatus(channel, client, message, readEvents), + status, }; }; @@ -275,6 +250,9 @@ const stateSelector = (state: AttachmentManagerState) => ({ /** * Hook to set the display preview for latest message on channel. * + * FIXME: This hook is very poorly implemented and needs to be refactored with granular hooks + * to avoid unnecessary re-renders and to make the code more readable. + * * @param {*} channel Channel object * * @returns {object} latest message preview e.g.. { text: 'this was last message ...', created_at: '11/12/2020', messageObject: { originalMessageObject } } @@ -282,7 +260,7 @@ const stateSelector = (state: AttachmentManagerState) => ({ export const useLatestMessagePreview = ( channel: Channel, forceUpdate: number, - lastMessage?: ReturnType | MessageResponse, + lastMessage?: LocalMessage, ) => { const { client } = useChatContext(); const { t } = useTranslationContext(); @@ -328,7 +306,11 @@ export const useLatestMessagePreview = ( return read_events; }, [channelConfigExists, channel]); - const readStatus = getLatestMessageReadStatus(channel, client, translatedLastMessage, readEvents); + const { status } = useMessageDeliveryStatus({ + channel, + isReadEventsEnabled: readEvents, + lastMessage: lastMessage as LocalMessage, + }); const pollId = lastMessage?.poll_id ?? ''; const poll = client.polls.fromState(pollId); @@ -343,16 +325,15 @@ export const useLatestMessagePreview = ( draftMessage, lastMessage: translatedLastMessage, pollState, - readEvents, + status, t, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ channelLastMessageString, + status, draftMessage, forceUpdate, - readEvents, - readStatus, latestVotesByOption, createdBy, name, diff --git a/package/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts b/package/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts new file mode 100644 index 0000000000..dd89b9a665 --- /dev/null +++ b/package/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; + +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; + +export enum MessageDeliveryStatus { + NOT_SENT_BY_CURRENT_USER = 'not_sent_by_current_user', + DELIVERED = 'delivered', + READ = 'read', + SENT = 'sent', +} + +type MessageDeliveryStatusProps = { + channel: Channel; + lastMessage: LocalMessage; + isReadEventsEnabled: boolean; +}; + +export const useMessageDeliveryStatus = ({ + channel, + lastMessage, + isReadEventsEnabled = true, +}: MessageDeliveryStatusProps) => { + const { client } = useChatContext(); + const [status, setStatus] = useState(undefined); + + const isOwnMessage = useCallback( + (message: LocalMessage | MessageResponse) => + client.user && message && message.user?.id === client.user.id, + [client], + ); + + useEffect(() => { + if (!lastMessage) { + setStatus(undefined); + } + + if (!isReadEventsEnabled) { + setStatus(MessageDeliveryStatus.NOT_SENT_BY_CURRENT_USER); + return; + } + + if (!lastMessage?.created_at || !isOwnMessage(lastMessage)) { + return; + } + + const msgRef = { + msgId: lastMessage.id, + timestampMs: new Date(lastMessage.created_at).getTime(), + }; + + const readerOfMessage = channel.messageReceiptsTracker.readersForMessage(msgRef); + const deliveredForMessage = channel.messageReceiptsTracker.deliveredForMessage(msgRef); + + setStatus( + readerOfMessage.length > 1 || + (readerOfMessage.length === 1 && readerOfMessage[0].id !== client.user?.id) + ? MessageDeliveryStatus.READ + : deliveredForMessage.length > 1 || + (deliveredForMessage.length === 1 && deliveredForMessage[0].id !== client.user?.id) + ? MessageDeliveryStatus.DELIVERED + : MessageDeliveryStatus.SENT, + ); + }, [channel, client.user?.id, isOwnMessage, isReadEventsEnabled, lastMessage]); + + useEffect(() => { + const handleMessageNew = (event: Event) => { + // the last message is not mine, so do not show the delivery status + if (event.message && !isOwnMessage(event.message)) { + return setStatus(undefined); + } + return setStatus(MessageDeliveryStatus.SENT); + }; + const { unsubscribe } = channel.on('message.new', handleMessageNew); + return unsubscribe; + }, [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 + ) + setStatus(MessageDeliveryStatus.DELIVERED); + }; + + const handleMarkRead = (event: Event) => { + if (event.user?.id !== client.user?.id) setStatus(MessageDeliveryStatus.READ); + }; + + const listeners = [ + channel.on('message.delivered', handleMessageDelivered), + channel.on('message.read', handleMarkRead), + ]; + + return () => listeners.forEach((l) => l.unsubscribe()); + }, [channel, client, isOwnMessage, lastMessage]); + + return { status }; +}; diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 1f10124150..d7018490de 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -6,6 +6,8 @@ import type { Attachment, LocalMessage, UserResponse } from 'stream-chat'; import { useCreateMessageContext } from './hooks/useCreateMessageContext'; import { useMessageActionHandlers } from './hooks/useMessageActionHandlers'; import { useMessageActions } from './hooks/useMessageActions'; +import { useMessageDeliveredData } from './hooks/useMessageDeliveryData'; +import { useMessageReadData } from './hooks/useMessageReadData'; import { useProcessReactions } from './hooks/useProcessReactions'; import { messageActions as defaultMessageActions } from './utils/messageActions'; @@ -46,7 +48,6 @@ import { MessageStatusTypes, } from '../../utils/utils'; import type { Thumbnail } from '../Attachment/utils/buildGallery/types'; -import { getReadState } from '../MessageList/utils/getReadState'; export type TouchableEmitter = | 'fileAttachment' @@ -142,10 +143,18 @@ export type MessagePropsWithContext = Pick< Partial< Omit< MessageContextValue, - 'groupStyles' | 'handleReaction' | 'message' | 'isMessageAIGenerated' | 'readBy' + | 'groupStyles' + | 'handleReaction' + | 'message' + | 'isMessageAIGenerated' + | 'deliveredToCount' + | 'readBy' > > & - Pick & + Pick< + MessageContextValue, + 'groupStyles' | 'message' | 'isMessageAIGenerated' | 'readBy' | 'deliveredToCount' + > & Pick< MessagesContextValue, | 'sendReaction' @@ -219,6 +228,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { chatContext, deleteMessage: deleteMessageFromContext, deleteReaction, + deliveredToCount, dismissKeyboard, dismissKeyboardOnMessageTouch, enableLongPress = true, @@ -617,6 +627,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { actionsEnabled, alignment, channel, + deliveredToCount, dismissOverlay, files: attachments.files, goToMessage, @@ -758,6 +769,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWithContext) => { const { chatContext: { mutedUsers: prevMutedUsers }, + deliveredToCount: prevDeliveredBy, goToMessage: prevGoToMessage, groupStyles: prevGroupStyles, isAttachmentEqual, @@ -772,6 +784,7 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit } = prevProps; const { chatContext: { mutedUsers: nextMutedUsers }, + deliveredToCount: nextDeliveredBy, goToMessage: nextGoToMessage, groupStyles: nextGroupStyles, isTargetedMessage: nextIsTargetedMessage, @@ -784,6 +797,11 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit t: nextT, } = nextProps; + const deliveredByEqual = prevDeliveredBy === nextDeliveredBy; + if (!deliveredByEqual) { + return false; + } + const readByEqual = prevReadBy === nextReadBy; if (!readByEqual) { return false; @@ -948,13 +966,14 @@ export type MessageProps = Partial< */ export const Message = (props: MessageProps) => { const { message } = props; - const { channel, enforceUniqueReaction, members, read } = useChannelContext(); + const { channel, enforceUniqueReaction, members } = useChannelContext(); const chatContext = useChatContext(); const { dismissKeyboard } = useKeyboardContext(); const messagesContext = useMessagesContext(); const { openThread } = useThreadContext(); const { t } = useTranslationContext(); - const readBy = useMemo(() => getReadState(message, read), [message, read]); + const readBy = useMessageReadData({ message }); + const deliveredToCount = useMessageDeliveredData({ message }); const { setQuotedMessage, setEditingState } = useMessageComposerAPIContext(); return ( @@ -963,6 +982,7 @@ export const Message = (props: MessageProps) => { {...{ channel, chatContext, + deliveredToCount, dismissKeyboard, enforceUniqueReaction, members, diff --git a/package/src/components/Message/MessageSimple/MessageStatus.tsx b/package/src/components/Message/MessageSimple/MessageStatus.tsx index 1552c775a5..a221b06ede 100644 --- a/package/src/components/Message/MessageSimple/MessageStatus.tsx +++ b/package/src/components/Message/MessageSimple/MessageStatus.tsx @@ -1,7 +1,10 @@ import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { + ChannelContextValue, + useChannelContext, +} from '../../../contexts/channelContext/ChannelContext'; import { MessageContextValue, useMessageContext, @@ -12,14 +15,11 @@ import { CheckAll } from '../../../icons/CheckAll'; import { Time } from '../../../icons/Time'; import { MessageStatusTypes } from '../../../utils/utils'; -export type MessageStatusPropsWithContext = Pick< - MessageContextValue, - 'message' | 'readBy' | 'threadList' ->; +export type MessageStatusPropsWithContext = Pick & + Pick; const MessageStatusWithContext = (props: MessageStatusPropsWithContext) => { - const { channel } = useChannelContext(); - const { message, readBy, threadList } = props; + const { channel, deliveredToCount, message, readBy, threadList } = props; const { theme: { @@ -30,68 +30,72 @@ const MessageStatusWithContext = (props: MessageStatusPropsWithContext) => { }, } = useTheme(); - if (message.status === MessageStatusTypes.SENDING) { - return ( - - - ); - } - - if (threadList || message.status === MessageStatusTypes.FAILED) { + if (threadList || message.status === MessageStatusTypes.FAILED || message.type === 'error') { return null; } - if (readBy) { - const members = channel?.state.members; - const otherMembers = Object.values(members).filter( - (member) => member.user_id !== message.user?.id, - ); - const hasOtherMembersGreaterThanOne = otherMembers.length > 1; - const hasReadByGreaterThanOne = typeof readBy === 'number' && readBy > 1; - const shouldDisplayReadByCount = hasOtherMembersGreaterThanOne && hasReadByGreaterThanOne; - const countOfReadBy = - typeof readBy === 'number' && hasOtherMembersGreaterThanOne ? readBy - 1 : 0; - const showDoubleCheck = hasReadByGreaterThanOne || readBy === true; - - return ( - - {shouldDisplayReadByCount ? ( - - {countOfReadBy} - - ) : null} - {message.type !== 'error' ? ( - showDoubleCheck ? ( - - ) : ( - - ) - ) : null} - - ); - } - - if (message.status === MessageStatusTypes.RECEIVED && message.type !== 'ephemeral') { - return ( - - - - ); - } - - return null; + const hasReadByGreaterThanOne = typeof readBy === 'number' && readBy > 1; + + // Variables to determine the status of the message + const read = hasReadByGreaterThanOne || readBy === true; + const delivered = deliveredToCount > 1; + const sending = message.status === MessageStatusTypes.SENDING; + const sent = + message.status === MessageStatusTypes.RECEIVED && + !delivered && + !read && + message.type !== 'ephemeral'; + + const members = channel?.state.members; + const isGroupChannel = Object.keys(members).length > 2; + + const shouldDisplayReadByCount = isGroupChannel && hasReadByGreaterThanOne; + const countOfReadBy = typeof readBy === 'number' && shouldDisplayReadByCount ? readBy - 1 : 0; + + return ( + + {shouldDisplayReadByCount ? ( + + {countOfReadBy} + + ) : null} + {read ? ( + + ) : delivered ? ( + + ) : sending ? ( + + ); }; const areEqual = ( prevProps: MessageStatusPropsWithContext, nextProps: MessageStatusPropsWithContext, ) => { - const { message: prevMessage, readBy: prevReadBy, threadList: prevThreadList } = prevProps; - const { message: nextMessage, readBy: nextReadBy, threadList: nextThreadList } = nextProps; + const { + deliveredToCount: prevDeliveredBy, + message: prevMessage, + readBy: prevReadBy, + threadList: prevThreadList, + } = prevProps; + const { + deliveredToCount: nextDeliveredBy, + message: nextMessage, + readBy: nextReadBy, + threadList: nextThreadList, + } = nextProps; + + const deliveredByEqual = prevDeliveredBy === nextDeliveredBy; + if (!deliveredByEqual) { + return false; + } const threadListEqual = prevThreadList === nextThreadList; if (!threadListEqual) { @@ -120,9 +124,15 @@ const MemoizedMessageStatus = React.memo( export type MessageStatusProps = Partial; export const MessageStatus = (props: MessageStatusProps) => { - const { message, readBy, threadList } = useMessageContext(); - - return ; + const { channel } = useChannelContext(); + const { deliveredToCount, message, readBy, threadList } = useMessageContext(); + + return ( + + ); }; MessageStatus.displayName = 'MessageStatus{messageSimple{status}}'; diff --git a/package/src/components/Message/MessageSimple/MessageTextContainer.tsx b/package/src/components/Message/MessageSimple/MessageTextContainer.tsx index 28b0ac70ca..6f5c892713 100644 --- a/package/src/components/Message/MessageSimple/MessageTextContainer.tsx +++ b/package/src/components/Message/MessageSimple/MessageTextContainer.tsx @@ -71,7 +71,7 @@ const MessageTextContainerWithContext = (props: MessageTextContainerPropsWithCon }, } = theme; - const translatedMessage = useTranslatedMessage(message) as LocalMessage; + const translatedMessage = useTranslatedMessage(message); if (!message.text) { return null; @@ -94,7 +94,7 @@ const MessageTextContainerWithContext = (props: MessageTextContainerPropsWithCon ...markdownStyles, ...(onlyEmojis ? onlyEmojiMarkdown : {}), }, - message: translatedMessage, + message: translatedMessage as LocalMessage, messageOverlay, messageTextNumberOfLines, onLongPress, diff --git a/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js b/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js index 85c4ab790f..36958bfe38 100644 --- a/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js +++ b/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js @@ -42,6 +42,8 @@ describe('MessageStatus', () => { chatClient = await getTestClientWithUser(user1); useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); channel = chatClient.channel('messaging', mockedChannel.id); + + channel.state.members = Object.fromEntries(members.map((member) => [member.user_id, member])); }); afterEach(cleanup); @@ -56,33 +58,19 @@ describe('MessageStatus', () => { , ); - it('should render message status with delivered container', async () => { - const user = generateUser(); - const message = generateMessage({ user }); - - const { getByTestId } = renderMessageStatus({ - lastReceivedId: message.id, - message: { ...message, status: 'received' }, - }); - - await waitFor(() => { - expect(getByTestId('delivered-container')).toBeTruthy(); - }); - }); - it('should render message status with read by container', async () => { const user = generateUser(); const message = generateMessage({ user }); const readBy = 2; - const { getByTestId, getByText, rerender, toJSON } = renderMessageStatus({ - lastReceivedId: message.id, + const { getByLabelText, getByText, rerender, toJSON } = renderMessageStatus({ + deliveredToCount: 2, message, readBy, }); await waitFor(() => { - expect(getByTestId('read-by-container')).toBeTruthy(); + expect(getByLabelText('Read by count')).toBeTruthy(); expect(getByText((readBy - 1).toString())).toBeTruthy(); }); @@ -105,21 +93,29 @@ describe('MessageStatus', () => { await waitFor(() => { expect(toJSON()).toMatchSnapshot(); - expect(getByTestId('read-by-container')).toBeTruthy(); + expect(getByLabelText('Read by count')).toBeTruthy(); expect(getByText((readBy - 1).toString())).toBeTruthy(); }); }); - it('should render message status with sending container', async () => { - const user = generateUser(); - const message = generateMessage({ user }); - - const { getByTestId } = renderMessageStatus({ - message: { ...message, status: 'sending' }, - }); - - await waitFor(() => { - expect(getByTestId('sending-container')).toBeTruthy(); - }); - }); + it.each([ + [1, 1, 'sending', 'Sending'], + [2, 2, 'received', 'Read'], + [1, 1, 'received', 'Sent'], + [2, 1, 'received', 'Delivered'], + ])( + 'should render message status with %s container when deliveredToCount is %s and readBy is %s and status is %s', + async (deliveredToCount, readBy, status, accessibilityLabel) => { + const user = generateUser(); + const message = generateMessage({ user }); + const { getByLabelText } = renderMessageStatus({ + deliveredToCount, + message: { ...message, status }, + readBy, + }); + await waitFor(() => { + expect(getByLabelText(accessibilityLabel)).toBeTruthy(); + }); + }, + ); }); diff --git a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap index 164ee4ead7..605e11641c 100644 --- a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap +++ b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`MessageStatus should render message status with read by container 1`] = ` 1 { + const { channel } = useChannelContext(); + const { client } = useChatContext(); + const calculate = useCallback(() => { + if (!message.created_at) { + return 0; + } + const messageRef = { + msgId: message.id, + timestampMs: new Date(message.created_at).getTime(), + }; + return channel.messageReceiptsTracker.deliveredForMessage(messageRef).length; + }, [channel, message]); + + const [deliveredToCount, setDeliveredToCount] = useState(calculate()); + + useEffect(() => { + const { unsubscribe } = channel.on('message.delivered', (event: Event) => { + /** + * An optimization to only re-calculate if the event is received by a different user. + */ + if (event.user?.id !== client.user?.id) { + setDeliveredToCount(calculate()); + } + }); + return unsubscribe; + }, [channel, calculate, client.user?.id]); + + return deliveredToCount; +}; diff --git a/package/src/components/Message/hooks/useMessageReadData.ts b/package/src/components/Message/hooks/useMessageReadData.ts new file mode 100644 index 0000000000..8e0086a232 --- /dev/null +++ b/package/src/components/Message/hooks/useMessageReadData.ts @@ -0,0 +1,38 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { Event, LocalMessage } from 'stream-chat'; + +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; + +export const useMessageReadData = ({ message }: { message: LocalMessage }) => { + const { channel } = useChannelContext(); + const { client } = useChatContext(); + const calculate = useCallback(() => { + if (!message.created_at) { + return 0; + } + const messageRef = { + msgId: message.id, + timestampMs: new Date(message.created_at).getTime(), + }; + + return channel.messageReceiptsTracker.readersForMessage(messageRef).length; + }, [channel, message]); + + const [readBy, setReadBy] = useState(calculate()); + + useEffect(() => { + const { unsubscribe } = channel.on('message.read', (event: Event) => { + /** + * An optimization to only re-calculate if the event is received by a different user. + */ + if (event.user?.id !== client.user?.id) { + setReadBy(calculate()); + } + }); + return unsubscribe; + }, [channel, calculate, client.user?.id]); + + return readBy; +}; diff --git a/package/src/components/MessageList/utils/getReadState.ts b/package/src/components/MessageList/utils/getReadState.ts deleted file mode 100644 index 6b7821ca48..0000000000 --- a/package/src/components/MessageList/utils/getReadState.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ChannelState, LocalMessage } from 'stream-chat'; - -/** - * Get the number of users who have read the message - * @param message - The message to get the read state for - * @param read - The read state of the channel - * @returns The number of users who have read the message - */ -export const getReadState = (message: LocalMessage, read?: ChannelState['read']) => { - if (!read) { - return 0; - } - - const readState = Object.values(read).reduce((acc, readState) => { - if (!readState.last_read) { - return acc; - } - - if (message.created_at && message.created_at < readState.last_read) { - return acc + 1; - } - - return acc; - }, 0); - - return readState; -}; diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 094c1599ce..108f0471c8 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -157,7 +157,8 @@ export * from './MessageList/TypingIndicatorContainer'; export * from './MessageList/utils/getDateSeparators'; export * from './MessageList/utils/getGroupStyles'; export * from './MessageList/utils/getLastReceivedMessage'; -export * from './MessageList/utils/getReadState'; +export * from './Message/hooks/useMessageDeliveryData'; +export * from './Message/hooks/useMessageReadData'; export * from './MessageMenu/MessageActionList'; export * from './MessageMenu/MessageActionListItem'; diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index 824e217a40..70c44865b3 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -88,8 +88,10 @@ export type MessageContextValue = { /** The images attached to a message */ otherAttachments: Attachment[]; reactions: ReactionSummary[]; - /** Whether or not the message has been read by the current user */ + /** Read count of the message */ readBy: number | boolean; + /** Delivery count of the message */ + deliveredToCount: number; /** React set state function to set the state of `isEditedMessageOpen` */ setIsEditedMessageOpen: React.Dispatch>; /** diff --git a/package/src/hooks/useTranslatedMessage.ts b/package/src/hooks/useTranslatedMessage.ts index 628c6ef99a..a0684516a5 100644 --- a/package/src/hooks/useTranslatedMessage.ts +++ b/package/src/hooks/useTranslatedMessage.ts @@ -1,10 +1,10 @@ -import type { LocalMessage, MessageResponse, TranslationLanguages } from 'stream-chat'; +import type { LocalMessage, TranslationLanguages } from 'stream-chat'; import { useTranslationContext } from '../contexts/translationContext/TranslationContext'; type TranslationKey = `${TranslationLanguages}_text`; -export const useTranslatedMessage = (message?: MessageResponse | LocalMessage) => { +export const useTranslatedMessage = (message?: LocalMessage) => { const { userLanguage } = useTranslationContext(); const translationKey: TranslationKey = `${userLanguage}_text`; diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index 74380960c6..103eaa25e0 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -28,7 +28,7 @@ import type { PreparedBatchQueries, PreparedQueries, Scalar, Table } from './typ * This way usage @op-engineering/op-sqlite package is scoped to a single class/file. */ export class SqliteClient { - static dbVersion = 13; + static dbVersion = 14; static dbName = DB_NAME; static dbLocation = DB_LOCATION; diff --git a/package/src/store/mappers/mapReadToStorable.ts b/package/src/store/mappers/mapReadToStorable.ts index 42e6f01bbd..b6f3b8007d 100644 --- a/package/src/store/mappers/mapReadToStorable.ts +++ b/package/src/store/mappers/mapReadToStorable.ts @@ -11,10 +11,19 @@ export const mapReadToStorable = ({ cid: string; read: ReadResponse; }): TableRow<'reads'> => { - const { last_read, unread_messages, user, last_read_message_id } = read; + const { + last_read, + unread_messages, + user, + last_read_message_id, + last_delivered_at, + last_delivered_message_id, + } = read; return { cid, + lastDeliveredAt: mapDateTimeToStorable(last_delivered_at), + lastDeliveredMessageId: last_delivered_message_id, lastRead: mapDateTimeToStorable(last_read), lastReadMessageId: last_read_message_id, unreadMessages: unread_messages, diff --git a/package/src/store/mappers/mapStorableToRead.ts b/package/src/store/mappers/mapStorableToRead.ts index 17c8fe1ddc..b758151bc3 100644 --- a/package/src/store/mappers/mapStorableToRead.ts +++ b/package/src/store/mappers/mapStorableToRead.ts @@ -5,9 +5,18 @@ import { mapStorableToUser } from './mapStorableToUser'; import type { TableRowJoinedUser } from '../types'; export const mapStorableToRead = (row: TableRowJoinedUser<'reads'>): ReadResponse => { - const { lastRead, unreadMessages, user, lastReadMessageId } = row; + const { + lastRead, + unreadMessages, + user, + lastReadMessageId, + lastDeliveredAt, + lastDeliveredMessageId, + } = row; return { + last_delivered_at: lastDeliveredAt, + last_delivered_message_id: lastDeliveredMessageId, last_read: lastRead, last_read_message_id: lastReadMessageId, unread_messages: unreadMessages, diff --git a/package/src/store/schema.ts b/package/src/store/schema.ts index 50ec7ee2aa..6245c402ff 100644 --- a/package/src/store/schema.ts +++ b/package/src/store/schema.ts @@ -265,6 +265,8 @@ export const tables: Tables = { reads: { columns: { cid: 'TEXT NOT NULL', + lastDeliveredAt: 'TEXT', + lastDeliveredMessageId: 'TEXT', lastRead: 'TEXT NOT NULL', lastReadMessageId: 'TEXT', unreadMessages: 'INTEGER DEFAULT 0', @@ -466,6 +468,8 @@ export type Schema = { lastReadMessageId?: string; unreadMessages?: number; userId?: string; + lastDeliveredAt?: string; + lastDeliveredMessageId?: string; }; reminders: { channelCid: string; diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index 928893f9b6..120056db43 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -41,6 +41,7 @@ export const ProgressIndicatorTypes: { }); export const MessageStatusTypes = { + DELIVERED: 'delivered', FAILED: 'failed', RECEIVED: 'received', SENDING: 'sending',