UNPKG

stream-chat

Version:

JS SDK for the Stream Chat API

418 lines (381 loc) 15.3 kB
import type { ReadResponse, UserResponse } from '../types'; type UserId = string; type MessageId = string; export type MsgRef = { timestampMs: number; msgId: MessageId }; export type OwnMessageReceiptsTrackerMessageLocator = ( timestampMs: number, ) => MsgRef | null; export type UserProgress = { user: UserResponse; lastReadRef: MsgRef; // MIN_REF if none lastDeliveredRef: MsgRef; // MIN_REF if none; always >= readRef }; // ---------- ordering utilities ---------- const MIN_REF: MsgRef = { timestampMs: Number.NEGATIVE_INFINITY, msgId: '' } as const; const compareRefsAsc = (a: MsgRef, b: MsgRef) => a.timestampMs !== b.timestampMs ? a.timestampMs - b.timestampMs : 0; const findIndex = <T>(arr: T[], target: MsgRef, keyOf: (x: T) => MsgRef): number => { let lo = 0, hi = arr.length; while (lo < hi) { const mid = (lo + hi) >>> 1; if (compareRefsAsc(keyOf(arr[mid]), target) >= 0) hi = mid; else lo = mid + 1; } return lo; }; /** * For insertion after the last equal item. E.g. array [a] exists and b is being inserted -> we want [a,b], not [b,a]. * @param arr * @param target * @param keyOf */ const findUpperIndex = <T>(arr: T[], target: MsgRef, keyOf: (x: T) => MsgRef): number => { let lo = 0, hi = arr.length; while (lo < hi) { const mid = (lo + hi) >>> 1; if (compareRefsAsc(keyOf(arr[mid]), target) > 0) hi = mid; else lo = mid + 1; } return lo; }; const insertByKey = ( arr: UserProgress[], item: UserProgress, keyOf: (x: UserProgress) => MsgRef, ) => arr.splice(findUpperIndex(arr, keyOf(item), keyOf), 0, item); const removeByOldKey = ( arr: UserProgress[], item: UserProgress, oldKey: MsgRef, keyOf: (x: UserProgress) => MsgRef, ) => { // Find the plateau for oldKey, scan to match by user id let i = findIndex(arr, oldKey, keyOf); while (i < arr.length && compareRefsAsc(keyOf(arr[i]), oldKey) === 0) { if (arr[i].user.id === item.user.id) { arr.splice(i, 1); return; } i++; } }; export type OwnMessageReceiptsTrackerOptions = { locateMessage: OwnMessageReceiptsTrackerMessageLocator; }; /** * MessageReceiptsTracker * -------------------------------- * Tracks **other participants’** delivery/read progress toward **own (outgoing) messages** * within a **single timeline** (one channel/thread). * * How it works * ------------ * - Each user has a compact progress record: * - `lastReadRef`: latest message they have **read** * - `lastDeliveredRef`: latest message they have **received** (always `>= lastReadRef`) * - Internally keeps two arrays sorted **ascending by timestamp**: * - `readSorted` (by `lastReadRef`) * - `deliveredSorted` (by `lastDeliveredRef`) * - Queries like “who read message M?” become a **binary search + suffix slice**. * * Construction * ------------ * `new MessageReceiptsTracker({locateMessage})` * - `locateMessage(timestamp) => MsgRef | null` must resolve a message ref representation - `{ timestamp, msgId }`. * - If `locateMessage` returns `null`, the event is ignored (message unknown locally). * * Event ingestion * --------------- * - `ingestInitial(rows: ReadResponse[])`: Builds initial state from server snapshot. * If a user’s `last_read` is ahead of `last_delivered_at`, the tracker enforces * the invariant `lastDeliveredRef >= lastReadRef`. * - `onMessageRead(user, readAtISO)`: * Advances the user’s read; also bumps delivered to match if needed. * - `onMessageDelivered(user, deliveredAtISO)`: * Advances the user’s delivered to `max(currentRead, deliveredAt)`. * * Queries * ------- * - `readersForMessage(msgRef) : UserResponse[]` → users with `lastReadRef >= msgRef` * - `deliveredForMessage(msgRef) : UserResponse[]` → users with `lastDeliveredRef >= msgRef` * - `deliveredNotReadForMessage(msgRef): UserResponse[]` → delivered but `lastReadRef < msgRef` * - `usersWhoseLastReadIs : UserResponse[]` → users for whom `msgRef` is their *last read* (exact match) * - `usersWhoseLastDeliveredIs : UserResponse[]` → users for whom `msgRef` is their *last delivered* (exact match) * - `groupUsersByLastReadMessage : Record<MsgId, UserResponse[]> → mapping of messages to their readers * - `groupUsersByLastDeliveredMessage : Record<MsgId, UserResponse[]> → mapping of messages to their receivers * - `hasUserRead(msgRef, userId) : boolean` * - `hasUserDelivered(msgRef, userId) : boolean` * * Complexity * ---------- * - Update on read/delivered: **O(log U)** (binary search + one splice) per event, where U is count of users stored by tracker. * - Query lists: **O(log U + K)** where `K` is the number of returned users (suffix length). * - Memory: **O(U)** - tracker’s memory grows linearly with the number of users in the channel/thread and does not depend on the number of messages. * * Scope & notes * ------------- * - One tracker instance is **scoped to a single timeline**. Instantiate per channel/thread. * - Ordering is by **ascending timestamp**; ties are kept stable by inserting at the end of the * equal-timestamp plateau (upper-bound insertion), preserving intuitive arrival order. * - This tracker models **others’ progress toward own messages**; */ export class MessageReceiptsTracker { private byUser = new Map<UserId, UserProgress>(); private readSorted: UserProgress[] = []; // asc by lastReadRef private deliveredSorted: UserProgress[] = []; // asc by lastDeliveredRef private locateMessage: OwnMessageReceiptsTrackerMessageLocator; constructor({ locateMessage }: OwnMessageReceiptsTrackerOptions) { this.locateMessage = locateMessage; } /** Build initial state from server snapshots (single pass + sort). */ ingestInitial(responses: ReadResponse[]) { this.byUser.clear(); this.readSorted = []; this.deliveredSorted = []; for (const r of responses) { const lastReadTimestamp = r.last_read ? new Date(r.last_read).getTime() : null; const lastDeliveredTimestamp = r.last_delivered_at ? new Date(r.last_delivered_at).getTime() : null; const lastReadRef = lastReadTimestamp ? (this.locateMessage(lastReadTimestamp) ?? MIN_REF) : MIN_REF; let lastDeliveredRef = lastDeliveredTimestamp ? (this.locateMessage(lastDeliveredTimestamp) ?? MIN_REF) : MIN_REF; const isReadAfterDelivered = compareRefsAsc(lastDeliveredRef, lastReadRef) < 0; if (isReadAfterDelivered) lastDeliveredRef = lastReadRef; const userProgress: UserProgress = { user: r.user, lastReadRef, lastDeliveredRef }; this.byUser.set(r.user.id, userProgress); this.readSorted.splice( findIndex(this.readSorted, lastReadRef, (up) => up.lastReadRef), 0, userProgress, ); this.deliveredSorted.splice( findIndex(this.deliveredSorted, lastDeliveredRef, (up) => up.lastDeliveredRef), 0, userProgress, ); } } /** message.delivered — user device confirmed delivery up to and including messageId. */ onMessageDelivered({ user, deliveredAt, lastDeliveredMessageId, }: { user: UserResponse; deliveredAt: string; lastDeliveredMessageId?: string; }) { const timestampMs = new Date(deliveredAt).getTime(); const msgRef = lastDeliveredMessageId ? { timestampMs, msgId: lastDeliveredMessageId } : this.locateMessage(new Date(deliveredAt).getTime()); if (!msgRef) return; const userProgress = this.ensureUser(user); const newDelivered = compareRefsAsc(msgRef, userProgress.lastReadRef) < 0 ? userProgress.lastReadRef : msgRef; // max(read, loc) // newly announced delivered is older than or equal what is already registered if (compareRefsAsc(newDelivered, userProgress.lastDeliveredRef) <= 0) return; removeByOldKey( this.deliveredSorted, userProgress, userProgress.lastDeliveredRef, (x) => x.lastDeliveredRef, ); userProgress.lastDeliveredRef = newDelivered; insertByKey(this.deliveredSorted, userProgress, (x) => x.lastDeliveredRef); } /** message.read — user read up to and including messageId. */ onMessageRead({ user, readAt, lastReadMessageId, }: { user: UserResponse; readAt: string; lastReadMessageId?: string; }) { const timestampMs = new Date(readAt).getTime(); const msgRef = lastReadMessageId ? { timestampMs, msgId: lastReadMessageId } : this.locateMessage(timestampMs); if (!msgRef) return; const userProgress = this.ensureUser(user); // newly announced read message is older than or equal the already recorded last read message if (compareRefsAsc(msgRef, userProgress.lastReadRef) <= 0) return; // move in readSorted removeByOldKey( this.readSorted, userProgress, userProgress.lastReadRef, (x) => x.lastReadRef, ); userProgress.lastReadRef = msgRef; insertByKey(this.readSorted, userProgress, (x) => x.lastReadRef); // keep delivered >= read if (compareRefsAsc(userProgress.lastDeliveredRef, userProgress.lastReadRef) < 0) { removeByOldKey( this.deliveredSorted, userProgress, userProgress.lastDeliveredRef, (x) => x.lastDeliveredRef, ); userProgress.lastDeliveredRef = userProgress.lastReadRef; insertByKey(this.deliveredSorted, userProgress, (x) => x.lastDeliveredRef); } } /** notification.mark_unread — user marked messages unread starting at `first_unread_message_id`. * Sets lastReadRef to the event’s last_read_* values. Delivery never moves backward. * The event is sent only to the user that triggered the action (own user), so we will never adjust read ref * for other users - we will not see changes in the UI for other users. However, this implementation does not * take into consideration this fact and is ready to handle the mark-unread event for any user. */ onNotificationMarkUnread({ user, lastReadAt, lastReadMessageId, }: { user: UserResponse; lastReadAt?: string; lastReadMessageId?: string; }) { const userProgress = this.ensureUser(user); const newReadRef: MsgRef = lastReadAt ? { timestampMs: new Date(lastReadAt).getTime(), msgId: lastReadMessageId ?? '' } : { ...MIN_REF }; // If no change, exit early. if ( compareRefsAsc(newReadRef, userProgress.lastReadRef) === 0 && newReadRef.msgId === userProgress.lastReadRef.msgId ) { return; } removeByOldKey( this.readSorted, userProgress, userProgress.lastReadRef, (x) => x.lastReadRef, ); userProgress.lastReadRef = newReadRef; insertByKey(this.readSorted, userProgress, (x) => x.lastReadRef); // Maintain invariant delivered >= read. if (compareRefsAsc(userProgress.lastDeliveredRef, userProgress.lastReadRef) < 0) { removeByOldKey( this.deliveredSorted, userProgress, userProgress.lastDeliveredRef, (x) => x.lastDeliveredRef, ); userProgress.lastDeliveredRef = userProgress.lastReadRef; insertByKey(this.deliveredSorted, userProgress, (x) => x.lastDeliveredRef); } } /** All users who READ this message. */ readersForMessage(msgRef: MsgRef): UserResponse[] { const index = findIndex(this.readSorted, msgRef, ({ lastReadRef }) => lastReadRef); return this.readSorted.slice(index).map((x) => x.user); } /** All users who have it DELIVERED (includes readers). */ deliveredForMessage(msgRef: MsgRef): UserResponse[] { const pos = findIndex( this.deliveredSorted, msgRef, ({ lastDeliveredRef }) => lastDeliveredRef, ); return this.deliveredSorted.slice(pos).map((x) => x.user); } /** Users who delivered but have NOT read. */ deliveredNotReadForMessage(msgRef: MsgRef): UserResponse[] { const pos = findIndex( this.deliveredSorted, msgRef, ({ lastDeliveredRef }) => lastDeliveredRef, ); const usersDeliveredNotRead: UserResponse[] = []; for (let i = pos; i < this.deliveredSorted.length; i++) { const userProgress = this.deliveredSorted[i]; if (compareRefsAsc(userProgress.lastReadRef, msgRef) < 0) usersDeliveredNotRead.push(userProgress.user); } return usersDeliveredNotRead; } /** Users for whom `msgRef` is their *last read* (exact match). */ usersWhoseLastReadIs(msgRef: MsgRef): UserResponse[] { if (!msgRef.msgId) return []; const start = findIndex(this.readSorted, msgRef, (x) => x.lastReadRef); const end = findUpperIndex(this.readSorted, msgRef, (x) => x.lastReadRef); const users: UserResponse[] = []; for (let i = start; i < end; i++) { const up = this.readSorted[i]; if (up.lastReadRef.msgId === msgRef.msgId) users.push(up.user); } return users; } /** Users for whom `msgRef` is their *last delivered* (exact match). */ usersWhoseLastDeliveredIs(msgRef: MsgRef): UserResponse[] { if (!msgRef.msgId) return []; const start = findIndex(this.deliveredSorted, msgRef, (x) => x.lastDeliveredRef); const end = findUpperIndex(this.deliveredSorted, msgRef, (x) => x.lastDeliveredRef); const users: UserResponse[] = []; for (let i = start; i < end; i++) { const up = this.deliveredSorted[i]; if (up.lastDeliveredRef.msgId === msgRef.msgId) users.push(up.user); } return users; } // ---- queries: per-user status ---- hasUserRead(msgRef: MsgRef, userId: string): boolean { const up = this.byUser.get(userId); return !!up && compareRefsAsc(up.lastReadRef, msgRef) >= 0; } hasUserDelivered(msgRef: MsgRef, userId: string): boolean { const up = this.byUser.get(userId); return !!up && compareRefsAsc(up.lastDeliveredRef, msgRef) >= 0; } getUserProgress(userId: string): UserProgress | null { const userProgress = this.byUser.get(userId); if (!userProgress) return null; return userProgress; } groupUsersByLastReadMessage(): Record<MessageId, UserResponse[]> { return Array.from(this.byUser.values()).reduce<Record<MessageId, UserResponse[]>>( (acc, userProgress) => { const msgId = userProgress.lastReadRef.msgId; if (!msgId) return acc; if (!acc[msgId]) acc[msgId] = []; acc[msgId].push(userProgress.user); return acc; }, {}, ); } groupUsersByLastDeliveredMessage(): Record<MessageId, UserResponse[]> { return Array.from(this.byUser.values()).reduce<Record<MessageId, UserResponse[]>>( (acc, userProgress) => { const msgId = userProgress.lastDeliveredRef.msgId; if (!msgId) return acc; if (!acc[msgId]) acc[msgId] = []; acc[msgId].push(userProgress.user); return acc; }, {}, ); } private ensureUser(user: UserResponse): UserProgress { let up = this.byUser.get(user.id); if (!up) { up = { user, lastReadRef: MIN_REF, lastDeliveredRef: MIN_REF }; this.byUser.set(user.id, up); insertByKey(this.readSorted, up, (x) => x.lastReadRef); insertByKey(this.deliveredSorted, up, (x) => x.lastDeliveredRef); } return up; } }