stream-chat
Version:
JS SDK for the Stream Chat API
1,442 lines (1,323 loc) • 64.7 kB
text/typescript
import { ChannelState } from './channel_state';
import { generateChannelTempCid, logChatPromiseExecution, messageSetPagination, normalizeQuerySort } from './utils';
import { StreamChat } from './client';
import {
APIResponse,
BanUserOptions,
ChannelAPIResponse,
ChannelData,
ChannelFilters,
ChannelMemberAPIResponse,
ChannelMemberResponse,
ChannelQueryOptions,
ChannelResponse,
ChannelUpdateOptions,
CreateCallOptions,
CreateCallResponse,
DefaultGenerics,
DeleteChannelAPIResponse,
Event,
EventAPIResponse,
EventHandler,
EventTypes,
ExtendableGenerics,
FormatMessageResponse,
GetMultipleMessagesAPIResponse,
GetReactionsAPIResponse,
GetRepliesAPIResponse,
InviteOptions,
MarkReadOptions,
MarkUnreadOptions,
MemberFilters,
MemberSort,
Message,
MessageFilters,
MessagePaginationOptions,
MessageResponse,
MessageSetType,
MuteChannelAPIResponse,
NewMemberPayload,
PartialUpdateChannel,
PartialUpdateChannelAPIResponse,
PartialUpdateMember,
PinnedMessagePaginationOptions,
PinnedMessagesSort,
QueryMembersOptions,
Reaction,
ReactionAPIResponse,
SearchAPIResponse,
SearchMessageSortBase,
SearchOptions,
SearchPayload,
SendMessageAPIResponse,
TruncateChannelAPIResponse,
TruncateOptions,
UpdateChannelAPIResponse,
UserResponse,
QueryChannelAPIResponse,
PollVoteData,
SendMessageOptions,
AscDesc,
PartialUpdateMemberAPIResponse,
AIState,
MessageOptions,
PushPreference,
CreateDraftResponse,
GetDraftResponse,
DraftMessagePayload,
} from './types';
import { Role } from './permissions';
import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants';
/**
* Channel - The Channel class manages it's own state.
*/
export class Channel<StreamChatGenerics extends ExtendableGenerics = DefaultGenerics> {
_client: StreamChat<StreamChatGenerics>;
type: string;
id: string | undefined;
data: ChannelData<StreamChatGenerics> | ChannelResponse<StreamChatGenerics> | undefined;
_data: ChannelData<StreamChatGenerics> | ChannelResponse<StreamChatGenerics>;
cid: string;
/** */
listeners: { [key: string]: (string | EventHandler<StreamChatGenerics>)[] };
state: ChannelState<StreamChatGenerics>;
/**
* This boolean is a vague indication of weather the channel exists on chat backend.
*
* If the value is true, then that means the channel has been initialized by either calling
* channel.create() or channel.query() or channel.watch().
*
* If the value is false, then channel may or may not exist on the backend. The only way to ensure
* is by calling channel.create() or channel.query() or channel.watch().
*/
initialized: boolean;
/**
* Indicates weather channel has been initialized by manually populating the state with some messages, members etc.
* Static state indicates that channel exists on backend, but is not being watched yet.
*/
offlineMode: boolean;
lastKeyStroke?: Date;
lastTypingEvent: Date | null;
isTyping: boolean;
disconnected: boolean;
push_preferences?: PushPreference;
/**
* constructor - Create a channel
*
* @param {StreamChat<StreamChatGenerics>} client the chat client
* @param {string} type the type of channel
* @param {string} [id] the id of the chat
* @param {ChannelData<StreamChatGenerics>} data any additional custom params
*
* @return {Channel<StreamChatGenerics>} Returns a new uninitialized channel
*/
constructor(
client: StreamChat<StreamChatGenerics>,
type: string,
id: string | undefined,
data: ChannelData<StreamChatGenerics>,
) {
const validTypeRe = /^[\w_-]+$/;
const validIDRe = /^[\w!_-]+$/;
if (!validTypeRe.test(type)) {
throw new Error(`Invalid chat type ${type}, letters, numbers and "_-" are allowed`);
}
if (typeof id === 'string' && !validIDRe.test(id)) {
throw new Error(`Invalid chat id ${id}, letters, numbers and "!-_" are allowed`);
}
this._client = client;
this.type = type;
this.id = id;
// used by the frontend, gets updated:
this.data = data;
// this._data is used for the requests...
this._data = { ...data };
this.cid = `${type}:${id}`;
this.listeners = {};
// perhaps the state variable should be private
this.state = new ChannelState<StreamChatGenerics>(this);
this.initialized = false;
this.offlineMode = false;
this.lastTypingEvent = null;
this.isTyping = false;
this.disconnected = false;
}
/**
* getClient - Get the chat client for this channel. If client.disconnect() was called, this function will error
*
* @return {StreamChat<StreamChatGenerics>}
*/
getClient(): StreamChat<StreamChatGenerics> {
if (this.disconnected === true) {
throw Error(`You can't use a channel after client.disconnect() was called`);
}
return this._client;
}
/**
* getConfig - Get the config for this channel id (cid)
*
* @return {Record<string, unknown>}
*/
getConfig() {
const client = this.getClient();
return client.configs[this.cid];
}
/**
* sendMessage - Send a message to this channel
*
* @param {Message<StreamChatGenerics>} message The Message object
* @param {boolean} [options.skip_enrich_url] Do not try to enrich the URLs within message
* @param {boolean} [options.skip_push] Skip sending push notifications
* @param {boolean} [options.is_pending_message] DEPRECATED, please use `pending` instead.
* @param {boolean} [options.pending] Make this message pending
* @param {Record<string,string>} [options.pending_message_metadata] Metadata for the pending message
* @param {boolean} [options.force_moderation] Apply force moderation for server-side requests
*
* @return {Promise<SendMessageAPIResponse<StreamChatGenerics>>} The Server Response
*/
async sendMessage(message: Message<StreamChatGenerics>, options?: SendMessageOptions) {
return await this.getClient().post<SendMessageAPIResponse<StreamChatGenerics>>(this._channelURL() + '/message', {
message,
...options,
});
}
sendFile(
uri: string | NodeJS.ReadableStream | Buffer | File,
name?: string,
contentType?: string,
user?: UserResponse<StreamChatGenerics>,
) {
return this.getClient().sendFile(`${this._channelURL()}/file`, uri, name, contentType, user);
}
sendImage(
uri: string | NodeJS.ReadableStream | File,
name?: string,
contentType?: string,
user?: UserResponse<StreamChatGenerics>,
) {
return this.getClient().sendFile(`${this._channelURL()}/image`, uri, name, contentType, user);
}
deleteFile(url: string) {
return this.getClient().delete<APIResponse>(`${this._channelURL()}/file`, { url });
}
deleteImage(url: string) {
return this.getClient().delete<APIResponse>(`${this._channelURL()}/image`, { url });
}
/**
* sendEvent - Send an event on this channel
*
* @param {Event<StreamChatGenerics>} event for example {type: 'message.read'}
*
* @return {Promise<EventAPIResponse<StreamChatGenerics>>} The Server Response
*/
async sendEvent(event: Event<StreamChatGenerics>) {
this._checkInitialized();
return await this.getClient().post<EventAPIResponse<StreamChatGenerics>>(this._channelURL() + '/event', {
event,
});
}
/**
* search - Query messages
*
* @param {MessageFilters<StreamChatGenerics> | string} query search query or object MongoDB style filters
* @param {{client_id?: string; connection_id?: string; query?: string; message_filter_conditions?: MessageFilters<StreamChatGenerics>}} options Option object, {user_id: 'tommaso'}
*
* @return {Promise<SearchAPIResponse<StreamChatGenerics>>} search messages response
*/
async search(
query: MessageFilters<StreamChatGenerics> | string,
options: SearchOptions<StreamChatGenerics> & {
client_id?: string;
connection_id?: string;
message_filter_conditions?: MessageFilters<StreamChatGenerics>;
message_options?: MessageOptions;
query?: string;
} = {},
) {
if (options.offset && options.next) {
throw Error(`Cannot specify offset with next`);
}
// Return a list of channels
const payload: SearchPayload<StreamChatGenerics> = {
filter_conditions: { cid: this.cid } as ChannelFilters<StreamChatGenerics>,
...options,
sort: options.sort ? normalizeQuerySort<SearchMessageSortBase<StreamChatGenerics>>(options.sort) : undefined,
};
if (typeof query === 'string') {
payload.query = query;
} else if (typeof query === 'object') {
payload.message_filter_conditions = query;
} else {
throw Error(`Invalid type ${typeof query} for query parameter`);
}
// Make sure we wait for the connect promise if there is a pending one
await this.getClient().wsPromise;
return await this.getClient().get<SearchAPIResponse<StreamChatGenerics>>(this.getClient().baseURL + '/search', {
payload,
});
}
/**
* queryMembers - Query Members
*
* @param {MemberFilters<StreamChatGenerics>} filterConditions object MongoDB style filters
* @param {MemberSort<StreamChatGenerics>} [sort] Sort options, for instance [{created_at: -1}].
* When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{name: -1}, {created_at: 1}]
* @param {{ limit?: number; offset?: number }} [options] Option object, {limit: 10, offset:10}
*
* @return {Promise<ChannelMemberAPIResponse<StreamChatGenerics>>} Query Members response
*/
async queryMembers(
filterConditions: MemberFilters<StreamChatGenerics>,
sort: MemberSort<StreamChatGenerics> = [],
options: QueryMembersOptions = {},
) {
let id: string | undefined;
const type = this.type;
let members: string[] | ChannelMemberResponse<StreamChatGenerics>[] | undefined;
if (this.id) {
id = this.id;
} else if (this.data?.members && Array.isArray(this.data.members)) {
members = this.data.members;
}
// Return a list of members
return await this.getClient().get<ChannelMemberAPIResponse<StreamChatGenerics>>(
this.getClient().baseURL + '/members',
{
payload: {
type,
id,
members,
sort: normalizeQuerySort(sort),
filter_conditions: filterConditions,
...options,
},
},
);
}
/**
* partialUpdateMember - Partial update a member
*
* @param {string} user_id member user id
* @param {PartialUpdateMember<StreamChatGenerics>} updates
*
* @return {Promise<ChannelMemberResponse<StreamChatGenerics>>} Updated member
*/
async partialUpdateMember(user_id: string, updates: PartialUpdateMember<StreamChatGenerics>) {
if (!user_id) {
throw Error('Please specify the user id');
}
return await this.getClient().patch<PartialUpdateMemberAPIResponse<StreamChatGenerics>>(
this._channelURL() + `/member/${encodeURIComponent(user_id)}`,
updates,
);
}
/**
* sendReaction - Send a reaction about a message
*
* @param {string} messageID the message id
* @param {Reaction<StreamChatGenerics>} reaction the reaction object for instance {type: 'love'}
* @param {{ enforce_unique?: boolean, skip_push?: boolean }} [options] Option object, {enforce_unique: true, skip_push: true} to override any existing reaction or skip sending push notifications
*
* @return {Promise<ReactionAPIResponse<StreamChatGenerics>>} The Server Response
*/
async sendReaction(
messageID: string,
reaction: Reaction<StreamChatGenerics>,
options?: { enforce_unique?: boolean; skip_push?: boolean },
) {
if (!messageID) {
throw Error(`Message id is missing`);
}
if (!reaction || Object.keys(reaction).length === 0) {
throw Error(`Reaction object is missing`);
}
return await this.getClient().post<ReactionAPIResponse<StreamChatGenerics>>(
this.getClient().baseURL + `/messages/${encodeURIComponent(messageID)}/reaction`,
{
reaction,
...options,
},
);
}
/**
* deleteReaction - Delete a reaction by user and type
*
* @param {string} messageID the id of the message from which te remove the reaction
* @param {string} reactionType the type of reaction that should be removed
* @param {string} [user_id] the id of the user (used only for server side request) default null
*
* @return {Promise<ReactionAPIResponse<StreamChatGenerics>>} The Server Response
*/
deleteReaction(messageID: string, reactionType: string, user_id?: string) {
this._checkInitialized();
if (!reactionType || !messageID) {
throw Error('Deleting a reaction requires specifying both the message and reaction type');
}
const url =
this.getClient().baseURL +
`/messages/${encodeURIComponent(messageID)}/reaction/${encodeURIComponent(reactionType)}`;
//provided when server side request
if (user_id) {
return this.getClient().delete<ReactionAPIResponse<StreamChatGenerics>>(url, { user_id });
}
return this.getClient().delete<ReactionAPIResponse<StreamChatGenerics>>(url, {});
}
/**
* update - Edit the channel's custom properties
*
* @param {ChannelData<StreamChatGenerics>} channelData The object to update the custom properties of this channel with
* @param {Message<StreamChatGenerics>} [updateMessage] Optional message object for channel members notification
* @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
* @return {Promise<UpdateChannelAPIResponse<StreamChatGenerics>>} The server response
*/
async update(
channelData: Partial<ChannelData<StreamChatGenerics>> | Partial<ChannelResponse<StreamChatGenerics>> = {},
updateMessage?: Message<StreamChatGenerics>,
options?: ChannelUpdateOptions,
) {
// Strip out reserved names that will result in API errors.
const reserved = [
'config',
'cid',
'created_by',
'id',
'member_count',
'type',
'created_at',
'updated_at',
'last_message_at',
'own_capabilities',
];
reserved.forEach((key) => {
delete channelData[key];
});
return await this._update({
message: updateMessage,
data: channelData,
...options,
});
}
/**
* updatePartial - partial update channel properties
*
* @param {PartialUpdateChannel<StreamChatGenerics>} partial update request
*
* @return {Promise<PartialUpdateChannelAPIResponse<StreamChatGenerics>>}
*/
async updatePartial(update: PartialUpdateChannel<StreamChatGenerics>) {
const data = await this.getClient().patch<PartialUpdateChannelAPIResponse<StreamChatGenerics>>(
this._channelURL(),
update,
);
const areCapabilitiesChanged =
[...(data.channel.own_capabilities || [])].sort().join() !==
[...(Array.isArray(this.data?.own_capabilities) ? (this.data?.own_capabilities as string[]) : [])].sort().join();
this.data = data.channel;
// If the capabiltities are changed, we trigger the `capabilities.changed` event.
if (areCapabilitiesChanged) {
this.getClient().dispatchEvent({
type: 'capabilities.changed',
cid: this.cid,
own_capabilities: data.channel.own_capabilities,
});
}
return data;
}
/**
* enableSlowMode - enable slow mode
*
* @param {number} coolDownInterval the cooldown interval in seconds
* @return {Promise<UpdateChannelAPIResponse<StreamChatGenerics>>} The server response
*/
async enableSlowMode(coolDownInterval: number) {
const data = await this.getClient().post<UpdateChannelAPIResponse<StreamChatGenerics>>(this._channelURL(), {
cooldown: coolDownInterval,
});
this.data = data.channel;
return data;
}
/**
* disableSlowMode - disable slow mode
*
* @return {Promise<UpdateChannelAPIResponse<StreamChatGenerics>>} The server response
*/
async disableSlowMode() {
const data = await this.getClient().post<UpdateChannelAPIResponse<StreamChatGenerics>>(this._channelURL(), {
cooldown: 0,
});
this.data = data.channel;
return data;
}
/**
* delete - Delete the channel. Messages are permanently removed.
*
* @param {boolean} [options.hard_delete] Defines if the channel is hard deleted or not
*
* @return {Promise<DeleteChannelAPIResponse<StreamChatGenerics>>} The server response
*/
async delete(options: { hard_delete?: boolean } = {}) {
return await this.getClient().delete<DeleteChannelAPIResponse<StreamChatGenerics>>(this._channelURL(), {
...options,
});
}
/**
* truncate - Removes all messages from the channel
* @param {TruncateOptions<StreamChatGenerics>} [options] Defines truncation options
* @return {Promise<TruncateChannelAPIResponse<StreamChatGenerics>>} The server response
*/
async truncate(options: TruncateOptions<StreamChatGenerics> = {}) {
return await this.getClient().post<TruncateChannelAPIResponse<StreamChatGenerics>>(
this._channelURL() + '/truncate',
options,
);
}
/**
* acceptInvite - accept invitation to the channel
*
* @param {InviteOptions<StreamChatGenerics>} [options] The object to update the custom properties of this channel with
*
* @return {Promise<UpdateChannelAPIResponse<StreamChatGenerics>>} The server response
*/
async acceptInvite(options: InviteOptions<StreamChatGenerics> = {}) {
return await this._update({ accept_invite: true, ...options });
}
/**
* rejectInvite - reject invitation to the channel
*
* @param {InviteOptions<StreamChatGenerics>} [options] The object to update the custom properties of this channel with
*
* @return {Promise<UpdateChannelAPIResponse<StreamChatGenerics>>} The server response
*/
async rejectInvite(options: InviteOptions<StreamChatGenerics> = {}) {
return await this._update({ reject_invite: true, ...options });
}
/**
* addMembers - add members to the channel
*
* @param {string[] | Array<NewMemberPayload<StreamChatGenerics>>} members An array of members to add to the channel
* @param {Message<StreamChatGenerics>} [message] Optional message object for channel members notification
* @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
* @return {Promise<UpdateChannelAPIResponse<StreamChatGenerics>>} The server response
*/
async addMembers(
members: string[] | Array<NewMemberPayload<StreamChatGenerics>>,
message?: Message<StreamChatGenerics>,
options: ChannelUpdateOptions = {},
) {
return await this._update({ add_members: members, message, ...options });
}
/**
* addModerators - add moderators to the channel
*
* @param {string[]} members An array of member identifiers
* @param {Message<StreamChatGenerics>} [message] Optional message object for channel members notification
* @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
* @return {Promise<UpdateChannelAPIResponse<StreamChatGenerics>>} The server response
*/
async addModerators(members: string[], message?: Message<StreamChatGenerics>, options: ChannelUpdateOptions = {}) {
return await this._update({ add_moderators: members, message, ...options });
}
/**
* assignRoles - sets member roles in a channel
*
* @param {{channel_role: Role, user_id: string}[]} roles List of role assignments
* @param {Message<StreamChatGenerics>} [message] Optional message object for channel members notification
* @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
* @return {Promise<UpdateChannelAPIResponse<StreamChatGenerics>>} The server response
*/
async assignRoles(
roles: { channel_role: Role; user_id: string }[],
message?: Message<StreamChatGenerics>,
options: ChannelUpdateOptions = {},
) {
return await this._update({ assign_roles: roles, message, ...options });
}
/**
* inviteMembers - invite members to the channel
*
* @param {string[] | Array<NewMemberPayload<StreamChatGenerics>>} members An array of members to invite to the channel
* @param {Message<StreamChatGenerics>} [message] Optional message object for channel members notification
* @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
* @return {Promise<UpdateChannelAPIResponse<StreamChatGenerics>>} The server response
*/
async inviteMembers(
members: string[] | Array<NewMemberPayload<StreamChatGenerics>>,
message?: Message<StreamChatGenerics>,
options: ChannelUpdateOptions = {},
) {
return await this._update({ invites: members, message, ...options });
}
/**
* removeMembers - remove members from channel
*
* @param {string[]} members An array of member identifiers
* @param {Message<StreamChatGenerics>} [message] Optional message object for channel members notification
* @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
* @return {Promise<UpdateChannelAPIResponse<StreamChatGenerics>>} The server response
*/
async removeMembers(members: string[], message?: Message<StreamChatGenerics>, options: ChannelUpdateOptions = {}) {
return await this._update({ remove_members: members, message, ...options });
}
/**
* demoteModerators - remove moderator role from channel members
*
* @param {string[]} members An array of member identifiers
* @param {Message<StreamChatGenerics>} [message] Optional message object for channel members notification
* @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
* @return {Promise<UpdateChannelAPIResponse<StreamChatGenerics>>} The server response
*/
async demoteModerators(members: string[], message?: Message<StreamChatGenerics>, options: ChannelUpdateOptions = {}) {
return await this._update({ demote_moderators: members, message, ...options });
}
/**
* _update - executes channel update request
* @param payload Object Update Channel payload
* @return {Promise<UpdateChannelAPIResponse<StreamChatGenerics>>} The server response
* TODO: introduce new type instead of Object in the next major update
*/
async _update(payload: Object) {
const data = await this.getClient().post<UpdateChannelAPIResponse<StreamChatGenerics>>(this._channelURL(), payload);
this.data = data.channel;
return data;
}
/**
* mute - mutes the current channel
* @param {{ user_id?: string, expiration?: string }} opts expiration in minutes or user_id
* @return {Promise<MuteChannelAPIResponse<StreamChatGenerics>>} The server response
*
* example with expiration:
* await channel.mute({expiration: moment.duration(2, 'weeks')});
*
* example server side:
* await channel.mute({user_id: userId});
*
*/
async mute(opts: { expiration?: number; user_id?: string } = {}) {
return await this.getClient().post<MuteChannelAPIResponse<StreamChatGenerics>>(
this.getClient().baseURL + '/moderation/mute/channel',
{ channel_cid: this.cid, ...opts },
);
}
/**
* unmute - mutes the current channel
* @param {{ user_id?: string}} opts user_id
* @return {Promise<APIResponse>} The server response
*
* example server side:
* await channel.unmute({user_id: userId});
*/
async unmute(opts: { user_id?: string } = {}) {
return await this.getClient().post<APIResponse>(this.getClient().baseURL + '/moderation/unmute/channel', {
channel_cid: this.cid,
...opts,
});
}
/**
* archive - archives the current channel
* @param {{ user_id?: string }} opts user_id if called server side
* @return {Promise<ChannelMemberResponse<StreamChatGenerics>>} The server response
*
* example:
* await channel.archives();
*
* example server side:
* await channel.archive({user_id: userId});
*
*/
async archive(opts: { user_id?: string } = {}) {
const cli = this.getClient();
const uid = opts.user_id || cli.userID;
if (!uid) {
throw Error('A user_id is required for archiving a channel');
}
const resp = await this.partialUpdateMember(uid, { set: { archived: true } });
return resp.channel_member;
}
/**
* unarchive - unarchives the current channel
* @param {{ user_id?: string }} opts user_id if called server side
* @return {Promise<ChannelMemberResponse<StreamChatGenerics>>} The server response
*
* example:
* await channel.unarchive();
*
* example server side:
* await channel.unarchive({user_id: userId});
*
*/
async unarchive(opts: { user_id?: string } = {}) {
const cli = this.getClient();
const uid = opts.user_id || cli.userID;
if (!uid) {
throw Error('A user_id is required for unarchiving a channel');
}
const resp = await this.partialUpdateMember(uid, { set: { archived: false } });
return resp.channel_member;
}
/**
* pin - pins the current channel
* @param {{ user_id?: string }} opts user_id if called server side
* @return {Promise<ChannelMemberResponse<StreamChatGenerics>>} The server response
*
* example:
* await channel.pin();
*
* example server side:
* await channel.pin({user_id: userId});
*
*/
async pin(opts: { user_id?: string } = {}) {
const cli = this.getClient();
const uid = opts.user_id || cli.userID;
if (!uid) {
throw new Error('A user_id is required for pinning a channel');
}
const resp = await this.partialUpdateMember(uid, { set: { pinned: true } });
return resp.channel_member;
}
/**
* unpin - unpins the current channel
* @param {{ user_id?: string }} opts user_id if called server side
* @return {Promise<ChannelMemberResponse<StreamChatGenerics>>} The server response
*
* example:
* await channel.unpin();
*
* example server side:
* await channel.unpin({user_id: userId});
*
*/
async unpin(opts: { user_id?: string } = {}) {
const cli = this.getClient();
const uid = opts.user_id || cli.userID;
if (!uid) {
throw new Error('A user_id is required for unpinning a channel');
}
const resp = await this.partialUpdateMember(uid, { set: { pinned: false } });
return resp.channel_member;
}
/**
* muteStatus - returns the mute status for the current channel
* @return {{ muted: boolean; createdAt: Date | null; expiresAt: Date | null }} { muted: true | false, createdAt: Date | null, expiresAt: Date | null}
*/
muteStatus(): {
createdAt: Date | null;
expiresAt: Date | null;
muted: boolean;
} {
this._checkInitialized();
return this.getClient()._muteStatus(this.cid);
}
sendAction(messageID: string, formData: Record<string, string>) {
this._checkInitialized();
if (!messageID) {
throw Error(`Message id is missing`);
}
return this.getClient().post<SendMessageAPIResponse<StreamChatGenerics>>(
this.getClient().baseURL + `/messages/${encodeURIComponent(messageID)}/action`,
{
message_id: messageID,
form_data: formData,
id: this.id,
type: this.type,
},
);
}
/**
* keystroke - First of the typing.start and typing.stop events based on the users keystrokes.
* Call this on every keystroke
* @see {@link https://getstream.io/chat/docs/typing_indicators/?language=js|Docs}
* @param {string} [parent_id] set this field to `message.id` to indicate that typing event is happening in a thread
*/
async keystroke(parent_id?: string, options?: { user_id: string }) {
if (!this._isTypingIndicatorsEnabled()) {
return;
}
const now = new Date();
const diff = this.lastTypingEvent && now.getTime() - this.lastTypingEvent.getTime();
this.lastKeyStroke = now;
this.isTyping = true;
// send a typing.start every 2 seconds
if (diff === null || diff > 2000) {
this.lastTypingEvent = new Date();
await this.sendEvent({
type: 'typing.start',
parent_id,
...(options || {}),
} as Event<StreamChatGenerics>);
}
}
/**
* Sends an event to update the AI state for a specific message.
* Typically used by the server connected to the AI service to notify clients of state changes.
*
* @param messageId - The ID of the message associated with the AI state.
* @param state - The new state of the AI process (e.g., thinking, generating).
* @param options - Optional parameters, such as `ai_message`, to include additional details in the event.
*/
async updateAIState(messageId: string, state: AIState, options: { ai_message?: string } = {}) {
await this.sendEvent({
...options,
type: 'ai_indicator.update',
message_id: messageId,
ai_state: state,
} as Event<StreamChatGenerics>);
}
/**
* Sends an event to notify watchers to clear the typing/thinking UI when the AI response starts streaming.
* Typically used by the server connected to the AI service to inform clients that the AI response has started.
*/
async clearAIIndicator() {
await this.sendEvent({
type: 'ai_indicator.clear',
} as Event<StreamChatGenerics>);
}
/**
* Sends an event to stop AI response generation, leaving the message in its current state.
* Triggered by the user to halt the AI response process.
*/
async stopAIResponse() {
await this.sendEvent({
type: 'ai_indicator.stop',
} as Event<StreamChatGenerics>);
}
/**
* stopTyping - Sets last typing to null and sends the typing.stop event
* @see {@link https://getstream.io/chat/docs/typing_indicators/?language=js|Docs}
* @param {string} [parent_id] set this field to `message.id` to indicate that typing event is happening in a thread
*/
async stopTyping(parent_id?: string, options?: { user_id: string }) {
if (!this._isTypingIndicatorsEnabled()) {
return;
}
this.lastTypingEvent = null;
this.isTyping = false;
await this.sendEvent({
type: 'typing.stop',
parent_id,
...(options || {}),
} as Event<StreamChatGenerics>);
}
_isTypingIndicatorsEnabled(): boolean {
if (!this.getConfig()?.typing_events) {
return false;
}
return this.getClient().user?.privacy_settings?.typing_indicators?.enabled ?? true;
}
/**
* lastMessage - return the last message, takes into account that last few messages might not be perfectly sorted
*
* @return {ReturnType<ChannelState<StreamChatGenerics>['formatMessage']> | undefined} Description
*/
lastMessage(): FormatMessageResponse<StreamChatGenerics> | undefined {
// get last 5 messages, sort, return the latest
// get a slice of the last 5
let min = this.state.latestMessages.length - 5;
if (min < 0) {
min = 0;
}
const max = this.state.latestMessages.length + 1;
const messageSlice = this.state.latestMessages.slice(min, max);
// sort by pk desc
messageSlice.sort((a, b) => b.created_at.getTime() - a.created_at.getTime());
return messageSlice[0];
}
/**
* markRead - Send the mark read event for this user, only works if the `read_events` setting is enabled
*
* @param {MarkReadOptions<StreamChatGenerics>} data
* @return {Promise<EventAPIResponse<StreamChatGenerics> | null>} Description
*/
async markRead(data: MarkReadOptions<StreamChatGenerics> = {}) {
this._checkInitialized();
if (!this.getConfig()?.read_events && !this.getClient()._isUsingServerAuth()) {
return Promise.resolve(null);
}
return await this.getClient().post<EventAPIResponse<StreamChatGenerics>>(this._channelURL() + '/read', {
...data,
});
}
/**
* markUnread - Mark the channel as unread from messageID, only works if the `read_events` setting is enabled
*
* @param {MarkUnreadOptions<StreamChatGenerics>} data
* @return {APIResponse} An API response
*/
async markUnread(data: MarkUnreadOptions<StreamChatGenerics>) {
this._checkInitialized();
if (!this.getConfig()?.read_events && !this.getClient()._isUsingServerAuth()) {
return Promise.resolve(null);
}
return await this.getClient().post<APIResponse>(this._channelURL() + '/unread', {
...data,
});
}
/**
* clean - Cleans the channel state and fires stop typing if needed
*/
clean() {
if (this.lastKeyStroke) {
const now = new Date();
const diff = now.getTime() - this.lastKeyStroke.getTime();
if (diff > 1000 && this.isTyping) {
logChatPromiseExecution(this.stopTyping(), 'stop typing event');
}
}
this.state.clean();
}
/**
* watch - Loads the initial channel state and watches for changes
*
* @param {ChannelQueryOptions<StreamChatGenerics>} options additional options for the query endpoint
*
* @return {Promise<QueryChannelAPIResponse<StreamChatGenerics>>} The server response
*/
async watch(options?: ChannelQueryOptions<StreamChatGenerics>) {
const defaultOptions = {
state: true,
watch: true,
presence: false,
};
// Make sure we wait for the connect promise if there is a pending one
await this.getClient().wsPromise;
if (!this.getClient()._hasConnectionID()) {
defaultOptions.watch = false;
}
const combined = { ...defaultOptions, ...options };
const state = await this.query(combined, 'latest');
this.initialized = true;
this.data = state.channel;
this._client.logger('info', `channel:watch() - started watching channel ${this.cid}`, {
tags: ['channel'],
channel: this,
});
return state;
}
/**
* stopWatching - Stops watching the channel
*
* @return {Promise<APIResponse>} The server response
*/
async stopWatching() {
const response = await this.getClient().post<APIResponse>(this._channelURL() + '/stop-watching', {});
this._client.logger('info', `channel:watch() - stopped watching channel ${this.cid}`, {
tags: ['channel'],
channel: this,
});
return response;
}
/**
* getReplies - List the message replies for a parent message.
*
* The recommended way of working with threads is to use the Thread class.
*
* @param {string} parent_id The message parent id, ie the top of the thread
* @param {MessagePaginationOptions & { user?: UserResponse<StreamChatGenerics>; user_id?: string }} options Pagination params, ie {limit:10, id_lte: 10}
*
* @return {Promise<GetRepliesAPIResponse<StreamChatGenerics>>} A response with a list of messages
*/
async getReplies(
parent_id: string,
options: MessagePaginationOptions & { user?: UserResponse<StreamChatGenerics>; user_id?: string },
sort?: { created_at: AscDesc }[],
) {
const normalizedSort = sort ? normalizeQuerySort(sort) : undefined;
const data = await this.getClient().get<GetRepliesAPIResponse<StreamChatGenerics>>(
this.getClient().baseURL + `/messages/${encodeURIComponent(parent_id)}/replies`,
{
sort: normalizedSort,
...options,
},
);
// add any messages to our thread state
if (data.messages) {
this.state.addMessagesSorted(data.messages);
}
return data;
}
/**
* getPinnedMessages - List list pinned messages of the channel
*
* @param {PinnedMessagePaginationOptions & { user?: UserResponse<StreamChatGenerics>; user_id?: string }} options Pagination params, ie {limit:10, id_lte: 10}
* @param {PinnedMessagesSort} sort defines sorting direction of pinned messages
*
* @return {Promise<GetRepliesAPIResponse<StreamChatGenerics>>} A response with a list of messages
*/
async getPinnedMessages(
options: PinnedMessagePaginationOptions & { user?: UserResponse<StreamChatGenerics>; user_id?: string },
sort: PinnedMessagesSort = [],
) {
return await this.getClient().get<GetRepliesAPIResponse<StreamChatGenerics>>(
this._channelURL() + '/pinned_messages',
{
payload: {
...options,
sort: normalizeQuerySort(sort),
},
},
);
}
/**
* getReactions - List the reactions, supports pagination
*
* @param {string} message_id The message id
* @param {{ limit?: number; offset?: number }} options The pagination options
*
* @return {Promise<GetReactionsAPIResponse<StreamChatGenerics>>} Server response
*/
getReactions(message_id: string, options: { limit?: number; offset?: number }) {
return this.getClient().get<GetReactionsAPIResponse<StreamChatGenerics>>(
this.getClient().baseURL + `/messages/${encodeURIComponent(message_id)}/reactions`,
{
...options,
},
);
}
/**
* getMessagesById - Retrieves a list of messages by ID
*
* @param {string[]} messageIds The ids of the messages to retrieve from this channel
*
* @return {Promise<GetMultipleMessagesAPIResponse<StreamChatGenerics>>} Server response
*/
getMessagesById(messageIds: string[]) {
return this.getClient().get<GetMultipleMessagesAPIResponse<StreamChatGenerics>>(this._channelURL() + '/messages', {
ids: messageIds.join(','),
});
}
/**
* lastRead - returns the last time the user marked the channel as read if the user never marked the channel as read, this will return null
* @return {Date | null | undefined}
*/
lastRead() {
const { userID } = this.getClient();
if (userID) {
return this.state.read[userID] ? this.state.read[userID].last_read : null;
}
}
_countMessageAsUnread(message: FormatMessageResponse<StreamChatGenerics> | MessageResponse<StreamChatGenerics>) {
if (message.shadowed) return false;
if (message.silent) return false;
if (message.parent_id && !message.show_in_channel) return false;
if (message.user?.id === this.getClient().userID) return false;
if (message.user?.id && this.getClient().userMuteStatus(message.user.id)) return false;
// Return false if channel doesn't allow read events.
if (Array.isArray(this.data?.own_capabilities) && !this.data?.own_capabilities.includes('read-events'))
return false;
// FIXME: see #1265, adjust and count new messages even when the channel is muted
if (this.muteStatus().muted) return false;
return true;
}
/**
* countUnread - Count of unread messages
*
* @param {Date | null} [lastRead] lastRead the time that the user read a message, defaults to current user's read state
*
* @return {number} Unread count
*/
countUnread(lastRead?: Date | null) {
if (!lastRead) return this.state.unreadCount;
let count = 0;
for (let i = 0; i < this.state.latestMessages.length; i += 1) {
const message = this.state.latestMessages[i];
if (message.created_at > lastRead && this._countMessageAsUnread(message)) {
count++;
}
}
return count;
}
/**
* countUnreadMentions - Count the number of unread messages mentioning the current user
*
* @return {number} Unread mentions count
*/
countUnreadMentions() {
const lastRead = this.lastRead();
const userID = this.getClient().userID;
let count = 0;
for (let i = 0; i < this.state.latestMessages.length; i += 1) {
const message = this.state.latestMessages[i];
if (
this._countMessageAsUnread(message) &&
(!lastRead || message.created_at > lastRead) &&
message.mentioned_users?.some((user) => user.id === userID)
) {
count++;
}
}
return count;
}
/**
* create - Creates a new channel
*
* @return {Promise<QueryChannelAPIResponse<StreamChatGenerics>>} The Server Response
*
*/
create = async (options?: ChannelQueryOptions<StreamChatGenerics>) => {
const defaultOptions = {
...options,
watch: false,
state: false,
presence: false,
};
return await this.query(defaultOptions, 'latest');
};
/**
* query - Query the API, get messages, members or other channel fields
*
* @param {ChannelQueryOptions<StreamChatGenerics>} options The query options
* @param {MessageSetType} messageSetToAddToIfDoesNotExist It's possible to load disjunct sets of a channel's messages into state, use `current` to load the initial channel state or if you want to extend the currently displayed messages, use `latest` if you want to load/extend the latest messages, `new` is used for loading a specific message and it's surroundings
*
* @return {Promise<QueryChannelAPIResponse<StreamChatGenerics>>} Returns a query response
*/
async query(
options?: ChannelQueryOptions<StreamChatGenerics>,
messageSetToAddToIfDoesNotExist: MessageSetType = 'current',
) {
// Make sure we wait for the connect promise if there is a pending one
await this.getClient().wsPromise;
let queryURL = `${this.getClient().baseURL}/channels/${encodeURIComponent(this.type)}`;
if (this.id) {
queryURL += `/${encodeURIComponent(this.id)}`;
}
const state = await this.getClient().post<QueryChannelAPIResponse<StreamChatGenerics>>(queryURL + '/query', {
data: this._data,
state: true,
...options,
});
// update the channel id if it was missing
if (!this.id) {
this.id = state.channel.id;
this.cid = state.channel.cid;
// set the channel as active...
const tempChannelCid = generateChannelTempCid(
this.type,
state.members.map((member) => member.user_id || member.user?.id || ''),
);
if (tempChannelCid && tempChannelCid in this.getClient().activeChannels) {
// This gets set in `client.channel()` function, when channel is created
// using members, not id.
delete this.getClient().activeChannels[tempChannelCid];
}
if (!(this.cid in this.getClient().activeChannels) && this.getClient()._cacheEnabled()) {
this.getClient().activeChannels[this.cid] = this;
}
}
this.getClient()._addChannelConfig(state.channel);
// add any messages to our channel state
const { messageSet } = this._initializeState(state, messageSetToAddToIfDoesNotExist);
messageSet.pagination = {
...messageSet.pagination,
...messageSetPagination({
parentSet: messageSet,
messagePaginationOptions: options?.messages,
requestedPageSize: options?.messages?.limit ?? DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE,
returnedPage: state.messages,
logger: this.getClient().logger,
}),
};
this.getClient().polls.hydratePollCache(state.messages, true);
const areCapabilitiesChanged =
[...(state.channel.own_capabilities || [])].sort().join() !==
[...(Array.isArray(this.data?.own_capabilities) ? (this.data?.own_capabilities as string[]) : [])].sort().join();
this.data = state.channel;
this.offlineMode = false;
if (areCapabilitiesChanged) {
this.getClient().dispatchEvent({
type: 'capabilities.changed',
cid: this.cid,
own_capabilities: state.channel.own_capabilities,
});
}
this.getClient().dispatchEvent({
type: 'channels.queried',
queriedChannels: {
channels: [state],
isLatestMessageSet: messageSet.isLatest,
},
});
return state;
}
/**
* banUser - Bans a user from a channel
*
* @param {string} targetUserID
* @param {BanUserOptions<StreamChatGenerics>} options
* @returns {Promise<APIResponse>}
*/
async banUser(targetUserID: string, options: BanUserOptions<StreamChatGenerics>) {
this._checkInitialized();
return await this.getClient().banUser(targetUserID, {
...options,
type: this.type,
id: this.id,
});
}
/**
* hides the channel from queryChannels for the user until a message is added
* If clearHistory is set to true - all messages will be removed for the user
*
* @param {string | null} userId
* @param {boolean} clearHistory
* @returns {Promise<APIResponse>}
*/
async hide(userId: string | null = null, clearHistory = false) {
this._checkInitialized();
return await this.getClient().post<APIResponse>(`${this._channelURL()}/hide`, {
user_id: userId,
clear_history: clearHistory,
});
}
/**
* removes the hidden status for a channel
*
* @param {string | null} userId
* @returns {Promise<APIResponse>}
*/
async show(userId: string | null = null) {
this._checkInitialized();
return await this.getClient().post<APIResponse>(`${this._channelURL()}/show`, {
user_id: userId,
});
}
/**
* unbanUser - Removes the bans for a user on a channel
*
* @param {string} targetUserID
* @returns {Promise<APIResponse>}
*/
async unbanUser(targetUserID: string) {
this._checkInitialized();
return await this.getClient().unbanUser(targetUserID, {
type: this.type,
id: this.id,
});
}
/**
* shadowBan - Shadow bans a user from a channel
*
* @param {string} targetUserID
* @param {BanUserOptions<StreamChatGenerics>} options
* @returns {Promise<APIResponse>}
*/
async shadowBan(targetUserID: string, options: BanUserOptions<StreamChatGenerics>) {
this._checkInitialized();
return await this.getClient().shadowBan(targetUserID, {
...options,
type: this.type,
id: this.id,
});
}
/**
* removeShadowBan - Removes the shadow ban for a user on a channel
*
* @param {string} targetUserID
* @returns {Promise<APIResponse>}
*/
async removeShadowBan(targetUserID: string) {
this._checkInitialized();
return await this.getClient().removeShadowBan(targetUserID, {
type: this.type,
id: this.id,
});
}
/**
* createCall - creates a call for the current channel
*
* @param {CreateCallOptions} options
* @returns {Promise<CreateCallResponse>}
*/
async createCall(options: CreateCallOptions) {
return await this.getClient().post<CreateCallResponse>(this._channelURL() + '/call', options);
}
/**
* Cast or cancel one or more votes on a poll
* @param pollId string The poll id
* @param votes PollVoteData[] The votes that will be casted (or canceled in case of an empty array)
* @returns {APIResponse & PollVoteResponse} The poll votes
*/
async vote(messageId: string, pollId: string, vote: PollVoteData) {
return await this.getClient().castPollVote(messageId, pollId, vote);
}
async removeVote(messageId: string, pollId: string, voteId: string) {
return await this.getClient().removePollVote(messageId, pollId, voteId);
}
/**
* createDraft - Creates or updates a draft message in a channel
*
* @param {string} channelType The channel type
* @param {string} channelID The channel ID
* @param {DraftMessagePayload<StreamChatGenerics>} message The draft message to create or update
*
* @return {Promise<CreateDraftResponse<StreamChatGenerics>>} Response containing the created draft
*/
async createDraft(message: DraftMessagePayload<StreamChatGenerics>) {
return await this.getClient().post<CreateDraftResponse<StreamChatGenerics>>(this._channelURL() + '/draft', {
message,
});
}
/**
* deleteDraft - Deletes a draft message from a channel
*
* @param {Object} options
* @param {string} options.parent_id Optional parent message ID for drafts in threads
*
* @return {Promise<APIResponse>} API response
*/
async deleteDraft({ parent_id }: { parent_id?: string } = {}) {
return await this.getClient().delete<APIResponse>(this._channelURL() + '/draft', { parent_id });
}
/**
* getDraft - Retrieves a draft message from a channel
*
* @param {Object} options
* @param {string} options.parent_id Optional parent message ID for drafts in threads
*
* @return {Promise<GetDraftResponse<StreamChatGenerics>>} Response containing the draft
*/
async getDraft({ parent_id }: { parent_id?: string } = {}) {
return await this.getClient().get<GetDraftResponse<StreamChatGenerics>>(this._channelURL() + '/draft', {
parent_id,
});
}
/**
* on - Listen to events on this channel.
*
* channel.on('message.new', event => {console.log("my new message", event, channel.state.messages)})
* or
* channel.on(event => {console.log(event.type)})
*
* @param {EventHandler<StreamChatGenerics> | EventTypes} callbackOrString The event type to listen for (optional)
* @param {EventHandler<StreamChatGenerics>} [callbackOrNothing] The callback to call
*/
on(eventType: EventTypes, callback: EventHandler<StreamChatGenerics>): { unsubscribe: () => void };
on(callback: EventHandler<StreamChatGenerics>): { unsubscribe: () => void };
on(
callbackOrString: EventHandler<StreamChatGenerics> | EventTypes,
callbackOrNothing?: EventHandler<StreamChatGenerics>,
): { unsubscribe: () => void } {
const key = callbackOrNothing ? (callbackOrString as string) : 'all';
const callback = callbackOrNothing ? callbackOrNothing : callbackOrString;
if (!(key in this.listeners)) {
this.listeners[key] = [];
}
this._client.logger('info', `Attaching listener for ${key} event on channel ${this.cid}`, {
tags: ['event', 'channel'],
channel: this,
});
this.listeners[key].push(callback);
return {
unsubscribe: () => {
this._client.logger('info', `Removing listener for ${key} event from channel ${this.cid}`, {
tags: ['event', 'channel'],
channel: this,
});
this.listeners[key] = this.listeners[key].filter((el) => el !== callback);
},
};
}
/**
* off - Remove the event handler
*
*/
off(eventType: Ev