UNPKG

@ably/chat

Version:

Ably Chat is a set of purpose-built APIs for a host of chat features enabling you to create 1:1, 1:Many, Many:1 and Many:Many chat rooms for any scale. It is designed to meet a wide range of chat use cases, such as livestreams, in-game communication, cust

391 lines (344 loc) 14.1 kB
import * as Ably from 'ably'; import { ChannelOptionsMerger } from './channel-manager.js'; import { ChatApi, DeleteMessageReactionParams as APIDeleteMessageReactionParams, SendMessageReactionParams as APISendMessageReactionParams, } from './chat-api.js'; import { ErrorCode } from './errors.js'; import { AnnotationTypeToReactionType, MessageReactionRawEvent, MessageReactionRawEventType, MessageReactionSummaryEvent, MessageReactionSummaryEventType, MessageReactionType, ReactionAnnotationType, } from './events.js'; import { Logger } from './logger.js'; import { Message } from './message.js'; import { subscribe } from './realtime-subscriptions.js'; import { InternalRoomOptions, MessagesOptions } from './room-options.js'; import { assertValidSerial } from './serial.js'; import { Subscription } from './subscription.js'; import EventEmitter, { emitterHasListeners, wrap } from './utils/event-emitter.js'; /** * A listener for summary message reaction events. * @param event The message reaction summary event that was received. Use it * with {@link Message.with} to keep an up-to-date reaction count. */ export type MessageReactionListener = (event: MessageReactionSummaryEvent) => void; /** * A listener for individual message reaction events. * @param event The message reaction event that was received. */ export type MessageRawReactionListener = (event: MessageReactionRawEvent) => void; /** * Parameters for sending a message reaction. */ export interface SendMessageReactionParams { /** * The reaction name to send; ie. the emoji. */ name: string; /** * The type of reaction, must be one of {@link MessageReactionType}. * If not set, the default type will be used which is configured in the {@link MessagesOptions.defaultMessageReactionType} of the room. */ type?: MessageReactionType; /** * The count of the reaction for type {@link MessageReactionType.Multiple}. * Defaults to 1 if not set. Not supported for other reaction types. * @defaultValue 1 */ count?: number; } /** * Parameters for deleting a message reaction. */ export interface DeleteMessageReactionParams { /** * The reaction name to delete; ie. the emoji. Required for all reaction types * except {@link MessageReactionType.Unique}. */ name?: string; /** * The type of reaction, must be one of {@link MessageReactionType}. * If not set, the default type will be used which is configured in the {@link MessagesOptions.defaultMessageReactionType} of the room. */ type?: MessageReactionType; } /** * Send, delete, and subscribe to message reactions. */ export interface MessageReactions { /** * Send a message reaction. * @param messageSerial The serial of the message to react to. * @param params Describe the reaction to send. * @returns A promise that resolves when the reaction is sent. */ send(messageSerial: string, params: SendMessageReactionParams): Promise<void>; /** * Delete a message reaction * @param messageSerial The serial of the message to remove the reaction from. * @param params The type of reaction annotation and the specific reaction to remove. The reaction to remove is required for all types except {@link MessageReactionType.Unique}. * @returns A promise that resolves when the reaction is deleted. */ delete(messageSerial: string, params?: DeleteMessageReactionParams): Promise<void>; /** * Subscribe to message reaction summaries. Use this to keep message reaction * counts up to date efficiently in the UI. * @param listener The listener to call when a message reaction summary is received. * @returns A subscription object that should be used to unsubscribe. */ subscribe(listener: MessageReactionListener): Subscription; /** * Subscribe to individual reaction events. * * If you only need to keep track of reaction counts and clients, use * {@link subscribe} instead. * @param listener The listener to call when a message reaction event is received. * @returns A subscription object that should be used to unsubscribe. */ subscribeRaw(listener: MessageRawReactionListener): Subscription; /** * Get the reaction count for a message for a particular client. * @param messageSerial The serial of the message to remove the reaction from. * @param clientId The client to fetch the reaction summary for (leave unset for current client). * @returns A clipped reaction summary containing only the requested clientId. * @example * ```typescript * // Subscribe to reaction summaries and check for specific client reactions * room.messages.reactions.subscribe(async (event) => { * // For brevity of example, we check unique 👍 (normally iterate for all relevant reactions) * const uniqueLikes = event.summary.unique['👍']; * if (uniqueLikes && uniqueLikes.clipped && !uniqueLikes.clientIds.includes(myClientId)) { * // summary is clipped and doesn't include myClientId, so we need to fetch a clientSummary * const clientReactions = await room.messages.reactions.clientReactions( * event.messageSerial, * myClientId * ); * if (clientReactions.unique && clientReactions.unique['👍']) { * // client has reacted with 👍 * event.reactions.unique['👍'].clientIds.push(myClientId); * } * } * // from here, process the summary as usual * }); * ``` */ clientReactions(messageSerial: string, clientId?: string): Promise<Message['reactions']>; } /** * Maps Ably PubSub annotation action to message reaction event type. */ const eventTypeMap: Record<string, MessageReactionRawEventType> = { 'annotation.create': MessageReactionRawEventType.Create, 'annotation.delete': MessageReactionRawEventType.Delete, }; /** * @inheritDoc */ export class DefaultMessageReactions implements MessageReactions { private _emitter = new EventEmitter<{ [MessageReactionRawEventType.Create]: MessageReactionRawEvent; [MessageReactionRawEventType.Delete]: MessageReactionRawEvent; [MessageReactionSummaryEventType.Summary]: MessageReactionSummaryEvent; }>(); private readonly _defaultType: MessageReactionType; private readonly _unsubscribeMessageEvents: () => void; private readonly _unsubscribeAnnotationEvents?: () => void; constructor( private readonly _logger: Logger, private readonly _options: MessagesOptions | undefined, private readonly _api: ChatApi, private readonly _roomName: string, private readonly _channel: Ably.RealtimeChannel, ) { // Use subscription helper to create cleanup function this._unsubscribeMessageEvents = subscribe(_channel, this._processMessageEvent.bind(this)); if (this._options?.rawMessageReactions) { this._unsubscribeAnnotationEvents = subscribe(_channel.annotations, this._processAnnotationEvent.bind(this)); } this._defaultType = this._options?.defaultMessageReactionType ?? MessageReactionType.Distinct; } private _processAnnotationEvent(event: Ably.Annotation) { this._logger.trace('MessageReactions._processAnnotationEvent();', { event }); // If we don't know the reaction type, ignore it const reactionType = AnnotationTypeToReactionType[event.type]; if (!reactionType) { this._logger.info('MessageReactions._processAnnotationEvent(); ignoring unknown reaction type', { event }); return; } // If we don't know the event type, ignore it const eventType = eventTypeMap[event.action]; if (!eventType) { this._logger.info('MessageReactions._processAnnotationEvent(); ignoring unknown reaction event type', { event }); return; } const name = event.name ?? ''; const reactionEvent: MessageReactionRawEvent = { type: eventType, timestamp: new Date(event.timestamp), reaction: { messageSerial: event.messageSerial, type: reactionType, name: name, clientId: event.clientId ?? '', }, }; if (event.count) { reactionEvent.reaction.count = event.count; } else if (eventType === MessageReactionRawEventType.Create && reactionType === MessageReactionType.Multiple) { reactionEvent.reaction.count = 1; // count defaults to 1 for multiple if not set } this._emitter.emit(eventType, reactionEvent); } private _processMessageEvent(event: Ably.InboundMessage) { this._logger.trace('MessageReactions._processMessageEvent();', { event }); // only process summary events if (event.action !== 'message.summary') { return; } // As Chat uses mutable messages, we know that `serial` will be defined, so this cast is ok const serial = event.serial as unknown as string; // Set the reaction types from the summary const summary = event.annotations.summary; const unique = (summary[ReactionAnnotationType.Unique] ?? {}) as unknown as Ably.SummaryUniqueValues; const distinct = (summary[ReactionAnnotationType.Distinct] ?? {}) as unknown as Ably.SummaryDistinctValues; const multiple = (summary[ReactionAnnotationType.Multiple] ?? {}) as Ably.SummaryMultipleValues; this._emitter.emit(MessageReactionSummaryEventType.Summary, { type: MessageReactionSummaryEventType.Summary, messageSerial: serial, reactions: { unique: unique, distinct: distinct, multiple: multiple, }, }); } /** * @inheritDoc */ async send(messageSerial: string, params: SendMessageReactionParams): Promise<void> { this._logger.trace('MessageReactions.send();', { messageSerial, params }); // Spec: CHA-MR4a2 assertValidSerial(messageSerial, 'send message reaction', 'messageSerial'); let { type, count } = params; if (!type) { type = this._defaultType; } if (type === MessageReactionType.Multiple && !count) { count = 1; } const apiParams: APISendMessageReactionParams = { type, name: params.name }; if (count) { apiParams.count = count; } return this._api.sendMessageReaction(this._roomName, messageSerial, apiParams); } /** * @inheritDoc */ async delete(messageSerial: string, params?: DeleteMessageReactionParams): Promise<void> { this._logger.trace('MessageReactions.delete();', { messageSerial, params }); // Spec: CHA-MR11a2 assertValidSerial(messageSerial, 'delete message reaction', 'messageSerial'); let type = params?.type; if (!type) { type = this._defaultType; } if (type !== MessageReactionType.Unique && !params?.name) { throw new Ably.ErrorInfo( `unable to delete reaction of type ${type}; name not specified`, ErrorCode.InvalidArgument, 400, ); } const apiParams: APIDeleteMessageReactionParams = { type }; if (type !== MessageReactionType.Unique) { apiParams.name = params?.name; } return this._api.deleteMessageReaction(this._roomName, messageSerial, apiParams); } /** * @inheritDoc */ subscribe(listener: MessageReactionListener): Subscription { this._logger.trace('MessageReactions.subscribe();'); const wrapped = wrap(listener); this._emitter.on(MessageReactionSummaryEventType.Summary, wrapped); return { unsubscribe: () => { this._emitter.off(wrapped); }, }; } /** * @inheritDoc */ subscribeRaw(listener: MessageRawReactionListener): Subscription { this._logger.trace('MessageReactions.subscribeRaw();'); if (!this._options?.rawMessageReactions) { throw new Ably.ErrorInfo( 'unable to subscribe to message reactions; raw message reactions are not enabled', ErrorCode.FeatureNotEnabledInRoom, 400, ); } const wrapped = wrap(listener); this._emitter.on([MessageReactionRawEventType.Create, MessageReactionRawEventType.Delete], wrapped); return { unsubscribe: () => { this._emitter.off(wrapped); }, }; } /** * Merges the channel options to add support for message reactions. * @param roomOptions The room options to merge for. * @returns A function that merges the channel options for the room with the ones required for presence. */ static channelOptionMerger(roomOptions: InternalRoomOptions): ChannelOptionsMerger { return (options) => { // annotation publish is always required for message reactions if (!options.modes.includes('ANNOTATION_PUBLISH')) { options.modes.push('ANNOTATION_PUBLISH'); } // annotation subscribe is only required if the room has raw message reactions if (roomOptions.messages.rawMessageReactions && !options.modes.includes('ANNOTATION_SUBSCRIBE')) { options.modes.push('ANNOTATION_SUBSCRIBE'); } return options; }; } async clientReactions(messageSerial: string, clientId?: string): Promise<Message['reactions']> { this._logger.trace('MessageReactions.clientReactions();', { messageSerial, clientId }); assertValidSerial(messageSerial, 'get client reactions', 'messageSerial'); return this._api.getClientReactions(this._roomName, messageSerial, clientId); } /** * Disposes of the message reactions instance, removing all listeners and subscriptions. * This method should be called when the room is being released to ensure proper cleanup. * @internal */ dispose(): void { this._logger.trace('DefaultMessageReactions.dispose();'); // Remove all user-level listeners from the emitter this._emitter.off(); // Unsubscribe from channel events using stored unsubscribe functions this._unsubscribeMessageEvents(); // Unsubscribe from annotations if they were enabled this._unsubscribeAnnotationEvents?.(); this._logger.debug('DefaultMessageReactions.dispose(); disposed successfully'); } /** * Checks if there are any listeners registered by users. * @internal * @returns true if there are listeners, false otherwise. */ hasListeners(): boolean { return emitterHasListeners(this._emitter); } }