UNPKG

stream-chat

Version:

JS SDK for the Stream Chat API

1,114 lines (1,022 loc) 38.7 kB
import type { Channel } from './channel'; import type { ChannelMemberResponse, Event, LocalMessage, MessageResponse, MessageResponseBase, MessageSet, MessageSetType, PendingMessageResponse, ReactionResponse, UserResponse, } from './types'; import { deleteUserMessages as _deleteUserMessages, addToMessageList, formatMessage, } from './utils'; import { DEFAULT_MESSAGE_SET_PAGINATION } from './constants'; type ChannelReadStatus = Record< string, { last_read: Date; unread_messages: number; user: UserResponse; first_unread_message_id?: string; last_read_message_id?: string; last_delivered_at?: Date; last_delivered_message_id?: string; } >; const messageSetBounds = ( a: LocalMessage[] | MessageResponse[], b: LocalMessage[] | MessageResponse[], ) => ({ newestMessageA: new Date(a[0]?.created_at ?? 0), oldestMessageA: new Date(a.slice(-1)[0]?.created_at ?? 0), newestMessageB: new Date(b[0]?.created_at ?? 0), oldestMessageB: new Date(b.slice(-1)[0]?.created_at ?? 0), }); const aContainsOrEqualsB = (a: LocalMessage[], b: LocalMessage[]) => { const { newestMessageA, newestMessageB, oldestMessageA, oldestMessageB } = messageSetBounds(a, b); return newestMessageA >= newestMessageB && oldestMessageB >= oldestMessageA; }; const aOverlapsB = (a: LocalMessage[], b: LocalMessage[]) => { const { newestMessageA, newestMessageB, oldestMessageA, oldestMessageB } = messageSetBounds(a, b); return ( oldestMessageA < oldestMessageB && oldestMessageB < newestMessageA && newestMessageA < newestMessageB ); }; const messageSetsOverlapByTimestamp = (a: LocalMessage[], b: LocalMessage[]) => aContainsOrEqualsB(a, b) || aContainsOrEqualsB(b, a) || aOverlapsB(a, b) || aOverlapsB(b, a); /** * ChannelState - A container class for the channel state. */ export class ChannelState { _channel: Channel; watcher_count: number; typing: Record<string, Event>; read: ChannelReadStatus; pinnedMessages: Array<ReturnType<ChannelState['formatMessage']>>; pending_messages: Array<PendingMessageResponse>; threads: Record<string, Array<ReturnType<ChannelState['formatMessage']>>>; mutedUsers: Array<UserResponse>; watchers: Record<string, UserResponse>; members: Record<string, ChannelMemberResponse>; unreadCount: number; membership: ChannelMemberResponse; last_message_at: Date | null; /** * Flag which indicates if channel state contain latest/recent messages or no. * This flag should be managed by UI sdks using a setter - setIsUpToDate. * When false, any new message (received by websocket event - message.new) will not * be pushed on to message list. */ isUpToDate: boolean; /** * Disjoint lists of messages * Users can jump in the message list (with searching) and this can result in disjoint lists of messages * The state manages these lists and merges them when lists overlap * The messages array contains the currently active set */ messageSets: MessageSet[] = []; constructor(channel: Channel) { this._channel = channel; this.watcher_count = 0; this.typing = {}; this.read = {}; this.initMessages(); this.pinnedMessages = []; this.pending_messages = []; this.threads = {}; // a list of users to hide messages from this.mutedUsers = []; this.watchers = {}; this.members = {}; this.membership = {}; this.unreadCount = 0; /** * Flag which indicates if channel state contain latest/recent messages or no. * This flag should be managed by UI sdks using a setter - setIsUpToDate. * When false, any new message (received by websocket event - message.new) will not * be pushed on to message list. */ this.isUpToDate = true; this.last_message_at = channel?.state?.last_message_at != null ? new Date(channel.state.last_message_at) : null; } get messages() { return this.messageSets.find((s) => s.isCurrent)?.messages || []; } set messages(messages: Array<ReturnType<ChannelState['formatMessage']>>) { const index = this.messageSets.findIndex((s) => s.isCurrent); this.messageSets[index].messages = messages; } /** * The list of latest messages * The messages array not always contains the latest messages (for example if a user searched for an earlier message, that is in a different message set) */ get latestMessages() { return this.messageSets.find((s) => s.isLatest)?.messages || []; } set latestMessages(messages: Array<ReturnType<ChannelState['formatMessage']>>) { const index = this.messageSets.findIndex((s) => s.isLatest); this.messageSets[index].messages = messages; } get messagePagination() { return ( this.messageSets.find((s) => s.isCurrent)?.pagination || DEFAULT_MESSAGE_SET_PAGINATION ); } pruneOldest(maxMessages: number) { const currentIndex = this.messageSets.findIndex((s) => s.isCurrent); if (this.messageSets[currentIndex].isLatest) { const newMessages = this.messageSets[currentIndex].messages; this.messageSets[currentIndex].messages = newMessages.slice(-maxMessages); this.messageSets[currentIndex].pagination.hasPrev = true; } } /** * addMessageSorted - Add a message to the state * * @param {MessageResponse} newMessage A new message * @param {boolean} timestampChanged Whether updating a message with changed created_at value. * @param {boolean} addIfDoesNotExist Add message if it is not in the list, used to prevent out of order updated messages from being added. * @param {MessageSetType} messageSetToAddToIfDoesNotExist Which message set to add to if message is not in the list (only used if addIfDoesNotExist is true) */ addMessageSorted( newMessage: MessageResponse | LocalMessage, timestampChanged = false, addIfDoesNotExist = true, messageSetToAddToIfDoesNotExist: MessageSetType = 'latest', ) { return this.addMessagesSorted( [newMessage], timestampChanged, false, addIfDoesNotExist, messageSetToAddToIfDoesNotExist, ); } /** * Takes the message object, parses the dates, sets `__html` * and sets the status to `received` if missing; returns a new message object. * * @param {MessageResponse} message `MessageResponse` object */ formatMessage = (message: MessageResponse | MessageResponseBase | LocalMessage) => formatMessage(message); /** * addMessagesSorted - Add the list of messages to state and resorts the messages * * @param {Array<MessageResponse>} newMessages A list of messages * @param {boolean} timestampChanged Whether updating messages with changed created_at value. * @param {boolean} initializing Whether channel is being initialized. * @param {boolean} addIfDoesNotExist Add message if it is not in the list, used to prevent out of order updated messages from being added. * @param {MessageSetType} messageSetToAddToIfDoesNotExist Which message set to add to if messages are not in the list (only used if addIfDoesNotExist is true) * */ addMessagesSorted( newMessages: (MessageResponse | LocalMessage)[], timestampChanged = false, initializing = false, addIfDoesNotExist = true, messageSetToAddToIfDoesNotExist: MessageSetType = 'current', ) { const { messagesToAdd, targetMessageSetIndex } = this.findTargetMessageSet( newMessages, addIfDoesNotExist, messageSetToAddToIfDoesNotExist, ); for (let i = 0; i < messagesToAdd.length; i += 1) { const isFromShadowBannedUser = messagesToAdd[i].shadowed; if (isFromShadowBannedUser) { continue; } // If message is already formatted we can skip the tasks below // This will be true for messages that are already present at the state -> this happens when we perform merging of message sets // This will be also true for message previews used by some SDKs const isMessageFormatted = messagesToAdd[i].created_at instanceof Date; let message: ReturnType<ChannelState['formatMessage']>; if (isMessageFormatted) { message = messagesToAdd[i] as ReturnType<ChannelState['formatMessage']>; } else { message = this.formatMessage(messagesToAdd[i]); if (message.user && this._channel?.cid) { /** * Store the reference to user for this channel, so that when we have to * handle updates to user, we can use the reference map, to determine which * channels need to be updated with updated user object. */ this._channel .getClient() .state.updateUserReference(message.user, this._channel.cid); } if (initializing && message.id && this.threads[message.id]) { // If we are initializing the state of channel (e.g., in case of connection recovery), // then in that case we remove thread related to this message from threads object. // This way we can ensure that we don't have any stale data in thread object // and consumer can refetch the replies. delete this.threads[message.id]; } if (!this.last_message_at) { this.last_message_at = new Date(message.created_at.getTime()); } if (message.created_at.getTime() > this.last_message_at.getTime()) { this.last_message_at = new Date(message.created_at.getTime()); } } // update or append the messages... const parentID = message.parent_id; // add to the given message set if ((!parentID || message.show_in_channel) && targetMessageSetIndex !== -1) { this.messageSets[targetMessageSetIndex].messages = this._addToMessageList( this.messageSets[targetMessageSetIndex].messages, message, timestampChanged, 'created_at', addIfDoesNotExist, ); } /** * Add message to thread if applicable and the message * was added when querying for replies, or the thread already exits. * This is to prevent the thread state from getting out of sync if * a thread message is shown in channel but older than the newest thread * message. This situation can result in a thread state where a random * message is "oldest" message, and newer messages are therefore not loaded. * This can also occur if an old thread message is updated. */ if (parentID && !initializing) { const thread = this.threads[parentID] || []; this.threads[parentID] = this._addToMessageList( thread, message, timestampChanged, 'created_at', addIfDoesNotExist, ); } } return { messageSet: this.messageSets[targetMessageSetIndex], }; } /** * addPinnedMessages - adds messages in pinnedMessages property * * @param {Array<MessageResponse>} pinnedMessages A list of pinned messages * */ addPinnedMessages(pinnedMessages: MessageResponse[]) { for (let i = 0; i < pinnedMessages.length; i += 1) { this.addPinnedMessage(pinnedMessages[i]); } } /** * addPinnedMessage - adds message in pinnedMessages * * @param {MessageResponse} pinnedMessage message to update * */ addPinnedMessage(pinnedMessage: MessageResponse) { this.pinnedMessages = this._addToMessageList( this.pinnedMessages, this.formatMessage(pinnedMessage), false, 'pinned_at', ); } /** * removePinnedMessage - removes pinned message from pinnedMessages * * @param {MessageResponse} message message to remove * */ removePinnedMessage(message: MessageResponse) { const { result } = this.removeMessageFromArray(this.pinnedMessages, message); this.pinnedMessages = result; } addReaction( reaction: ReactionResponse, message?: MessageResponse, enforce_unique?: boolean, ) { const messageWithReaction = message; let messageFromState: LocalMessage | undefined; if (!messageWithReaction) { messageFromState = this.findMessage(reaction.message_id); } if (!messageWithReaction && !messageFromState) { return; } const messageToUpdate = messageWithReaction ?? messageFromState; const updateData = { id: messageToUpdate?.id, parent_id: messageToUpdate?.parent_id, pinned: messageToUpdate?.pinned, show_in_channel: messageToUpdate?.show_in_channel, }; this._updateMessage(updateData, (msg) => { if (messageWithReaction) { const updatedMessage = { ...messageWithReaction }; // This part will remove own_reactions from what is essentially // a copy of event.message; we do not want to return that as someone // else reaction would remove our own_reactions needlessly. This // only happens when we are not the sender of the reaction. We need // the variable itself so that the event can be properly enriched // later on. messageWithReaction.own_reactions = this._addOwnReactionToMessage( msg.own_reactions, reaction, enforce_unique, ); // Whenever we are the ones sending the reaction, the helper enriches // own_reactions as normal so we can use that, otherwise we fallback // to whatever state we had. updatedMessage.own_reactions = this._channel.getClient().userID === reaction.user_id ? messageWithReaction.own_reactions : msg.own_reactions; return this.formatMessage(updatedMessage); } if (messageFromState) { return this._addReactionToState(messageFromState, reaction, enforce_unique); } return msg; }); return messageWithReaction ?? messageFromState; } _addReactionToState( messageFromState: LocalMessage, reaction: ReactionResponse, enforce_unique?: boolean, ) { if (!messageFromState.reaction_groups) { messageFromState.reaction_groups = {}; } // 1. Firstly, get rid of all of our own reactions from the reaction_groups // if enforce_unique is enabled. if (enforce_unique) { for (const ownReaction of messageFromState.own_reactions ?? []) { const oldOwnReactionTypeData = messageFromState.reaction_groups[ownReaction.type]; messageFromState.reaction_groups[ownReaction.type] = { ...oldOwnReactionTypeData, count: oldOwnReactionTypeData.count - 1, sum_scores: oldOwnReactionTypeData.sum_scores - (ownReaction.score ?? 1), }; // If there are no reactions left in this group, simply remove it. if (messageFromState.reaction_groups[ownReaction.type].count < 1) { delete messageFromState.reaction_groups[ownReaction.type]; } } } const newReactionGroups = messageFromState.reaction_groups; const oldReactionTypeData = newReactionGroups[reaction.type]; const score = reaction.score ?? 1; // 2. Next, update the reaction_groups with the new reaction. messageFromState.reaction_groups[reaction.type] = oldReactionTypeData ? { ...oldReactionTypeData, count: oldReactionTypeData.count + 1, sum_scores: oldReactionTypeData.sum_scores + score, last_reaction_at: reaction.created_at, } : { count: 1, first_reaction_at: reaction.created_at, last_reaction_at: reaction.created_at, sum_scores: score, }; // 3. Update the own_reactions with the new reaction. messageFromState.own_reactions = this._addOwnReactionToMessage( messageFromState.own_reactions, reaction, enforce_unique, ); // 4. Finally, update the latest_reactions with the new reaction, // while respecting enforce_unique. const userId = this._channel.getClient().userID; messageFromState.latest_reactions = enforce_unique ? [ ...(messageFromState.latest_reactions || []).filter( (r) => r.user_id !== userId, ), reaction, ] : [...(messageFromState.latest_reactions || []), reaction]; return messageFromState; } _addOwnReactionToMessage( ownReactions: ReactionResponse[] | null | undefined, reaction: ReactionResponse, enforce_unique?: boolean, ) { if (enforce_unique) { ownReactions = []; } else { ownReactions = this._removeOwnReactionFromMessage(ownReactions, reaction); } ownReactions = ownReactions || []; if (this._channel.getClient().userID === reaction.user_id) { ownReactions.push(reaction); } return ownReactions; } _removeOwnReactionFromMessage( ownReactions: ReactionResponse[] | null | undefined, reaction: ReactionResponse, ) { if (ownReactions) { return ownReactions.filter( (item) => item.user_id !== reaction.user_id || item.type !== reaction.type, ); } return ownReactions; } removeReaction(reaction: ReactionResponse, message?: MessageResponse) { const messageWithRemovedReaction = message; let messageFromState: LocalMessage | undefined; if (!messageWithRemovedReaction) { messageFromState = this.findMessage(reaction.message_id); } if (!messageWithRemovedReaction && !messageFromState) { return; } const messageToUpdate = messageWithRemovedReaction ?? messageFromState; const updateData = { id: messageToUpdate?.id, parent_id: messageToUpdate?.parent_id, pinned: messageToUpdate?.pinned, show_in_channel: messageToUpdate?.show_in_channel, }; this._updateMessage(updateData, (msg) => { if (messageWithRemovedReaction) { messageWithRemovedReaction.own_reactions = this._removeOwnReactionFromMessage( msg.own_reactions, reaction, ); return this.formatMessage(messageWithRemovedReaction); } if (messageFromState) { return this._removeReactionFromState(messageFromState, reaction); } return msg; }); return messageWithRemovedReaction; } _removeReactionFromState(messageFromState: LocalMessage, reaction: ReactionResponse) { const reactionToRemove = messageFromState.own_reactions?.find( (r) => r.type === reaction.type, ); if (reactionToRemove && messageFromState.reaction_groups?.[reactionToRemove.type]) { const newReactionGroup = messageFromState.reaction_groups[reactionToRemove.type]; messageFromState.reaction_groups[reactionToRemove.type] = { ...newReactionGroup, count: newReactionGroup.count - 1, sum_scores: newReactionGroup.sum_scores - (reactionToRemove.score ?? 1), }; // If there are no reactions left in this group, simply remove it. if (messageFromState.reaction_groups[reactionToRemove.type].count < 1) { delete messageFromState.reaction_groups[reactionToRemove.type]; } } messageFromState.own_reactions = messageFromState.own_reactions?.filter( (r) => r.type !== reaction.type, ); const userId = this._channel.getClient().userID; messageFromState.latest_reactions = messageFromState.latest_reactions?.filter( (r) => !(r.user_id === userId && r.type === reaction.type), ); return messageFromState; } _updateQuotedMessageReferences({ message, remove, }: { message: MessageResponse; remove?: boolean; }) { const parseMessage = (m: ReturnType<ChannelState['formatMessage']>) => ({ ...m, created_at: m.created_at.toISOString(), pinned_at: m.pinned_at?.toISOString(), updated_at: m.updated_at?.toISOString(), }) as unknown as MessageResponse; const update = (messages: LocalMessage[]) => { const updatedMessages = messages.reduce<MessageResponse[]>((acc, msg) => { if (msg.quoted_message_id === message.id) { acc.push({ ...parseMessage(msg), quoted_message: remove ? { ...message, attachments: [] } : message, }); } return acc; }, []); this.addMessagesSorted(updatedMessages, true); }; if (!message.parent_id) { this.messageSets.forEach((set) => update(set.messages)); } else if (message.parent_id && this.threads[message.parent_id]) { // prevent going through all the threads even though it is possible to quote a message from another thread update(this.threads[message.parent_id]); } } removeQuotedMessageReferences(message: MessageResponse) { this._updateQuotedMessageReferences({ message, remove: true }); } /** * Updates all instances of given message in channel state * @param message * @param updateFunc */ _updateMessage( message: { id?: string; parent_id?: string; pinned?: boolean; show_in_channel?: boolean; }, updateFunc: ( msg: ReturnType<ChannelState['formatMessage']>, ) => ReturnType<ChannelState['formatMessage']>, ) { const { parent_id, show_in_channel, pinned } = message; if (parent_id && this.threads[parent_id]) { const thread = this.threads[parent_id]; const msgIndex = thread.findIndex((msg) => msg.id === message.id); if (msgIndex !== -1) { thread[msgIndex] = updateFunc(thread[msgIndex]); this.threads[parent_id] = thread; } } if ((!show_in_channel && !parent_id) || show_in_channel) { const messageSetIndex = this.findMessageSetIndex(message); if (messageSetIndex !== -1) { const msgIndex = this.messageSets[messageSetIndex].messages.findIndex( (msg) => msg.id === message.id, ); if (msgIndex !== -1) { const upMsg = updateFunc(this.messageSets[messageSetIndex].messages[msgIndex]); this.messageSets[messageSetIndex].messages[msgIndex] = upMsg; } } } if (pinned) { const msgIndex = this.pinnedMessages.findIndex((msg) => msg.id === message.id); if (msgIndex !== -1) { this.pinnedMessages[msgIndex] = updateFunc(this.pinnedMessages[msgIndex]); } } } /** * Setter for isUpToDate. * * @param isUpToDate Flag which indicates if channel state contain latest/recent messages or no. * This flag should be managed by UI sdks using a setter - setIsUpToDate. * When false, any new message (received by websocket event - message.new) will not * be pushed on to message list. */ setIsUpToDate = (isUpToDate: boolean) => { this.isUpToDate = isUpToDate; }; /** * _addToMessageList - Adds a message to a list of messages, tries to update first, appends if message isn't found * * @param {Array<ReturnType<ChannelState['formatMessage']>>} messages A list of messages * @param message * @param {boolean} timestampChanged Whether updating a message with changed created_at value. * @param {string} sortBy field name to use to sort the messages by * @param {boolean} addIfDoesNotExist Add message if it is not in the list, used to prevent out of order updated messages from being added. */ _addToMessageList( messages: Array<ReturnType<ChannelState['formatMessage']>>, message: ReturnType<ChannelState['formatMessage']>, timestampChanged = false, sortBy: 'pinned_at' | 'created_at' = 'created_at', addIfDoesNotExist = true, ) { return addToMessageList( messages, message, timestampChanged, sortBy, addIfDoesNotExist, ); } /** * removeMessage - Description * * @param {{ id: string; parent_id?: string }} messageToRemove Object of the message to remove. Needs to have at id specified. * * @return {boolean} Returns if the message was removed */ removeMessage(messageToRemove: { id: string; messageSetIndex?: number; parent_id?: string; }) { let isRemoved = false; if (messageToRemove.parent_id && this.threads[messageToRemove.parent_id]) { const { removed, result: threadMessages } = this.removeMessageFromArray( this.threads[messageToRemove.parent_id], messageToRemove, ); this.threads[messageToRemove.parent_id] = threadMessages; isRemoved = removed; } else { const messageSetIndex = messageToRemove.messageSetIndex ?? this.findMessageSetIndex(messageToRemove); if (messageSetIndex !== -1) { const { removed, result: messages } = this.removeMessageFromArray( this.messageSets[messageSetIndex].messages, messageToRemove, ); this.messageSets[messageSetIndex].messages = messages; isRemoved = removed; } } return isRemoved; } removeMessageFromArray = ( msgArray: Array<ReturnType<ChannelState['formatMessage']>>, msg: { id: string; parent_id?: string }, ) => { const result = msgArray.filter( (message) => !(!!message.id && !!msg.id && message.id === msg.id), ); return { removed: result.length < msgArray.length, result }; }; /** * Updates the message.user property with updated user object, for messages. * * @param {UserResponse} user */ updateUserMessages = (user: UserResponse) => { const _updateUserMessages = ( messages: Array<ReturnType<ChannelState['formatMessage']>>, user: UserResponse, ) => { for (let i = 0; i < messages.length; i++) { const m = messages[i]; if (m.user?.id === user.id) { messages[i] = { ...m, user }; } } }; this.messageSets.forEach((set) => _updateUserMessages(set.messages, user)); for (const parentId in this.threads) { _updateUserMessages(this.threads[parentId], user); } _updateUserMessages(this.pinnedMessages, user); }; /** * Marks the messages as deleted, from deleted user. * * @param {UserResponse} user * @param {boolean} hardDelete */ deleteUserMessages = ( user: UserResponse, hardDelete = false, deletedAt?: LocalMessage['deleted_at'], ) => { this.messageSets.forEach(({ messages }) => _deleteUserMessages({ messages, user, hardDelete, deletedAt: deletedAt ?? null }), ); for (const parentId in this.threads) { _deleteUserMessages({ messages: this.threads[parentId], user, hardDelete, deletedAt: deletedAt ?? null, }); } _deleteUserMessages({ messages: this.pinnedMessages, user, hardDelete, deletedAt: deletedAt ?? null, }); }; /** * filterErrorMessages - Removes error messages from the channel state. * */ filterErrorMessages() { const filteredMessages = this.latestMessages.filter( (message) => message.type !== 'error', ); this.latestMessages = filteredMessages; } /** * clean - Remove stale data such as users that stayed in typing state for more than 5 seconds */ clean() { const now = new Date(); // prevent old users from showing up as typing for (const [userID, lastEvent] of Object.entries(this.typing)) { const receivedAt = typeof lastEvent.received_at === 'string' ? new Date(lastEvent.received_at) : lastEvent.received_at || new Date(); if (now.getTime() - receivedAt.getTime() > 7000) { delete this.typing[userID]; this._channel.getClient().dispatchEvent({ cid: this._channel.cid, type: 'typing.stop', user: { id: userID }, } as Event); } } } clearMessages() { this.initMessages(); this.pinnedMessages = []; } initMessages() { this.messageSets = [ { messages: [], isLatest: true, isCurrent: true, pagination: { ...DEFAULT_MESSAGE_SET_PAGINATION }, }, ]; } /** * loadMessageIntoState - Loads a given message (and messages around it) into the state * * @param {string} messageId The id of the message, or 'latest' to indicate switching to the latest messages * @param {string} parentMessageId The id of the parent message, if we want load a thread reply * @param {number} limit The page size if the message has to be queried from the server */ async loadMessageIntoState( messageId: string | 'latest', parentMessageId?: string, limit = 25, ) { let messageSetIndex: number; let switchedToMessageSet = false; let loadedMessageThread = false; const messageIdToFind = parentMessageId || messageId; if (messageId === 'latest') { if (this.messages === this.latestMessages) { return; } messageSetIndex = this.messageSets.findIndex((s) => s.isLatest); } else { messageSetIndex = this.findMessageSetIndex({ id: messageIdToFind }); } if (messageSetIndex !== -1) { this.switchToMessageSet(messageSetIndex); switchedToMessageSet = true; } loadedMessageThread = !parentMessageId || !!this.threads[parentMessageId]?.find((m) => m.id === messageId); if (switchedToMessageSet && loadedMessageThread) { return; } if (!switchedToMessageSet) { await this._channel.query( { messages: { id_around: messageIdToFind, limit } }, 'new', ); } if (!loadedMessageThread && parentMessageId) { await this._channel.getReplies(parentMessageId, { id_around: messageId, limit }); } messageSetIndex = this.findMessageSetIndex({ id: messageIdToFind }); if (messageSetIndex !== -1) { this.switchToMessageSet(messageSetIndex); } } /** * findMessage - Finds a message inside the state * * @param {string} messageId The id of the message * @param {string} parentMessageId The id of the parent message, if we want load a thread reply * * @return {ReturnType<ChannelState['formatMessage']>} Returns the message, or undefined if the message wasn't found */ findMessage(messageId: string, parentMessageId?: string) { if (parentMessageId) { const messages = this.threads[parentMessageId]; if (!messages) { return undefined; } return messages.find((m) => m.id === messageId); } const messageSetIndex = this.findMessageSetIndex({ id: messageId }); if (messageSetIndex === -1) { return undefined; } return this.messageSets[messageSetIndex].messages.find((m) => m.id === messageId); } findMessageByTimestamp( timestampMs: number, parentMessageId?: string, exactTsMatch: boolean = false, ): LocalMessage | null { if ( (parentMessageId && !this.threads[parentMessageId]) || this.messageSets.length === 0 ) return null; const setIndex = this.findMessageSetByOldestTimestamp(timestampMs); const targetMsgSet = this.messageSets[setIndex]?.messages; if (!targetMsgSet?.length) return null; const firstMsgTimestamp = targetMsgSet[0].created_at.getTime(); const lastMsgTimestamp = targetMsgSet.slice(-1)[0].created_at.getTime(); const isOutOfBound = timestampMs < firstMsgTimestamp || lastMsgTimestamp < timestampMs; if (isOutOfBound && exactTsMatch) return null; let msgIndex = 0, hi = targetMsgSet.length - 1; while (msgIndex < hi) { const mid = (msgIndex + hi) >>> 1; if (timestampMs <= targetMsgSet[mid].created_at.getTime()) hi = mid; else msgIndex = mid + 1; } const foundMessage = targetMsgSet[msgIndex]; return !exactTsMatch ? foundMessage : foundMessage.created_at.getTime() === timestampMs ? foundMessage : null; } private switchToMessageSet(index: number) { const currentMessages = this.messageSets.find((s) => s.isCurrent); if (!currentMessages) { return; } currentMessages.isCurrent = false; this.messageSets[index].isCurrent = true; } private areMessageSetsOverlap( messages1: Array<{ id: string }>, messages2: Array<{ id: string }>, ) { return messages1.some((m1) => messages2.find((m2) => m1.id === m2.id)); } private findMessageSetIndex(message: { id?: string }) { return this.messageSets.findIndex( (set) => !!set.messages.find((m) => m.id === message.id), ); } /** * Identifies the set index into which a message set would pertain if its first item's creation date corresponded to oldestTimestampMs. * @param oldestTimestampMs */ private findMessageSetByOldestTimestamp = (oldestTimestampMs: number): number => { let lo = 0, hi = this.messageSets.length; while (lo < hi) { const mid = (lo + hi) >>> 1; const msgSet = this.messageSets[mid]; // should not happen if (msgSet.messages.length === 0) return -1; const oldestMessageTimestampInSet = msgSet.messages[0].created_at.getTime(); if (oldestMessageTimestampInSet <= oldestTimestampMs) hi = mid; else lo = mid + 1; } return lo; }; private findTargetMessageSet( newMessages: (MessageResponse | LocalMessage)[], addIfDoesNotExist = true, messageSetToAddToIfDoesNotExist: MessageSetType = 'current', ) { let messagesToAdd: (MessageResponse | LocalMessage)[] = newMessages; let targetMessageSetIndex!: number; if (newMessages.length === 0) return { targetMessageSetIndex: 0, messagesToAdd: newMessages }; if (addIfDoesNotExist) { const overlappingMessageSetIndicesByMsgIds = this.messageSets .map((_, i) => i) .filter((i) => this.areMessageSetsOverlap(this.messageSets[i].messages, newMessages), ); const overlappingMessageSetIndicesByTimestamp = this.messageSets .map((_, i) => i) .filter((i) => messageSetsOverlapByTimestamp( this.messageSets[i].messages, newMessages.map(formatMessage), ), ); switch (messageSetToAddToIfDoesNotExist) { case 'new': if (overlappingMessageSetIndicesByMsgIds.length > 0) { targetMessageSetIndex = overlappingMessageSetIndicesByMsgIds[0]; } else if (overlappingMessageSetIndicesByTimestamp.length > 0) { targetMessageSetIndex = overlappingMessageSetIndicesByTimestamp[0]; // No new message set is created if newMessages only contains thread replies } else if (newMessages.some((m) => !m.parent_id)) { // find the index to insert the set const setIngestIndex = this.findMessageSetByOldestTimestamp( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion new Date(newMessages[0].created_at!).getTime(), ); if (setIngestIndex === -1) { this.messageSets.push({ messages: [], isCurrent: false, isLatest: false, pagination: { ...DEFAULT_MESSAGE_SET_PAGINATION }, }); targetMessageSetIndex = this.messageSets.length - 1; } else { const isLatest = setIngestIndex === 0; this.messageSets.splice(setIngestIndex, 0, { messages: [], isCurrent: false, isLatest, pagination: { ...DEFAULT_MESSAGE_SET_PAGINATION }, // fixme: it is problematic decide about pagination without having data }); if (isLatest) { this.messageSets.slice(1).forEach((set) => { set.isLatest = false; }); } targetMessageSetIndex = setIngestIndex; } } break; case 'current': // determine if there is another set to which it would match taken into consideration the timestamp if (overlappingMessageSetIndicesByTimestamp.length > 0) { targetMessageSetIndex = overlappingMessageSetIndicesByTimestamp[0]; } else { targetMessageSetIndex = this.messageSets.findIndex((s) => s.isCurrent); } break; case 'latest': // determine if there is another set to which it would match taken into consideration the timestamp if (overlappingMessageSetIndicesByTimestamp.length > 0) { targetMessageSetIndex = overlappingMessageSetIndicesByTimestamp[0]; } else { targetMessageSetIndex = this.messageSets.findIndex((s) => s.isLatest); } break; default: targetMessageSetIndex = -1; } // when merging the target set will be the first one from the overlapping message sets const mergeTargetMessageSetIndex = overlappingMessageSetIndicesByMsgIds.splice( 0, 1, )[0]; const mergeSourceMessageSetIndices = [...overlappingMessageSetIndicesByMsgIds]; if ( mergeTargetMessageSetIndex !== undefined && mergeTargetMessageSetIndex !== targetMessageSetIndex ) { mergeSourceMessageSetIndices.push(targetMessageSetIndex); } // merge message sets if (mergeSourceMessageSetIndices.length > 0) { const target = this.messageSets[mergeTargetMessageSetIndex]; const sources = this.messageSets.filter( (_, i) => mergeSourceMessageSetIndices.indexOf(i) !== -1, ); sources.forEach((messageSet) => { target.isLatest = target.isLatest || messageSet.isLatest; target.isCurrent = target.isCurrent || messageSet.isCurrent; target.pagination.hasPrev = messageSet.messages[0].created_at < target.messages[0].created_at ? messageSet.pagination.hasPrev : target.pagination.hasPrev; target.pagination.hasNext = target.messages.slice(-1)[0].created_at < messageSet.messages.slice(-1)[0].created_at ? messageSet.pagination.hasNext : target.pagination.hasNext; messagesToAdd = [...messagesToAdd, ...messageSet.messages]; }); sources.forEach((s) => this.messageSets.splice(this.messageSets.indexOf(s), 1)); const overlappingMessageSetIndex = this.messageSets.findIndex((s) => this.areMessageSetsOverlap(s.messages, newMessages), ); targetMessageSetIndex = overlappingMessageSetIndex; } } else { // assumes that all new messages belong to the same set targetMessageSetIndex = this.findMessageSetIndex(newMessages[0]); } return { targetMessageSetIndex, messagesToAdd }; } }