UNPKG

stream-chat

Version:

JS SDK for the Stream Chat API

1,624 lines (1,468 loc) 145 kB
/* eslint no-unused-vars: "off" */ /* global process */ import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import axios from 'axios'; import https from 'https'; import type WebSocket from 'isomorphic-ws'; import { Channel } from './channel'; import { ClientState } from './client_state'; import { StableWSConnection } from './connection'; import { CheckSignature, DevToken, JWTUserToken } from './signing'; import { TokenManager } from './token_manager'; import { WSConnectionFallback } from './connection_fallback'; import { Campaign } from './campaign'; import { Segment } from './segment'; import { isErrorResponse, isWSFailure } from './errors'; import { addFileToFormData, axiosParamsSerializer, chatCodes, generateChannelTempCid, isFunction, isOnline, isOwnUserBaseProperty, messageSetPagination, normalizeQuerySort, randomId, retryInterval, sleep, toUpdatedMessagePayload, } from './utils'; import type { ActiveLiveLocationsAPIResponse, APIErrorResponse, APIResponse, AppSettings, AppSettingsAPIResponse, BannedUsersFilters, BannedUsersPaginationOptions, BannedUsersResponse, BannedUsersSort, BanUserOptions, BaseDeviceFields, BlockList, BlockListResponse, BlockUserAPIResponse, CampaignData, CampaignFilters, CampaignQueryOptions, CampaignResponse, CampaignSort, CastVoteAPIResponse, ChannelAPIResponse, ChannelData, ChannelFilters, ChannelMute, ChannelOptions, ChannelResponse, ChannelSort, ChannelStateOptions, CheckPushResponse, CheckSNSResponse, CheckSQSResponse, Configs, ConnectAPIResponse, CreateChannelOptions, CreateChannelResponse, CreateCommandOptions, CreateCommandResponse, CreateImportOptions, CreateImportResponse, CreateImportURLResponse, CreatePollAPIResponse, CreatePollData, CreatePollOptionAPIResponse, CreateReminderOptions, CustomPermissionOptions, DeactivateUsersOptions, DeleteChannelsResponse, DeleteCommandResponse, DeleteUserOptions, Device, DeviceIdentifier, DraftFilters, DraftSort, EndpointName, Event, EventHandler, ExportChannelOptions, ExportChannelRequest, ExportChannelResponse, ExportChannelStatusResponse, ExportUsersRequest, ExportUsersResponse, FlagMessageResponse, FlagReportsFilters, FlagReportsPaginationOptions, FlagReportsResponse, FlagsFilters, FlagsPaginationOptions, FlagsResponse, FlagUserResponse, GetBlockedUsersAPIResponse, GetCampaignOptions, GetChannelTypeResponse, GetCommandResponse, GetHookEventsResponse, GetImportResponse, GetMessageAPIResponse, GetMessageOptions, GetPollAPIResponse, GetPollOptionAPIResponse, GetRateLimitsResponse, GetThreadAPIResponse, GetThreadOptions, GetUnreadCountAPIResponse, GetUnreadCountBatchAPIResponse, ListChannelResponse, ListCommandsResponse, ListImportsPaginationOptions, ListImportsResponse, LocalMessage, Logger, MarkChannelsReadOptions, MessageFilters, MessageFlagsFilters, MessageFlagsPaginationOptions, MessageFlagsResponse, MessageResponse, Mute, MuteUserOptions, MuteUserResponse, NewMemberPayload, OGAttachment, OwnUserResponse, Pager, PartialMessageUpdate, PartialPollUpdate, PartialThreadUpdate, PartialUserUpdate, PermissionAPIResponse, PermissionsAPIResponse, PollAnswersAPIResponse, PollData, PollOptionData, PollSort, PollVote, PollVoteData, PollVotesAPIResponse, Product, PushPreference, PushProvider, PushProviderConfig, PushProviderID, PushProviderListResponse, PushProviderUpsertResponse, QueryChannelsAPIResponse, QueryDraftsResponse, QueryMessageHistoryFilters, QueryMessageHistoryOptions, QueryMessageHistoryResponse, QueryMessageHistorySort, QueryPollsFilters, QueryPollsOptions, QueryPollsResponse, QueryReactionsAPIResponse, QueryReactionsOptions, QueryRemindersOptions, QueryRemindersResponse, QuerySegmentsOptions, QuerySegmentTargetsFilter, QueryThreadsAPIResponse, QueryThreadsOptions, QueryVotesFilters, QueryVotesOptions, ReactionFilters, ReactionResponse, ReactionSort, ReactivateUserOptions, ReactivateUsersOptions, ReminderAPIResponse, ReviewFlagReportOptions, ReviewFlagReportResponse, SdkIdentifier, SearchAPIResponse, SearchMessageSortBase, SearchOptions, SearchPayload, SegmentData, SegmentResponse, SegmentTargetsResponse, SegmentType, SendFileAPIResponse, SharedLocationResponse, SortParam, StreamChatOptions, SyncOptions, SyncResponse, TaskResponse, TaskStatus, TestPushDataInput, TestSNSDataInput, TestSQSDataInput, TokenOrProvider, TranslateResponse, UnBanUserOptions, UpdateChannelTypeRequest, UpdateChannelTypeResponse, UpdateCommandOptions, UpdateCommandResponse, UpdateLocationPayload, UpdateMessageAPIResponse, UpdateMessageOptions, UpdatePollAPIResponse, UpdatePollOptionAPIResponse, UpdateReminderOptions, UpdateSegmentData, UpsertPushPreferencesResponse, UserCustomEvent, UserFilters, UserOptions, UserResponse, UserSort, VoteSort, } from './types'; import { ErrorFromResponse } from './types'; import { InsightMetrics, postInsights } from './insights'; import { Thread } from './thread'; import { Moderation } from './moderation'; import { ThreadManager } from './thread_manager'; import { DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE } from './constants'; import { PollManager } from './poll_manager'; import type { ChannelManagerEventHandlerOverrides, ChannelManagerOptions, QueryChannelsRequestType, } from './channel_manager'; import { ChannelManager } from './channel_manager'; import { NotificationManager } from './notifications'; import { ReminderManager } from './reminders'; import { StateStore } from './store'; import type { MessageComposer } from './messageComposer'; import type { AbstractOfflineDB } from './offline-support'; function isString(x: unknown): x is string { return typeof x === 'string' || x instanceof String; } type MessageComposerTearDownFunction = () => void; type MessageComposerSetupFunction = ({ composer, }: { composer: MessageComposer; }) => void | MessageComposerTearDownFunction; export type MessageComposerSetupState = { /** * Each `MessageComposer` runs this function each time its signature changes or * whenever you run `MessageComposer.registerSubscriptions`. Function returned * from `applyModifications` will be used as a cleanup function - it will be stored * and ran before new modification is applied. Cleaning up only the * modified parts is the general way to go but if your setup gets a bit * complicated, feel free to restore the whole composer with `MessageComposer.restore`. */ setupFunction: MessageComposerSetupFunction | null; }; export class StreamChat { private static _instance?: unknown | StreamChat; // type is undefined|StreamChat, unknown is due to TS limitations with statics _user?: OwnUserResponse | UserResponse; appSettingsPromise?: Promise<AppSettingsAPIResponse>; activeChannels: { [key: string]: Channel; }; threads: ThreadManager; polls: PollManager; offlineDb?: AbstractOfflineDB; notifications: NotificationManager; reminders: ReminderManager; anonymous: boolean; persistUserOnConnectionFailure?: boolean; axiosInstance: AxiosInstance; baseURL?: string; browser: boolean; cleaningIntervalRef?: NodeJS.Timeout; clientID?: string; configs: Configs; key: string; listeners: Record<string, Array<(event: Event) => void>>; logger: Logger; /** * When network is recovered, we re-query the active channels on client. But in single query, you can recover * only 30 channels. So its not guaranteed that all the channels in activeChannels object have updated state. * Thus in UI sdks, state recovery is managed by components themselves, they don't rely on js client for this. * * `recoverStateOnReconnect` parameter can be used in such cases, to disable state recovery within js client. * When false, user/consumer of this client will need to make sure all the channels present on UI by * manually calling queryChannels endpoint. */ recoverStateOnReconnect?: boolean; moderation: Moderation; mutedChannels: ChannelMute[]; mutedUsers: Mute[]; node: boolean; options: StreamChatOptions; secret?: string; setUserPromise: ConnectAPIResponse | null; state: ClientState; tokenManager: TokenManager; user?: OwnUserResponse | UserResponse; userAgent?: string; userID?: string; wsBaseURL?: string; wsConnection: StableWSConnection | null; wsFallback?: WSConnectionFallback; wsPromise: ConnectAPIResponse | null; consecutiveFailures: number; insightMetrics: InsightMetrics; defaultWSTimeoutWithFallback: number; defaultWSTimeout: number; sdkIdentifier?: SdkIdentifier; deviceIdentifier?: DeviceIdentifier; private nextRequestAbortController: AbortController | null = null; /** * @private */ _messageComposerSetupState = new StateStore<MessageComposerSetupState>({ setupFunction: null, }); /** * Initialize a client * * **Only use constructor for advanced usages. It is strongly advised to use `StreamChat.getInstance()` instead of `new StreamChat()` to reduce integration issues due to multiple WebSocket connections** * @param {string} key - the api key * @param {string} [secret] - the api secret * @param {StreamChatOptions} [options] - additional options, here you can pass custom options to axios instance * @param {boolean} [options.browser] - enforce the client to be in browser mode * @param {boolean} [options.warmUp] - default to false, if true, client will open a connection as soon as possible to speed up following requests * @param {Logger} [options.Logger] - custom logger * @param {number} [options.timeout] - default to 3000 * @param {httpsAgent} [options.httpsAgent] - custom httpsAgent, in node it's default to https.agent() * @example <caption>initialize the client in user mode</caption> * new StreamChat('api_key') * @example <caption>initialize the client in user mode with options</caption> * new StreamChat('api_key', { warmUp:true, timeout:5000 }) * @example <caption>secret is optional and only used in server side mode</caption> * new StreamChat('api_key', "secret", { httpsAgent: customAgent }) */ constructor(key: string, options?: StreamChatOptions); constructor(key: string, secret?: string, options?: StreamChatOptions); constructor( key: string, secretOrOptions?: StreamChatOptions | string, options?: StreamChatOptions, ) { // set the key this.key = key; this.listeners = {}; this.state = new ClientState({ client: this }); // a list of channels to hide ws events from this.mutedChannels = []; this.mutedUsers = []; this.moderation = new Moderation(this); this.notifications = options?.notifications ?? new NotificationManager(); // set the secret if (secretOrOptions && isString(secretOrOptions)) { this.secret = secretOrOptions; } // set the options... and figure out defaults... const inputOptions = options ? options : secretOrOptions && !isString(secretOrOptions) ? secretOrOptions : {}; this.browser = typeof inputOptions.browser !== 'undefined' ? inputOptions.browser : typeof window !== 'undefined'; this.node = !this.browser; this.options = { timeout: 3000, withCredentials: false, // making sure cookies are not sent warmUp: false, recoverStateOnReconnect: true, disableCache: false, wsUrlParams: new URLSearchParams({}), ...inputOptions, }; if (this.node && !this.options.httpsAgent) { this.options.httpsAgent = new https.Agent({ keepAlive: true, keepAliveMsecs: 3000, }); } this.axiosInstance = axios.create(this.options); this.setBaseURL(this.options.baseURL || 'https://chat.stream-io-api.com'); if ( typeof process !== 'undefined' && 'env' in process && process.env.STREAM_LOCAL_TEST_RUN ) { this.setBaseURL('http://localhost:3030'); } if ( typeof process !== 'undefined' && 'env' in process && process.env.STREAM_LOCAL_TEST_HOST ) { this.setBaseURL('http://' + process.env.STREAM_LOCAL_TEST_HOST); } // WS connection is initialized when setUser is called this.wsConnection = null; this.wsPromise = null; this.setUserPromise = null; // keeps a reference to all the channels that are in use this.activeChannels = {}; // mapping between channel groups and configs this.configs = {}; this.anonymous = false; this.persistUserOnConnectionFailure = this.options?.persistUserOnConnectionFailure; // If its a server-side client, then lets initialize the tokenManager, since token will be // generated from secret. this.tokenManager = new TokenManager(this.secret); this.consecutiveFailures = 0; this.insightMetrics = new InsightMetrics(); this.defaultWSTimeoutWithFallback = 6 * 1000; this.defaultWSTimeout = 15 * 1000; this.axiosInstance.defaults.paramsSerializer = axiosParamsSerializer; /** * logger function should accept 3 parameters: * @param logLevel string * @param message string * @param extraData object * * e.g., * const client = new StreamChat('api_key', {}, { * logger = (logLevel, message, extraData) => { * console.log(message); * } * }) * * extraData contains tags array attached to log message. Tags can have one/many of following values: * 1. api * 2. api_request * 3. api_response * 4. client * 5. channel * 6. connection * 7. event * * It may also contains some extra data, some examples have been mentioned below: * 1. { * tags: ['api', 'api_request', 'client'], * url: string, * payload: object, * config: object * } * 2. { * tags: ['api', 'api_response', 'client'], * url: string, * response: object * } * 3. { * tags: ['api', 'api_response', 'client'], * url: string, * error: object * } * 4. { * tags: ['event', 'client'], * event: object * } * 5. { * tags: ['channel'], * channel: object * } */ this.logger = isFunction(inputOptions.logger) ? inputOptions.logger : () => null; this.recoverStateOnReconnect = this.options.recoverStateOnReconnect; this.threads = new ThreadManager({ client: this }); this.polls = new PollManager({ client: this }); this.reminders = new ReminderManager({ client: this }); } /** * Get a client instance * * This function always returns the same Client instance to avoid issues raised by multiple Client and WS connections * * **After the first call, the client configuration will not change if the key or options parameters change** * * @param {string} key - the api key * @param {string} [secret] - the api secret * @param {StreamChatOptions} [options] - additional options, here you can pass custom options to axios instance * @param {boolean} [options.browser] - enforce the client to be in browser mode * @param {boolean} [options.warmUp] - default to false, if true, client will open a connection as soon as possible to speed up following requests * @param {Logger} [options.Logger] - custom logger * @param {number} [options.timeout] - default to 3000 * @param {httpsAgent} [options.httpsAgent] - custom httpsAgent, in node it's default to https.agent() * @example <caption>initialize the client in user mode</caption> * StreamChat.getInstance('api_key') * @example <caption>initialize the client in user mode with options</caption> * StreamChat.getInstance('api_key', { timeout:5000 }) * @example <caption>secret is optional and only used in server side mode</caption> * StreamChat.getInstance('api_key', "secret", { httpsAgent: customAgent }) */ public static getInstance(key: string, options?: StreamChatOptions): StreamChat; public static getInstance( key: string, secret?: string, options?: StreamChatOptions, ): StreamChat; public static getInstance( key: string, secretOrOptions?: StreamChatOptions | string, options?: StreamChatOptions, ): StreamChat { if (!StreamChat._instance) { if (typeof secretOrOptions === 'string') { StreamChat._instance = new StreamChat(key, secretOrOptions, options); } else { StreamChat._instance = new StreamChat(key, secretOrOptions); } } return StreamChat._instance as StreamChat; } setOfflineDBApi(offlineDBInstance: AbstractOfflineDB) { if (this.offlineDb) { return; } this.offlineDb = offlineDBInstance; } devToken(userID: string) { return DevToken(userID); } getAuthType() { return this.anonymous ? 'anonymous' : 'jwt'; } setBaseURL(baseURL: string) { this.baseURL = baseURL; this.wsBaseURL = this.baseURL.replace('http', 'ws').replace(':3030', ':8800'); } _getConnectionID = () => this.wsConnection?.connectionID || this.wsFallback?.connectionID; _hasConnectionID = () => Boolean(this._getConnectionID()); public setMessageComposerSetupFunction = ( setupFunction: MessageComposerSetupState['setupFunction'], ) => { this._messageComposerSetupState.partialNext({ setupFunction }); }; /** * connectUser - Set the current user and open a WebSocket connection * * @param {OwnUserResponse | UserResponse} user Data about this user. IE {name: "john"} * @param {TokenOrProvider} userTokenOrProvider Token or provider * * @return {ConnectAPIResponse} Returns a promise that resolves when the connection is setup */ connectUser = async ( user: OwnUserResponse | UserResponse, userTokenOrProvider: TokenOrProvider, ) => { if (!user.id) { throw new Error('The "id" field on the user is missing'); } /** * Calling connectUser multiple times is potentially the result of a bad integration, however, * If the user id remains the same we don't throw error */ if (this.userID === user.id && this.setUserPromise) { console.warn( 'Consecutive calls to connectUser is detected, ideally you should only call this function once in your app.', ); return this.setUserPromise; } if (this.userID) { throw new Error( 'Use client.disconnect() before trying to connect as a different user. connectUser was called twice.', ); } if ( (this._isUsingServerAuth() || this.node) && !this.options.allowServerSideConnect ) { console.warn( 'Please do not use connectUser server side. connectUser impacts MAU and concurrent connection usage and thus your bill. If you have a valid use-case, add "allowServerSideConnect: true" to the client options to disable this warning.', ); } // we generate the client id client side this.userID = user.id; this.anonymous = false; const setTokenPromise = this._setToken(user, userTokenOrProvider); this._setUser(user); const wsPromise = this.openConnection(); this.setUserPromise = Promise.all([setTokenPromise, wsPromise]).then( (result) => result[1], // We only return connection promise; ); try { return await this.setUserPromise; } catch (err) { if (this.persistUserOnConnectionFailure) { // cleanup client to allow the user to retry connectUser again this.closeConnection(); } else { this.disconnectUser(); } throw err; } }; /** * @deprecated Please use connectUser() function instead. Its naming is more consistent with its functionality. * * setUser - Set the current user and open a WebSocket connection * * @param {OwnUserResponse | UserResponse} user Data about this user. IE {name: "john"} * @param {TokenOrProvider} userTokenOrProvider Token or provider * * @return {ConnectAPIResponse} Returns a promise that resolves when the connection is setup */ setUser = this.connectUser; _setToken = (user: UserResponse, userTokenOrProvider: TokenOrProvider) => this.tokenManager.setTokenOrProvider(userTokenOrProvider, user); _setUser(user: OwnUserResponse | UserResponse) { /** * This one is used by the frontend. This is a copy of the current user object stored on backend. * It contains reserved properties and own user properties which are not present in `this._user`. */ this.user = user; this.userID = user.id; // this one is actually used for requests. This is a copy of current user provided to `connectUser` function. this._user = { ...user }; } /** * Disconnects the websocket connection, without removing the user set on client. * client.closeConnection will not trigger default auto-retry mechanism for reconnection. You need * to call client.openConnection to reconnect to websocket. * * This is mainly useful on mobile side. You can only receive push notifications * if you don't have active websocket connection. * So when your app goes to background, you can call `client.closeConnection`. * And when app comes back to foreground, call `client.openConnection`. * * @param timeout Max number of ms, to wait for close event of websocket, before forcefully assuming succesful disconnection. * https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent */ closeConnection = async (timeout?: number) => { if (this.cleaningIntervalRef != null) { clearInterval(this.cleaningIntervalRef); this.cleaningIntervalRef = undefined; } await Promise.all([ this.wsConnection?.disconnect(timeout), this.wsFallback?.disconnect(timeout), ]); this.offlineDb?.executeQuerySafely( async (db) => { if (this.userID) { await db.upsertUserSyncStatus({ userId: this.userID, lastSyncedAt: new Date().toString(), }); } }, { method: 'upsertUserSyncStatus' }, ); return Promise.resolve(); }; /** * Creates an instance of ChannelManager. * * @internal * * @param eventHandlerOverrides - the overrides for event handlers to be used * @param options - the options used for the channel manager */ createChannelManager = ({ eventHandlerOverrides = {}, options = {}, queryChannelsOverride, }: { eventHandlerOverrides?: ChannelManagerEventHandlerOverrides; options?: ChannelManagerOptions; queryChannelsOverride?: QueryChannelsRequestType; }) => new ChannelManager({ client: this, eventHandlerOverrides, options, queryChannelsOverride, }); /** * Creates a new WebSocket connection with the current user. Returns empty promise, if there is an active connection */ openConnection = () => { if (!this.userID) { throw Error( 'User is not set on client, use client.connectUser or client.connectAnonymousUser instead', ); } if (this.wsConnection?.isConnecting && this.wsPromise) { this.logger('info', 'client:openConnection() - connection already in progress', { tags: ['connection', 'client'], }); return this.wsPromise; } if ( (this.wsConnection?.isHealthy || this.wsFallback?.isHealthy()) && this._hasConnectionID() ) { this.logger( 'info', 'client:openConnection() - openConnection called twice, healthy connection already exists', { tags: ['connection', 'client'], }, ); return; } this.clientID = `${this.userID}--${randomId()}`; this.wsPromise = this.connect(); this._startCleaning(); return this.wsPromise; }; /** * @deprecated Please use client.openConnction instead. * @private * * Creates a new websocket connection with current user. */ _setupConnection = this.openConnection; /** * updateAppSettings - updates application settings * * @param {AppSettings} options App settings. * IE: { 'apn_config': { 'auth_type': 'token', 'auth_key": fs.readFileSync( './apn-push-auth-key.p8', 'utf-8', ), 'key_id': 'keyid', 'team_id': 'teamid', 'notification_template": 'notification handlebars template', 'bundle_id': 'com.apple.your.app', 'development': true }, 'firebase_config': { 'server_key': 'server key from fcm', 'notification_template': 'notification handlebars template', 'data_template': 'data handlebars template', 'apn_template': 'apn notification handlebars template under v2' }, 'webhook_url': 'https://acme.com/my/awesome/webhook/', 'event_hooks': [ { 'hook_type': 'webhook', 'enabled': true, 'event_types': ['message.new'], 'webhook_url': 'https://acme.com/my/awesome/webhook/' }, { 'hook_type': 'sqs', 'enabled': true, 'event_types': ['message.new'], 'sqs_url': 'https://sqs.us-east-1.amazonaws.com/1234567890/my-queue', 'sqs_auth_type': 'key', 'sqs_key': 'my-access-key', 'sqs_secret': 'my-secret-key' } ] } */ async updateAppSettings(options: AppSettings) { const apn_config = options.apn_config; if (apn_config?.p12_cert) { options = { ...options, apn_config: { ...apn_config, p12_cert: Buffer.from(apn_config.p12_cert).toString('base64'), }, }; } return await this.patch<APIResponse>(this.baseURL + '/app', options); } _normalizeDate = (before: Date | string | null): string | null => { if (before instanceof Date) { before = before.toISOString(); } if (before === '') { throw new Error( "Don't pass blank string for since, use null instead if resetting the token revoke", ); } return before; }; /** * Revokes all tokens on application level issued before given time */ async revokeTokens(before: Date | string | null) { return await this.updateAppSettings({ revoke_tokens_issued_before: this._normalizeDate(before), }); } /** * Revokes token for a user issued before given time */ async revokeUserToken(userID: string, before?: Date | string | null) { return await this.revokeUsersToken([userID], before); } /** * Revokes tokens for a list of users issued before given time */ async revokeUsersToken(userIDs: string[], before?: Date | string | null) { if (before === undefined) { before = new Date().toISOString(); } else { before = this._normalizeDate(before); } const users: PartialUserUpdate[] = []; for (const userID of userIDs) { users.push({ id: userID, set: <Partial<UserResponse>>{ revoke_tokens_issued_before: before, }, }); } return await this.partialUpdateUsers(users); } /** * getAppSettings - retrieves application settings */ async getAppSettings() { this.appSettingsPromise = this.get<AppSettingsAPIResponse>(this.baseURL + '/app'); return await this.appSettingsPromise; } /** * testPushSettings - Tests the push settings for a user with a random chat message and the configured push templates * * @param {string} userID User ID. If user has no devices, it will error * @param {TestPushDataInput} [data] Overrides for push templates/message used * IE: { messageID: 'id-of-message', // will error if message does not exist apnTemplate: '{}', // if app doesn't have apn configured it will error firebaseTemplate: '{}', // if app doesn't have firebase configured it will error firebaseDataTemplate: '{}', // if app doesn't have firebase configured it will error skipDevices: true, // skip config/device checks and sending to real devices pushProviderName: 'staging' // one of your configured push providers pushProviderType: 'apn' // one of supported provider types } */ async testPushSettings(userID: string, data: TestPushDataInput = {}) { return await this.post<CheckPushResponse>(this.baseURL + '/check_push', { user_id: userID, ...(data.messageID ? { message_id: data.messageID } : {}), ...(data.apnTemplate ? { apn_template: data.apnTemplate } : {}), ...(data.firebaseTemplate ? { firebase_template: data.firebaseTemplate } : {}), ...(data.firebaseDataTemplate ? { firebase_data_template: data.firebaseDataTemplate } : {}), ...(data.skipDevices ? { skip_devices: true } : {}), ...(data.pushProviderName ? { push_provider_name: data.pushProviderName } : {}), ...(data.pushProviderType ? { push_provider_type: data.pushProviderType } : {}), }); } /** * testSQSSettings - Tests that the given or configured SQS configuration is valid * * @param {TestSQSDataInput} [data] Overrides SQS settings for testing if needed * IE: { sqs_key: 'auth_key', sqs_secret: 'auth_secret', sqs_url: 'url_to_queue', } */ async testSQSSettings(data: TestSQSDataInput = {}) { return await this.post<CheckSQSResponse>(this.baseURL + '/check_sqs', data); } /** * testSNSSettings - Tests that the given or configured SNS configuration is valid * * @param {TestSNSDataInput} [data] Overrides SNS settings for testing if needed * IE: { sns_key: 'auth_key', sns_secret: 'auth_secret', sns_topic_arn: 'topic_to_publish_to', } */ async testSNSSettings(data: TestSNSDataInput = {}) { return await this.post<CheckSNSResponse>(this.baseURL + '/check_sns', data); } /** * Disconnects the websocket and removes the user from client. * * @param timeout Max number of ms, to wait for close event of websocket, before forcefully assuming successful disconnection. * https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent */ disconnectUser = (timeout?: number) => { this.logger('info', 'client:disconnect() - Disconnecting the client', { tags: ['connection', 'client'], }); // remove the user specific fields delete this.user; delete this._user; delete this.userID; this.anonymous = false; const closePromise = this.closeConnection(timeout); for (const channel of Object.values(this.activeChannels)) { channel._disconnect(); } // ensure we no longer return inactive channels this.activeChannels = {}; // reset client state this.state = new ClientState({ client: this }); // reset thread manager this.threads.resetState(); // reset token manager setTimeout(this.tokenManager.reset); // delay reseting to use token for disconnect calls // close the WS connection return closePromise; }; /** * * @deprecated Please use client.disconnectUser instead. * * Disconnects the websocket and removes the user from client. */ disconnect = this.disconnectUser; /** * connectAnonymousUser - Set an anonymous user and open a WebSocket connection */ connectAnonymousUser = () => { if ( (this._isUsingServerAuth() || this.node) && !this.options.allowServerSideConnect ) { console.warn( 'Please do not use connectUser server side. connectUser impacts MAU and concurrent connection usage and thus your bill. If you have a valid use-case, add "allowServerSideConnect: true" to the client options to disable this warning.', ); } this.anonymous = true; this.userID = randomId(); const anonymousUser = { id: this.userID, anon: true, } as UserResponse; this._setToken(anonymousUser, ''); this._setUser(anonymousUser); return this._setupConnection(); }; /** * @deprecated Please use connectAnonymousUser. Its naming is more consistent with its functionality. */ setAnonymousUser = this.connectAnonymousUser; /** * setGuestUser - Setup a temporary guest user * * @param {UserResponse} user Data about this user. IE {name: "john"} * * @return {ConnectAPIResponse} Returns a promise that resolves when the connection is setup */ async setGuestUser(user: UserResponse) { let response: { access_token: string; user: UserResponse } | undefined; this.anonymous = true; try { response = await this.post< APIResponse & { access_token: string; user: UserResponse; } >(this.baseURL + '/guest', { user }); } catch (e) { this.anonymous = false; throw e; } this.anonymous = false; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { created_at, updated_at, last_active, online, ...guestUser } = response.user; return await this.connectUser(guestUser as UserResponse, response.access_token); } /** * createToken - Creates a token to authenticate this user. This function is used server side. * The resulting token should be passed to the client side when the users registers or logs in. * * @param {string} userID The User ID * @param {number} [exp] The expiration time for the token expressed in the number of seconds since the epoch * * @return {string} Returns a token */ createToken(userID: string, exp?: number, iat?: number) { if (this.secret == null) { throw Error(`tokens can only be created server-side using the API Secret`); } const extra: { exp?: number; iat?: number } = {}; if (exp) { extra.exp = exp; } if (iat) { extra.iat = iat; } return JWTUserToken(this.secret, userID, extra, {}); } /** * on - Listen to events on all channels and users your watching * * client.on('message.new', event => {console.log("my new message", event, channel.state.messages)}) * or * client.on(event => {console.log(event.type)}) * * @param {EventHandler | string} callbackOrString The event type to listen for (optional) * @param {EventHandler} [callbackOrNothing] The callback to call * * @return {{ unsubscribe: () => void }} Description */ on(callback: EventHandler): { unsubscribe: () => void }; on(eventType: string, callback: EventHandler): { unsubscribe: () => void }; on( callbackOrString: EventHandler | string, callbackOrNothing?: EventHandler, ): { unsubscribe: () => void } { const key = callbackOrNothing ? (callbackOrString as string) : 'all'; const callback = callbackOrNothing ? callbackOrNothing : (callbackOrString as EventHandler); if (!(key in this.listeners)) { this.listeners[key] = []; } this.logger('info', `Attaching listener for ${key} event`, { tags: ['event', 'client'], }); this.listeners[key].push(callback); return { unsubscribe: () => { this.logger('info', `Removing listener for ${key} event`, { tags: ['event', 'client'], }); this.listeners[key] = this.listeners[key].filter((el) => el !== callback); }, }; } /** * off - Remove the event handler * */ off(callback: EventHandler): void; off(eventType: string, callback: EventHandler): void; off(callbackOrString: EventHandler | string, callbackOrNothing?: EventHandler) { const key = callbackOrNothing ? (callbackOrString as string) : 'all'; const callback = callbackOrNothing ? callbackOrNothing : (callbackOrString as EventHandler); if (!(key in this.listeners)) { this.listeners[key] = []; } this.logger('info', `Removing listener for ${key} event`, { tags: ['event', 'client'], }); this.listeners[key] = this.listeners[key].filter((value) => value !== callback); } _logApiRequest( type: string, url: string, data: unknown, config: AxiosRequestConfig & { config?: AxiosRequestConfig & { maxBodyLength?: number }; }, ) { this.logger('info', `client: ${type} - Request - ${url}`, { tags: ['api', 'api_request', 'client'], url, payload: data, config, }); } _logApiResponse<T>(type: string, url: string, response: AxiosResponse<T>) { this.logger( 'info', `client:${type} - Response - url: ${url} > status ${response.status}`, { tags: ['api', 'api_response', 'client'], url, response, }, ); } _logApiError(type: string, url: string, error: unknown) { this.logger('error', `client:${type} - Error - url: ${url}`, { tags: ['api', 'api_response', 'client'], url, error, }); } doAxiosRequest = async <T>( type: string, url: string, data?: unknown, options: AxiosRequestConfig & { config?: AxiosRequestConfig & { maxBodyLength?: number }; } = {}, ): Promise<T> => { await this.tokenManager.tokenReady(); const requestConfig = this._enrichAxiosOptions(options); try { let response: AxiosResponse<T>; this._logApiRequest(type, url, data, requestConfig); switch (type) { case 'get': response = await this.axiosInstance.get(url, requestConfig); break; case 'delete': response = await this.axiosInstance.delete(url, requestConfig); break; case 'post': response = await this.axiosInstance.post(url, data, requestConfig); break; case 'postForm': response = await this.axiosInstance.postForm(url, data, requestConfig); break; case 'put': response = await this.axiosInstance.put(url, data, requestConfig); break; case 'patch': response = await this.axiosInstance.patch(url, data, requestConfig); break; case 'options': response = await this.axiosInstance.options(url, requestConfig); break; default: throw new Error('Invalid request type'); } this._logApiResponse<T>(type, url, response); this.consecutiveFailures = 0; return this.handleResponse(response); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any /**TODO: generalize error types */) { e.client_request_id = requestConfig.headers?.['x-client-request-id']; this._logApiError(type, url, e); this.consecutiveFailures += 1; if (e.response) { /** connection_fallback depends on this token expiration logic */ if ( e.response.data.code === chatCodes.TOKEN_EXPIRED && !this.tokenManager.isStatic() ) { if (this.consecutiveFailures > 1) { await sleep(retryInterval(this.consecutiveFailures)); } this.tokenManager.loadToken(); return await this.doAxiosRequest<T>(type, url, data, options); } return this.handleResponse(e.response); } else { throw e as AxiosError<APIErrorResponse>; } } }; get<T>(url: string, params?: AxiosRequestConfig['params']) { return this.doAxiosRequest<T>('get', url, null, { params }); } put<T>(url: string, data?: unknown) { return this.doAxiosRequest<T>('put', url, data); } post<T>(url: string, data?: unknown) { return this.doAxiosRequest<T>('post', url, data); } patch<T>(url: string, data?: unknown) { return this.doAxiosRequest<T>('patch', url, data); } delete<T>(url: string, params?: AxiosRequestConfig['params']) { return this.doAxiosRequest<T>('delete', url, null, { params }); } sendFile( url: string, uri: string | NodeJS.ReadableStream | Buffer | File, name?: string, contentType?: string, user?: UserResponse, ) { const data = addFileToFormData(uri, name, contentType || 'multipart/form-data'); if (user != null) data.append('user', JSON.stringify(user)); return this.doAxiosRequest<SendFileAPIResponse>('postForm', url, data, { headers: data.getHeaders ? data.getHeaders() : {}, // node vs browser config: { timeout: 0, maxContentLength: Infinity, maxBodyLength: Infinity, }, }); } errorFromResponse(response: AxiosResponse<APIErrorResponse>) { const message = typeof response.data.code !== 'undefined' ? `StreamChat error code ${response.data.code}: ${response.data.message}` : `StreamChat error HTTP code: ${response.status}`; return new ErrorFromResponse<APIErrorResponse>(message, { code: response.data.code ?? null, response, status: response.status, }); } handleResponse<T>(response: AxiosResponse<T>) { const data = response.data; if (isErrorResponse(response)) { throw this.errorFromResponse(response); } return data; } dispatchEvent = (event: Event) => { if (!event.received_at) event.received_at = new Date(); // client event handlers const postListenerCallbacks = this._handleClientEvent(event); // channel event handlers const cid = event.cid; const channel = cid ? this.activeChannels[cid] : undefined; if (channel) { channel._handleChannelEvent(event); } this._callClientListeners(event); if (channel) { channel._callChannelListeners(event); } postListenerCallbacks.forEach((c) => c()); this.offlineDb?.executeQuerySafely((db) => db.handleEvent({ event }), { method: `handleEvent;${event.type}`, }); }; handleEvent = (messageEvent: WebSocket.MessageEvent) => { // dispatch the event to the channel listeners const jsonString = messageEvent.data as string; const event = JSON.parse(jsonString) as Event; this.dispatchEvent(event); }; /** * Updates the members, watchers and read references of the currently active channels that contain this user * * @param {UserResponse} user */ _updateMemberWatcherReferences = (user: UserResponse) => { const refMap = this.state.userChannelReferences[user.id] || {}; for (const channelID in refMap) { const channel = this.activeChannels[channelID]; if (channel?.state) { if (channel.state.members[user.id]) { channel.state.members[user.id].user = user; } if (channel.state.watchers[user.id]) { channel.state.watchers[user.id] = user; } if (channel.state.read[user.id]) { channel.state.read[user.id].user = user; } } } }; /** * @deprecated Please _updateMemberWatcherReferences instead. * @private */ _updateUserReferences = this._updateMemberWatcherReferences; /** * @private * * Updates the messages from the currently active channels that contain this user, * with updated user object. * * @param {UserResponse} user */ _updateUserMessageReferences = (user: UserResponse) => { const refMap = this.state.userChannelReferences[user.id] || {}; for (const channelID in refMap) { const channel = this.activeChannels[channelID]; if (!channel) continue; const state = channel.state; /** update the messages from this user. */ state?.updateUserMessages(user); } }; /** * @private * * Deletes the messages from the currently active channels that contain this user * * If hardDelete is true, all the content of message will be stripped down. * Otherwise, only 'message.type' will be set as 'deleted'. * * @param {UserResponse} user * @param {boolean} hardDelete */ _deleteUserMessageReference = (user: UserResponse, hardDelete = false) => { const refMap = this.state.userChannelReferences[user.id] || {}; for (const channelID in refMap) { const channel = this.activeChannels[channelID]; if (channel) { const state = channel.state; /** deleted the messages from this user. */ state?.deleteUserMessages(user, hardDelete); } } }; /** * @private * * Handle following user related events: * - user.presence.changed * - user.updated * - user.deleted * * @param {Event} event */ _handleUserEvent = (event: Event) => { if (!event.user) { return; } /** update the client.state with any changes to users */ if (event.type === 'user.presence.changed' || event.type === 'user.updated') { if (event.user.id === this.userID) { const user = { ...this.user } as NonNullable<StreamChat['user']>; const _user = { ...this._user } as NonNullable<StreamChat['_user']>; // Remove deleted properties from user objects. for (const key in this.user) { if (key in event.user || isOwnUserBaseProperty(key)) { continue; } const deleteKey = key as keyof typeof user; delete user[deleteKey]; delete _user[deleteKey]; } /** Updating only available properties in _user object. */ for (const key in _user) { const updateKey = key as keyof typeof _user; if (updateKey in event.user) { // @ts-expect-error it has an issue with this, not sure why _user[updateKey] = event.user[updateKey]; } } this._user = _user; this.user = { ...user, ...event.user }; } this.state.updateUser(event.user); this._updateMemberWatcherReferences(event.user); } if (event.type === 'user.updated') { this._updateUserMessageReferences(event.user); } if ( event.type === 'user.deleted' && event.user.deleted_at && (event.mark_messages_deleted || event.hard_delete) ) { this._deleteUserMessageReference(event.user, event.hard_delete); } }; _handleClientEvent(event: Event) { // eslint-disable-next-line @typescript-eslint/no-this-alias const client = this; const postListenerCallbacks = []; this.logger( 'info', `client:_handleClientEvent - Received event of type { ${event.type} }`, { tags: ['event', 'client'], event, }, ); if ( event.type === 'user.presence.changed' || event.type === 'user.updated' || event.type === 'user.deleted' ) { this._handleUserEvent(event); } if (event.type === 'health.check' && event.me) { client.user = event.me; client.state.updateUser(event.me); client.mutedChannels = event.me.channel_mutes; client.mutedUsers = event.me.mutes; } if (event.channel && event.type === 'notification.message_new') { const { channel } = event; this._addChannelConfig(channel); } if (event.type === 'notification.channel_mutes_updated' && event.me?.channel_mutes) { this.mutedChannels = event.me.channel_mutes; } if (event.type === 'notification.mutes_updated' && event.me?.mutes) { this.mutedUsers = event.me.mutes; } if (event.type === 'notification.mark_read' && event.unread_channels === 0) { const activeChannelKeys = Object.keys(this.activeChannels); activeChannelKeys.forEach( (activeChannelKey) => (this.activeChannels[activeChannelKey].state.unreadCount = 0), ); } if ( (event.type === 'channel.deleted' || event.type === 'notification.channel_deleted') && event.cid ) { const { cid } = event; client.state.deleteAllChannelReference(cid); this.activeChannels[event.cid]?._disconnect(); postListenerCallbacks.push(() => { if (!cid) return; delete this.activeChannels[cid]; }); } return postListenerCallbacks; } _muteStatus(cid: string) { let muteStatus; for (let i = 0; i < this.mutedChannels.length; i++) { const mute = this.mutedChannels[i]; if (mute.channel?.cid === cid) { muteStatus = { muted: mute.expires ? new Date(mute.expires).getTime() > new Date().getTime() : true, createdAt: mute.created_at ? new Date(mute.created_at) : new Date(), expiresAt: mute.expires ? new Date(mute.expires) : null, }; break; } } if (muteStatus) { return muteStatus; } return { muted: false, createdAt: null, expiresAt: null, }; } _callClientListeners = (event: Event) => { // eslint-disable-next-line @typescript-eslint/no-this-alias const client = this; // gather and call the listeners const listeners: Array<(event: Event) => void> = []; if (client.listeners.all) { listeners.push(...client.listeners.all); } if (client.listeners[event.type]) { listeners.push(...client.listeners[event.type]); } // call the event and send it to the listeners for (const listener of listeners) { listener(event); } }; recoverState = async () => { this.logger( 'info', `client:recoverState() - Start of recoverState with connectionID ${this._getConnectionID()}`, { tags: ['connection'], }, ); const cids = Object.keys(this.activeChannels); if (cids.length && this.recoverStateOnReconnect) { this.logger( 'info', `client:recoverState() - Start the querying of ${cids.length} channels`, { tags: ['connection', 'client'], }, ); await this.queryChannels( { cid: { $in: cids } } as ChannelFilters, { last_message_at: -1 }, { limit: 30 }, ); this.logger('info', 'client:recoverState() - Querying channels finished', { tags: ['connection', 'cl