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

380 lines (334 loc) 13.7 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 { AnnotationTypeToReactionType, ChatMessageAction, MessageReactionEventType, MessageReactionRawEvent, MessageReactionSummaryEvent, MessageReactionType, ReactionAnnotationType, } from './events.js'; import { Logger } from './logger.js'; import { Message } from './message.js'; import { subscribe } from './realtime-subscriptions.js'; import { InternalRoomOptions, MessageOptions } from './room-options.js'; import { Serial, serialToString } 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 MessageOptions.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 MessageOptions.defaultMessageReactionType} of the room. */ type?: MessageReactionType; } /** * Send, delete, and subscribe to message reactions. */ export interface MessagesReactions { /** * 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: Serial, 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: Serial, 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.summary.messageSerial, * myClientId * ); * if (clientReactions.unique && clientReactions.unique['👍']) { * // client has reacted with 👍 * event.summary.unique['👍'].clientIds.push(myClientId); * } * } * // from here, process the summary as usual * }); * ``` */ clientReactions(messageSerial: Serial, clientId?: string): Promise<Message['reactions']>; } /** * Maps Ably PubSub annotation action to message reaction event type. */ const eventTypeMap: Record<string, MessageReactionEventType.Create | MessageReactionEventType.Delete> = { 'annotation.create': MessageReactionEventType.Create, 'annotation.delete': MessageReactionEventType.Delete, }; /** * @inheritDoc */ export class DefaultMessageReactions implements MessagesReactions { private _emitter = new EventEmitter<{ [MessageReactionEventType.Create]: MessageReactionRawEvent; [MessageReactionEventType.Delete]: MessageReactionRawEvent; [MessageReactionEventType.Summary]: MessageReactionSummaryEvent; }>(); private readonly _defaultType: MessageReactionType; private readonly _unsubscribeMessageEvents: () => void; private readonly _unsubscribeAnnotationEvents?: () => void; constructor( private readonly _logger: Logger, private readonly _options: MessageOptions | 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('MessagesReactions._processAnnotationEvent();', { event }); // If we don't know the reaction type, ignore it const reactionType = AnnotationTypeToReactionType[event.type]; if (!reactionType) { this._logger.info('MessagesReactions._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('MessagesReactions._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 === MessageReactionEventType.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('MessagesReactions._processMessageEvent();', { event }); // only process summary events if (event.action !== ChatMessageAction.MessageAnnotationSummary) { return; } if (!event.summary) { // This means the summary is now empty, which is valid. // Happens when there are no reactions such as after deleting the last reaction. event.summary = {}; } const unique = (event.summary[ReactionAnnotationType.Unique] ?? {}) as unknown as Ably.SummaryUniqueValues; const distinct = (event.summary[ReactionAnnotationType.Distinct] ?? {}) as unknown as Ably.SummaryDistinctValues; const multiple = (event.summary[ReactionAnnotationType.Multiple] ?? {}) as Ably.SummaryMultipleValues; this._emitter.emit(MessageReactionEventType.Summary, { type: MessageReactionEventType.Summary, summary: { messageSerial: event.serial, unique: unique, distinct: distinct, multiple: multiple, }, }); } /** * @inheritDoc */ send(messageSerial: Serial, params: SendMessageReactionParams): Promise<void> { this._logger.trace('MessagesReactions.send();', { messageSerial, params }); const serial = serialToString(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, serial, apiParams); } /** * @inheritDoc */ delete(messageSerial: Serial, params?: DeleteMessageReactionParams): Promise<void> { this._logger.trace('MessagesReactions.delete();', { messageSerial, params }); const serial = serialToString(messageSerial); let type = params?.type; if (!type) { type = this._defaultType; } if (type !== MessageReactionType.Unique && !params?.name) { throw new Ably.ErrorInfo(`cannot delete reaction of type ${type} without a name`, 40001, 400); } const apiParams: APIDeleteMessageReactionParams = { type }; if (type !== MessageReactionType.Unique) { apiParams.name = params?.name; } return this._api.deleteMessageReaction(this._roomName, serial, apiParams); } /** * @inheritDoc */ subscribe(listener: MessageReactionListener): Subscription { this._logger.trace('MessagesReactions.subscribe();'); const wrapped = wrap(listener); this._emitter.on(MessageReactionEventType.Summary, wrapped); return { unsubscribe: () => { this._emitter.off(wrapped); }, }; } /** * @inheritDoc */ subscribeRaw(listener: MessageRawReactionListener): Subscription { this._logger.trace('MessagesReactions.subscribeRaw();'); if (!this._options?.rawMessageReactions) { throw new Ably.ErrorInfo('Raw message reactions are not enabled', 40001, 400); } const wrapped = wrap(listener); this._emitter.on([MessageReactionEventType.Create, MessageReactionEventType.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; }; } clientReactions(messageSerial: Serial, clientId?: string): Promise<Message['reactions']> { this._logger.trace('MessagesReactions.clientReactions();', { messageSerial, clientId }); const serial = serialToString(messageSerial); return this._api.getClientReactions(this._roomName, serial, 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); } }