@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
text/typescript
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);
}
}