UNPKG

stream-chat

Version:

JS SDK for the Stream Chat API

306 lines (260 loc) 10.7 kB
import type { StreamChat } from '../client'; import { Channel } from '../channel'; import type { ThreadUserReadState } from '../thread'; import { Thread } from '../thread'; import type { ErrorFromResponse, EventAPIResponse, LocalMessage, MarkDeliveredOptions, MarkReadOptions, } from '../types'; import { type APIErrorResponse } from '../types'; import { throttle } from '../utils'; import { isAPIError, isErrorRetryable } from '../errors'; const MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD = 100 as const; const MARK_AS_DELIVERED_BUFFER_TIMEOUT = 1000 as const; const MARK_AS_READ_THROTTLE_TIMEOUT = 1000 as const; const RETRY_COUNT_LIMIT_FOR_TIMEOUT_INCREASE = 3 as const; const isChannel = (item: Channel | Thread): item is Channel => item instanceof Channel; const isThread = (item: Channel | Thread): item is Thread => item instanceof Thread; type MessageId = string; type ChannelThreadCompositeId = string; export type AnnounceDeliveryOptions = Omit< MarkDeliveredOptions, 'latest_delivered_messages' >; export type MessageDeliveryReporterOptions = { client: StreamChat; }; export class MessageDeliveryReporter { protected client: StreamChat; protected deliveryReportCandidates: Map<ChannelThreadCompositeId, MessageId> = new Map(); protected nextDeliveryReportCandidates: Map<ChannelThreadCompositeId, MessageId> = new Map(); protected markDeliveredRequestPromise: Promise<EventAPIResponse | void> | null = null; protected markDeliveredTimeout: ReturnType<typeof setTimeout> | null = null; protected requestTimeoutMs: number = MARK_AS_DELIVERED_BUFFER_TIMEOUT; // increased up to RETRY_COUNT_LIMIT_FOR_TIMEOUT_INCREASE protected requestRetryCount: number = 0; constructor({ client }: MessageDeliveryReporterOptions) { this.client = client; } private get markDeliveredRequestInFlight() { return this.markDeliveredRequestPromise !== null; } private get hasTimer() { return this.markDeliveredTimeout !== null; } private get hasDeliveryCandidates() { return this.deliveryReportCandidates.size > 0; } private get canExecuteRequest() { return !this.markDeliveredRequestInFlight && this.hasDeliveryCandidates; } private static hasPermissionToReportDeliveryFor(collection: Channel | Thread) { if (isChannel(collection)) return !!collection.getConfig()?.delivery_events; if (isThread(collection)) return !!collection.channel.getConfig()?.delivery_events; } private increaseBackOff() { if (this.requestRetryCount >= RETRY_COUNT_LIMIT_FOR_TIMEOUT_INCREASE) return; this.requestRetryCount = this.requestRetryCount + 1; this.requestTimeoutMs = this.requestTimeoutMs * 2; } private resetBackOff() { this.requestTimeoutMs = MARK_AS_DELIVERED_BUFFER_TIMEOUT; this.requestRetryCount = 0; } /** * Build latest_delivered_messages payload from an arbitrary buffer (deliveryReportCandidates / nextDeliveryReportCandidates) */ private confirmationsFrom(map: Map<ChannelThreadCompositeId, MessageId>) { return Array.from(map.entries()).map(([key, messageId]) => { const [type, id, parent_id] = key.split(':'); return parent_id ? { cid: `${type}:${id}`, id: messageId, parent_id } : { cid: key, id: messageId }; }); } private confirmationsFromDeliveryReportCandidates() { const entries = Array.from(this.deliveryReportCandidates); const sendBuffer = new Map(entries.slice(0, MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD)); this.deliveryReportCandidates = new Map( entries.slice(MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD), ); return { latest_delivered_messages: this.confirmationsFrom(sendBuffer), sendBuffer }; } /** * Generate candidate key for storing in the candidates buffer * @param collection * @private */ private candidateKeyFor( collection: Channel | Thread, ): ChannelThreadCompositeId | undefined { if (isChannel(collection)) return collection.cid; if (isThread(collection)) return `${collection.channel.cid}:${collection.id}`; } /** * Retrieve the reference to the latest message in the state that is nor read neither reported as delivered * @param collection */ private getNextDeliveryReportCandidate = ( collection: Channel | Thread, ): { key: ChannelThreadCompositeId; id: MessageId | null } | undefined => { const ownUserId = this.client.user?.id; if (!ownUserId) return; let latestMessages: LocalMessage[] = []; let lastDeliveredAt: Date | undefined; let lastReadAt: Date | undefined; let key: string | undefined = undefined; if (isChannel(collection)) { latestMessages = collection.state.latestMessages; const ownReadState = collection.state.read[ownUserId] ?? {}; lastReadAt = ownReadState?.last_read; lastDeliveredAt = ownReadState?.last_delivered_at; key = collection.cid; } else if (isThread(collection)) { latestMessages = collection.state.getLatestValue().replies; const ownReadState = collection.state.getLatestValue().read[ownUserId] ?? ({} as ThreadUserReadState); lastReadAt = ownReadState?.lastReadAt; // @ts-expect-error lastDeliveredAt is not defined yet on ThreadUserReadState lastDeliveredAt = ownReadState?.lastDeliveredAt; key = `${collection.channel.cid}:${collection.id}`; // todo: remove return statement once marking messages as delivered in thread is supported return; } else { return; } if (!key) return; const [latestMessage] = latestMessages.slice(-1); const wholeCollectionIsRead = !latestMessage || lastReadAt >= latestMessage.created_at; if (wholeCollectionIsRead) return { key, id: null }; const wholeCollectionIsMarkedDelivered = !latestMessage || (lastDeliveredAt ?? 0) >= latestMessage.created_at; if (wholeCollectionIsMarkedDelivered) return { key, id: null }; return { key, id: latestMessage.id || null }; }; /** * Updates the delivery candidates buffer with the latest delivery candidates * @param collection */ private trackDeliveredCandidate(collection: Channel | Thread) { if (!MessageDeliveryReporter.hasPermissionToReportDeliveryFor(collection)) return; const candidate = this.getNextDeliveryReportCandidate(collection); if (!candidate?.key) return; const buffer = this.markDeliveredRequestInFlight ? this.nextDeliveryReportCandidates : this.deliveryReportCandidates; if (candidate.id === null) buffer.delete(candidate.key); else buffer.set(candidate.key, candidate.id); } /** * Removes candidate from the delivery report buffer * @param collection * @private */ private removeCandidateFor(collection: Channel | Thread) { const candidateKey = this.candidateKeyFor(collection); if (!candidateKey) return; this.deliveryReportCandidates.delete(candidateKey); this.nextDeliveryReportCandidates.delete(candidateKey); } /** * Records the latest message delivered for Channel or Thread instances and schedules the next report * if not already scheduled and candidates exist. * Should be used for WS handling (message.new) as well as for ingesting HTTP channel query results. * @param collections */ public syncDeliveredCandidates(collections: (Channel | Thread)[]) { if (this.client.user?.privacy_settings?.delivery_receipts?.enabled === false) return; for (const c of collections) this.trackDeliveredCandidate(c); this.announceDeliveryBuffered(); } /** * Fires delivery announcement request followed by immediate delivery candidate buffer reset. * @param options */ public announceDelivery = (options?: AnnounceDeliveryOptions) => { if (!this.canExecuteRequest) return; const { latest_delivered_messages, sendBuffer } = this.confirmationsFromDeliveryReportCandidates(); if (!latest_delivered_messages.length) return; const payload = { ...options, latest_delivered_messages }; const postFlightReconcile = ({ preventSchedulingRetry, }: { preventSchedulingRetry?: boolean } = {}) => { this.markDeliveredRequestPromise = null; // promote anything that arrived during request for (const [k, v] of this.nextDeliveryReportCandidates.entries()) { this.deliveryReportCandidates.set(k, v); } this.nextDeliveryReportCandidates = new Map(); if (preventSchedulingRetry) return; // checks internally whether there are candidates to announce this.announceDeliveryBuffered(options); }; const handleSuccess = () => { this.resetBackOff(); postFlightReconcile(); }; const handleError = (error: ErrorFromResponse<APIErrorResponse> | Error) => { // re-populate relevant candidates for the next report // but make sure to keep the items that failed to be reported the first next time const newDeliveryReportCandidates = new Map(sendBuffer); for (const [k, v] of this.deliveryReportCandidates.entries()) { newDeliveryReportCandidates.set(k, v); } this.deliveryReportCandidates = newDeliveryReportCandidates; if ( (isAPIError(error) && isErrorRetryable(error)) || (error as ErrorFromResponse<APIErrorResponse>).status >= 500 ) { this.increaseBackOff(); postFlightReconcile(); } else { postFlightReconcile({ preventSchedulingRetry: true }); } }; this.markDeliveredRequestPromise = this.client .markChannelsDelivered(payload) .then(handleSuccess, handleError); }; public announceDeliveryBuffered = (options?: AnnounceDeliveryOptions) => { if (this.hasTimer || !this.canExecuteRequest) return; this.markDeliveredTimeout = setTimeout(() => { this.markDeliveredTimeout = null; this.announceDelivery(options); }, this.requestTimeoutMs); }; /** * Delegates the mark-read call to the Channel or Thread instance * @param collection * @param options */ public markRead = async (collection: Channel | Thread, options?: MarkReadOptions) => { let result: EventAPIResponse | null = null; if (isChannel(collection)) { result = await collection.markAsReadRequest(options); } else if (isThread(collection)) { result = await collection.channel.markAsReadRequest({ ...options, thread_id: collection.id, }); } this.removeCandidateFor(collection); return result; }; /** * Throttles the MessageDeliveryReporter.markRead call * @param collection * @param options */ public throttledMarkRead = throttle(this.markRead, MARK_AS_READ_THROTTLE_TIMEOUT, { leading: false, trailing: true, }); }