diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 70c7057ae0..de29ef053a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,6 +7,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + pull-requests: read + jobs: lint: runs-on: ubuntu-latest diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 6f266459b0..7a83000827 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -4,6 +4,10 @@ on: pull_request: types: [opened, edited, synchronize, reopened] +permissions: + contents: read + pull-requests: read + jobs: pr-title: name: Validate PR Title diff --git a/.github/workflows/scheduled_test.yml b/.github/workflows/scheduled_test.yml index c7ecbd8ac2..b1263149cf 100644 --- a/.github/workflows/scheduled_test.yml +++ b/.github/workflows/scheduled_test.yml @@ -6,6 +6,9 @@ on: # Monday at 9:00 UTC - cron: '0 9 * * 1' +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index 5d64ed4bb4..35d48583f8 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -10,6 +10,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + pull-requests: read + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/type.yml b/.github/workflows/type.yml index 537ad6b6a8..403809ad9b 100644 --- a/.github/workflows/type.yml +++ b/.github/workflows/type.yml @@ -5,6 +5,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + pull-requests: read + jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 2149b94dd5..9778735aed 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -5,6 +5,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + pull-requests: read + jobs: test: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d916fd38a..8d911ff964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [9.50.2](https://github.com/GetStream/stream-chat-js/compare/v9.50.1...v9.50.2) (2026-07-16) + +### Bug Fixes + +* **AttachmentManager:** add permission bypass for custom upload functions ([#1800](https://github.com/GetStream/stream-chat-js/issues/1800)) ([30e6bc4](https://github.com/GetStream/stream-chat-js/commit/30e6bc41f9d779da50078cf844883dc2713c346c)) + +## [9.50.1](https://github.com/GetStream/stream-chat-js/compare/v9.50.0...v9.50.1) (2026-07-09) + +### Bug Fixes + +* prevent reload if ThreadManager has never been activated ([#1798](https://github.com/GetStream/stream-chat-js/issues/1798)) ([affbb9c](https://github.com/GetStream/stream-chat-js/commit/affbb9cac73eea798084879523b9bad709ad4dc1)) + ## [9.50.0](https://github.com/GetStream/stream-chat-js/compare/v9.49.0...v9.50.0) (2026-07-03) ### Features diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba31b3453e..ffe13323a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,10 @@ $ yarn run test-unit We use [ESLint](https://eslint.org/) for linting and [Prettier](https://prettier.io/) for code formatting. We enforce it during the build process. If your IDE has integration with these tools, it's recommended to set them up. +## JSDoc + +See [`JSDOC.md`](./JSDOC.md) for the canonical JSDoc format used in this repository. The format is enforced by `eslint-plugin-jsdoc`. + ## Commit message convention Since we're autogenerating our [CHANGELOG](./CHANGELOG.md), we need to follow a specific commit message convention. diff --git a/JSDOC.md b/JSDOC.md new file mode 100644 index 0000000000..28d9298c90 --- /dev/null +++ b/JSDOC.md @@ -0,0 +1,66 @@ +# JSDoc style guide + +This repository uses TSDoc-flavored JSDoc. The TypeScript signature is the source of truth for parameter and return **types** and for **optionality** — JSDoc only carries human-readable descriptions and the few semantic tags listed below. + +The format is enforced by `eslint-plugin-jsdoc`. Run `yarn lint` to validate locally. + +## Canonical block + +````ts +/** + * One-sentence summary in sentence case, ending with a period. + * + * Optional longer prose paragraph. Wrap at ~100 columns. + * + * @param name - Description in sentence case, ending with a period. + * @param options - Send options. + * @param options.skip_enrich_url - Skip URL enrichment for this message. + * @returns Description of the return value. + * @throws When the channel is frozen. + * @deprecated Use {@link newName} instead. + * @example + * ```ts + * client.connectUser({ id: 'foo' }, token); + * ``` + */ +```` + +## Rules + +- **No `{Type}` annotations on `@param` / `@returns`.** TypeScript already provides them. Enforced by `jsdoc/no-types`. +- **No bracketed-optional syntax** (`@param [name]`). TypeScript marks optionality via `?` or default values. Enforced by `jsdoc/check-param-names`. +- **Use `@returns`, not `@return`.** Enforced by `jsdoc/check-tag-names`. +- **Drop legacy tags**: `@method`, `@memberof`, `@class`, `@type` — TypeScript provides these. +- **Allowed tags**: `@param`, `@returns`, `@throws`, `@example`, `@default`, `@deprecated`, `@see`, `@internal`, `@private`, `@experimental`, `@remarks`, `@template`, and the inline `{@link}`. +- **Hyphen before description**: `@param name - description`. Enforced by `jsdoc/require-hyphen-before-param-description`. +- **Destructured object params** use dot notation: `@param options.foo - ...`. +- **`@deprecated`** must point at the replacement: `@deprecated Use {@link newName} instead.` +- **Short single-line form** `/** Foo. */` is allowed only when there are no tags and the description fits on one line. +- **Field-level JSDoc** on interfaces/types may use the single-line form (mirrors `src/gen/models/index.ts`). + +## Casing in prose + +Apply consistently in JSDoc and `//` comments. Do **not** rewrite identifiers, string literals, or `@example` code blocks. + +| Wrong | Right | +| ------------ | ------------ | +| `websocket` | `WebSocket` | +| `sdk` | `SDK` | +| `api` | `API` | +| `url` | `URL` | +| `json` | `JSON` | +| `http(s)` | `HTTP(S)` | +| `jwt` | `JWT` | +| `id` (prose) | `ID` | +| `javascript` | `JavaScript` | +| `typescript` | `TypeScript` | + +## Reusing field descriptions + +Hand-written types in `src/types.ts` and elsewhere often share field names with the OpenAPI-generated types in `src/gen/models/index.ts` (`cid`, `created_at`, `channel_id`, `team`, `duration`, etc.). When documenting such a field, reuse the wording from the generated model for consistency. + +## When in doubt + +- Cross-check that `@param` names match the actual parameter names. +- Add `@returns` iff the function returns something other than `void` / `Promise`. +- Keep existing wording verbatim except to fix grammar, typos, casing, or factual mismatches with the signature. diff --git a/eslint.config.mjs b/eslint.config.mjs index 394d62bb14..e51175f3d6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,8 @@ import js from '@eslint/js'; import globals from 'globals'; import tseslint from 'typescript-eslint'; +import unusedImports from 'eslint-plugin-unused-imports'; +import jsdoc from 'eslint-plugin-jsdoc'; import importPlugin from 'eslint-plugin-import'; @@ -18,6 +20,7 @@ export default tseslint.config( }, plugins: { import: importPlugin, + 'unused-imports': unusedImports, }, settings: { react: { @@ -71,9 +74,18 @@ export default tseslint.config( }, ], 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': [ + '@typescript-eslint/no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'warn', + 'unused-imports/no-unused-vars': [ 'warn', - { ignoreRestSiblings: false, caughtErrors: 'none' }, + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + ignoreRestSiblings: false, + caughtErrors: 'none', + }, ], '@typescript-eslint/no-unsafe-function-type': 'error', '@typescript-eslint/no-wrapper-object-types': 'error', @@ -83,6 +95,29 @@ export default tseslint.config( '@typescript-eslint/no-require-imports': 'off', // TODO: remove this rule once all files are .mjs (and require is not used) '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/no-empty-object-type': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, + }, + { + ignores: ['src/gen/**'], + files: ['src/**/*.{js,ts}'], + plugins: { + jsdoc, + }, + rules: { + 'jsdoc/no-types': 'error', + 'jsdoc/check-param-names': ['error', { checkDestructured: false }], + 'jsdoc/check-tag-names': [ + 'error', + { definedTags: ['internal', 'experimental', 'remarks'] }, + ], + 'jsdoc/require-param-description': 'warn', + 'jsdoc/require-returns-description': 'warn', + 'jsdoc/require-hyphen-before-param-description': ['warn', 'always'], + 'jsdoc/tag-lines': ['error', 'any', { startLines: 1 }], + 'jsdoc/no-multi-asterisks': 'error', + 'jsdoc/empty-tags': 'error', + 'jsdoc/no-bad-blocks': 'error', }, }, ); diff --git a/package.json b/package.json index cc7188f1f4..de9e1c1492 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "/src" ], "dependencies": { + "@stream-io/logger": "^2.0.0", "@types/jsonwebtoken": "^9.0.8", "@types/ws": "^8.18.1", "axios": "^1.16.1", @@ -75,6 +76,8 @@ "esbuild": "^0.28.0", "eslint": "^9.39.4", "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsdoc": "^63.0.7", + "eslint-plugin-unused-imports": "^4.4.1", "globals": "^17.6.0", "husky": "^9.1.7", "lint-staged": "^17.0.5", @@ -90,7 +93,7 @@ "start": "concurrently 'tsc --watch' './scripts/bundle.mjs --watch'", "types": "tsc --noEmit", "lint": "yarn run prettier && yarn run eslint", - "lint-fix": "yarn run prettier-fix && yarn run eslint-fix", + "lint-fix": "yarn run eslint-fix; yarn run prettier-fix", "prettier": "prettier '**/*.{json,js,mjs,ts,yml,md}' --check", "prettier-fix": "yarn run prettier --write", "eslint": "eslint --max-warnings 0", @@ -104,7 +107,8 @@ "fix-staged": "lint-staged --config .lintstagedrc.fix.json --concurrent 1", "semantic-release": "semantic-release", "postinstall": "node -e \"require('fs').existsSync('scripts/install-husky.mjs') && import('./scripts/install-husky.mjs')\"", - "prepare": "yarn run build" + "prepare": "yarn run build", + "generate-client": "./scripts/generate-client.sh" }, "engines": { "node": ">=18" diff --git a/scripts/generate-client.sh b/scripts/generate-client.sh new file mode 100755 index 0000000000..7858fbecbe --- /dev/null +++ b/scripts/generate-client.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -euo pipefail + +OUTPUT_DIR="../stream-chat-js/src/gen" +CHAT_DIR="../chat" + +rm -rf $OUTPUT_DIR + +( cd $CHAT_DIR ; make openapi ; make -C projects/chat-manager build; build/chat-manager openapi generate-client --language ts --spec releases/v2/chat-clientside-api.yaml --output $OUTPUT_DIR ) + +yarn lint-fix \ No newline at end of file diff --git a/scripts/generate-filter-types.mts b/scripts/generate-filter-types.mts new file mode 100644 index 0000000000..8fc9f4f30c --- /dev/null +++ b/scripts/generate-filter-types.mts @@ -0,0 +1,120 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { parseArgs, type ParseArgsOptionsConfig } from 'node:util'; +import { parse } from 'yaml'; + +const options = { + spec: { + type: 'string', + short: 's', + }, + out: { + type: 'string', + short: 'o', + }, +} satisfies ParseArgsOptionsConfig; + +type OpenAPISpecification = { + components: { + schemas: { + [key: string]: { + properties?: Partial< + Record< + 'filter_conditions' | string, + Partial< + Record< + 'x-stream-filter-fields' | string, + Record< + string, + { + operators: string[]; + type: string; + } + > + > + > + > + >; + }; + }; + }; +}; + +const { values } = parseArgs({ + args: process.argv, + options, + allowPositionals: true, + tokens: false, +}); + +const specPath = values.spec; +const outputPath = values.out; + +if (!specPath || !outputPath) { + console.error( + 'Usage: node generate-filter-types.mts -s -o ', + ); + process.exit(1); +} + +const spec = parse(readFileSync(specPath, 'utf8')) as OpenAPISpecification; +const schemas = spec.components?.schemas; + +if (!schemas) { + console.error('No components.schemas found in the specification'); + process.exit(1); +} + +const lines = []; + +const typeMapping = { + string: 'string', + number: 'number', + boolean: 'boolean', + date: 'Date', +}; + +const snakeToCamelCase = (snakeCaseString: string) => + snakeCaseString + .split('_') + .map((wordSegment) => wordSegment.slice(0, 1).toUpperCase() + wordSegment.slice(1)) + .join(''); + +for (const [schemaName, schema] of Object.entries(schemas)) { + if (!schema.properties) { + console.log(schemaName, 'missing'); + continue; + } + + for (const [propertyName, propertyDef] of Object.entries(schema.properties)) { + if (!propertyDef?.['x-stream-filter-fields']) continue; + + const filterFields = propertyDef['x-stream-filter-fields']; + + let typeName = `${schemaName}${snakeToCamelCase(propertyName)}`; + + const fieldEntries = Object.entries(filterFields).map( + ([fieldName, fieldDefinition]) => { + // TODO: add support for such properties later on (custom/nested filters) + if (fieldDefinition.type === 'object' || fieldName.startsWith('_')) { + return ''; + } + + const operators = fieldDefinition.operators.length + ? fieldDefinition.operators.map((operator) => `"${operator}"`).join(' | ') + : 'never'; + return ` "${fieldName}": { type: ${typeMapping[fieldDefinition.type as keyof typeof typeMapping] ?? `"${fieldDefinition.type}"`}; operators: ${operators} };`; + }, + ); + + lines.push(`export type ${typeName} = {`); + lines.push(...fieldEntries); + lines.push(`};\n`); + } +} + +if (lines.length > 0) { + writeFileSync(outputPath, '\n' + lines.join('\n') + '\n'); + console.log(`Appended ${lines.length} lines to ${outputPath}`); +} else { + console.log('No filter types found'); +} diff --git a/src/ChannelPaginatorsOrchestrator.ts b/src/ChannelPaginatorsOrchestrator.ts index ad03a2556f..d3a6ff3a80 100644 --- a/src/ChannelPaginatorsOrchestrator.ts +++ b/src/ChannelPaginatorsOrchestrator.ts @@ -1,6 +1,6 @@ import { EventHandlerPipeline } from './EventHandlerPipeline'; import { WithSubscriptions } from './utils/WithSubscriptions'; -import type { Event, EventTypes } from './types'; +import type { EventType } from './types'; import type { ChannelPaginator } from './pagination'; import type { StreamChat } from './client'; import type { Unsubscribe } from './store'; @@ -10,6 +10,7 @@ import type { FindEventHandlerParams, InsertEventHandlerPayload, LabeledEventHandler, + PipelineEvent, } from './EventHandlerPipeline'; import { getChannel } from './pagination/utility.queryChannel'; import type { Channel } from './channel'; @@ -20,7 +21,7 @@ export type ChannelPaginatorsOrchestratorEventHandlerContext = { type EventHandlerContext = ChannelPaginatorsOrchestratorEventHandlerContext; -type SupportedEventType = EventTypes | (string & {}); +type SupportedEventType = EventType | (string & {}); /** * Resolves which paginators should be the "owners" of a channel @@ -69,7 +70,7 @@ export const createPriorityOwnershipResolver = ( }; const getCachedChannelFromEvent = ( - event: Event, + event: PipelineEvent, cache: Record, ): Channel | undefined => { let channel: Channel | undefined = undefined; @@ -452,8 +453,10 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { * If paginator already exists → remove old, reinsert at new index. * If index not provided → append at the end. * If index provided → insert (or move) at that index. - * @param paginator - * @param index + * + * @param params - The insertion parameters. + * @param params.paginator - The paginator to insert or move. + * @param params.index - Target index; when omitted the paginator is appended. */ insertPaginator({ paginator, index }: { paginator: ChannelPaginator; index?: number }) { const paginators = [...this.paginators]; @@ -507,7 +510,7 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { if (!this.hasSubscriptions) { this.addUnsubscribeFunction( // todo: maybe we should have a wrapper here to decide, whether the event is a LocalEventBus event or else supported by client - this.client.on((event: Event) => { + this.client.on((event) => { const pipe = this._pipelines.get(event.type); if (pipe) { pipe.run(event, this.ctx); diff --git a/src/CooldownTimer.ts b/src/CooldownTimer.ts index 1421461de3..07d293a54a 100644 --- a/src/CooldownTimer.ts +++ b/src/CooldownTimer.ts @@ -139,7 +139,7 @@ export class CooldownTimer extends WithSubscriptions { private getOwnUserId() { const client = this.channel.getClient(); - return client.userID ?? client.user?.id; + return client.userId ?? client.user?.id; } private findOwnLatestMessageDate({ diff --git a/src/EventHandlerPipeline.ts b/src/EventHandlerPipeline.ts index 925e7c1e64..03a5a63982 100644 --- a/src/EventHandlerPipeline.ts +++ b/src/EventHandlerPipeline.ts @@ -1,7 +1,34 @@ import { generateUUIDv4 } from './utils'; -import type { Event } from './types'; +import type { + ChannelResponse, + Event, + EventType, + MessageResponse, + ReactionResponse, + UserResponse, +} from './types'; import type { Unsubscribe } from './store'; +/** + * Flat routing view of an event, as seen by pipeline handlers. The public `Event` type is now a + * discriminated union (each WS event exposes only its own fields) and also admits bare custom-event + * name strings — neither is convenient for the generic routers here, which only read a bounded set of + * common optional fields. Dispatched event *values* are always objects, so the pipeline casts to + * this view at the boundary (see `processOne`). + */ +export type PipelineEvent = { + type: EventType | (string & {}); + channel?: ChannelResponse; + channel_id?: string; + channel_type?: string; + cid?: string; + created_at?: string | Date; + hard_delete?: boolean; + message?: MessageResponse; + reaction?: ReactionResponse; + user?: UserResponse; +}; + type MatchById = { id: string | RegExp; regexMatch?: boolean }; export type FindEventHandlerParams> = { handler?: LabeledEventHandler | EventHandlerPipelineHandler; @@ -19,7 +46,7 @@ export type InsertEventHandlerPayload> = { }; export type EventHandlerPipelineHandler> = (payload: { - event: Event; + event: PipelineEvent; ctx: CTX; }) => EventHandlerResult | void | Promise; @@ -74,10 +101,11 @@ export class EventHandlerPipeline = {}> { * (or appended if the index is greater than the pipeline size). Unsubscribe * will only remove this handler. * - * @param handler The handler function to insert. - * @param index Target index in the pipeline (clamped to valid range). - * @param replace If true, replace existing handler at index instead of inserting. - * @param revertOnUnsubscribe If true, restore the replaced handler when unsubscribing. + * @param payload - Insertion options. + * @param payload.handle - The handler function to insert. + * @param payload.index - Target index in the pipeline (clamped to valid range). + * @param payload.replace - If true, replace existing handler at index instead of inserting. + * @param payload.revertOnUnsubscribe - If true, restore the replaced handler when unsubscribing. * @returns An unsubscribe function that removes (and optionally restores) the handler. */ insert({ @@ -114,7 +142,8 @@ export class EventHandlerPipeline = {}> { * - handler function identity or * - by id that could be an exact match or * - match by regexp. - * @param params {FindEventHandlerParams} + * + * @param params - {FindEventHandlerParams} */ remove(params: FindEventHandlerParams): void { let index = this.findIndex(params); @@ -183,7 +212,10 @@ export class EventHandlerPipeline = {}> { for (let i = 0; i < snapshot.length; i++) { const handler = snapshot[i]; try { - const result = await handler.handle({ event, ctx }); + const result = await handler.handle({ + event: event as unknown as PipelineEvent, + ctx, + }); if (result?.action === 'stop') return; } catch { console.error(`[pipeline:${this.id}] handler failed`, { diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index 49df7c157d..ec56cdb5c6 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -14,10 +14,10 @@ import { WithSubscriptions } from './utils/WithSubscriptions'; import type { StreamChat } from './client'; import type { Unsubscribe } from './store'; import type { - EventTypes, + EventType, MessageResponse, SharedLiveLocationResponse, - SharedLocationResponse, + SharedLocationResponseData, } from './types'; import type { Coords } from './messageComposer'; @@ -73,7 +73,7 @@ export class LiveLocationManager extends WithSubscriptions { getDeviceId, watchLocation, }: LiveLocationManagerConstructorParameters) { - if (!client.userID) { + if (!client.userId) { throw new Error('Live-location sharing is reserved for client-side use only'); } @@ -121,10 +121,10 @@ export class LiveLocationManager extends WithSubscriptions { private async assureStateInit() { if (this.stateIsReady) return; - const { active_live_locations } = await this.client.getSharedLocations(); + const { active_live_locations } = await this.client.getUserLiveLocations(); this.state.next({ messages: new Map( - active_live_locations + (active_live_locations as SharedLiveLocationResponse[]) .filter((location) => !isExpiredLocation(location)) .map((location) => [ location.message_id, @@ -178,7 +178,7 @@ export class LiveLocationManager extends WithSubscriptions { Date.now() + UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT; withCancellation(LiveLocationManager.symbol, async () => { - const promises: Promise[] = []; + const promises: Promise[] = []; await this.assureStateInit(); const expiredLocations: string[] = []; @@ -189,8 +189,9 @@ export class LiveLocationManager extends WithSubscriptions { } if (location.latitude === latitude && location.longitude === longitude) continue; - const promise = this.client.updateLocation({ - created_by_device_id: location.created_by_device_id, + const promise = this.client.updateLiveLocation({ + // TODO: this is missing from the OAPI spec + // created_by_device_id: location.created_by_device_id, message_id: messageId, latitude, longitude, @@ -221,7 +222,7 @@ export class LiveLocationManager extends WithSubscriptions { 'live_location_sharing.started', 'message.updated', 'message.deleted', - ] as EventTypes[] + ] satisfies EventType[] ).map((eventType) => this.client.on(eventType, (event) => { if (!event.message) return; @@ -251,8 +252,8 @@ export class LiveLocationManager extends WithSubscriptions { private registerMessage(message: MessageResponse) { if ( - !this.client.userID || - message?.user?.id !== this.client.userID || + !this.client.userId || + message?.user?.id !== this.client.userId || !isValidLiveLocationMessage(message) ) return; diff --git a/src/api-client.ts b/src/api-client.ts new file mode 100644 index 0000000000..3cb41fdc9a --- /dev/null +++ b/src/api-client.ts @@ -0,0 +1,275 @@ +import type { AxiosRequestConfig, AxiosResponse, Method } from 'axios'; +import { AxiosError } from 'axios'; + +import type { + APIError, + RateLimit, + RequestMetadata, + SendFileAPIResponse, + UserResponse, +} from './types'; +import { StreamAPIError } from './types'; +import { addFileToFormData, chatCodes, randomId, retryInterval } from './utils'; +import type { StreamChat } from './client'; +import { chatLoggerSystem } from './logger'; +import { runWithRetry } from './utils/retryable'; + +const logger = chatLoggerSystem.getLogger('api-client'); + +export class ApiClient { + client!: StreamChat; + + private nextRequestAbortController: AbortController | null = null; + + constructor(client?: StreamChat) { + if (client) this.client = client; + } + + _getToken(): string | undefined { + if (this.client.getAuthType() === 'anonymous') return; + + return this.client.tokenManager.getToken(); + } + + createAbortControllerForNextRequest() { + return (this.nextRequestAbortController = new AbortController()); + } + + sendRequest( + method: Method, + url: string, + pathParams?: Record, + queryParams?: Record, + body?: unknown, + requestContentType?: string, + ): Promise<{ body: T; metadata: RequestMetadata }> { + const resolvedUrl = this.resolveUrl(url, pathParams); + + return this._doRequest(method, resolvedUrl, body, { + params: queryParams, + headers: { 'Content-Type': requestContentType }, + }); + } + + async doAxiosRequest( + type: string, + url: string, + data?: unknown, + options: AxiosRequestConfig = {}, + ): Promise { + return (await this._doRequest(type as Method, url, data, options)).body; + } + + get(url: string, params?: AxiosRequestConfig['params']) { + return this._doRequest('get', url, null, { params }).then((r) => r.body); + } + + put(url: string, data?: unknown) { + return this._doRequest('put', url, data).then((r) => r.body); + } + + post(url: string, data?: unknown) { + return this._doRequest('post', url, data).then((r) => r.body); + } + + patch(url: string, data?: unknown) { + return this._doRequest('patch', url, data).then((r) => r.body); + } + + delete(url: string, params?: AxiosRequestConfig['params']) { + return this._doRequest('delete', url, null, { params }).then((r) => r.body); + } + + sendFile( + url: string, + uri: string | NodeJS.ReadableStream | Buffer | File, + name?: string, + contentType?: string, + user?: UserResponse, + axiosRequestConfig?: AxiosRequestConfig, + ) { + const data = addFileToFormData(uri, name, contentType || 'multipart/form-data'); + if (user != null) data.append('user', JSON.stringify(user)); + + return this._doRequest('post', url, data, { + headers: data.getHeaders ? data.getHeaders() : {}, + timeout: 0, + maxContentLength: Infinity, + maxBodyLength: Infinity, + ...axiosRequestConfig, + }).then((response) => response.body); + } + + // --- private --- + + private resolveUrl(url: string, pathParams?: Record): string { + let resolved = url; + if (pathParams) { + for (const [key, value] of Object.entries(pathParams)) { + resolved = resolved.replace(`{${key}}`, encodeURIComponent(value)); + } + } + if (resolved.startsWith('/')) { + resolved = this.client.baseURL + resolved; + } + return resolved; + } + + private getNextAbortSignal(): AbortSignal | undefined { + if (!this.nextRequestAbortController) return; + + const signal = this.nextRequestAbortController.signal; + this.nextRequestAbortController = null; + return signal; + } + + populateRequestConfigWithDefaults( + additonalConfig: AxiosRequestConfig, + ): AxiosRequestConfig { + const token = this._getToken(); + const signal = this.getNextAbortSignal(); + + return { + ...additonalConfig, + headers: { + Authorization: token, + 'stream-auth-type': this.client.getAuthType(), + 'x-stream-client': this.client.getUserAgent(), + ...additonalConfig.headers, + // TODO: figure out whether this is needed, setting these at a later time (client.options.axiosRequestConfig = {...}) should probably be a setter + // that updates existing axios instance options instead + ...this.client.options.axiosRequestConfig?.headers, + 'x-client-request-id': + additonalConfig.headers?.['x-client-request-id'] || randomId(), + }, + params: { + user_id: this.client.userId, + api_key: this.client.key, + // TODO: figure out whether this is needed, setting these at a later time (client.options.axiosRequestConfig = {...}) should probably be a setter + // that updates existing axios instance options instead + ...this.client.options.axiosRequestConfig?.params, + ...additonalConfig.params, + connection_id: + additonalConfig.params?.connection_id || this.client._getConnectionID(), + }, + signal, + } satisfies AxiosRequestConfig; + } + + private extractMetadata( + response: AxiosResponse, + clientRequestId: string, + ): RequestMetadata { + const headers = response.headers || {}; + const rateLimit: RateLimit = {}; + + const limit = headers['x-ratelimit-limit'] as string | undefined; + if (limit) rateLimit.rate_limit = parseInt(limit, 10); + + const remaining = headers['x-ratelimit-remaining'] as string | undefined; + if (remaining) rateLimit.rate_limit_remaining = parseInt(remaining, 10); + + const reset = headers['x-ratelimit-reset'] as string | undefined; + if (reset) rateLimit.rate_limit_reset = new Date(reset); + + return { + response_headers: headers as Record, + rate_limit: rateLimit, + response_code: response.status, + client_request_id: clientRequestId, + }; + } + + private async _doRequest( + type: Method, + url: string, + data?: unknown | null, + additionalConfig: AxiosRequestConfig = {}, + ): Promise<{ body: T; metadata: RequestMetadata }> { + const initialRequestConfig = this.populateRequestConfigWithDefaults(additionalConfig); + const clientRequestId = initialRequestConfig.headers?.[ + 'x-client-request-id' + ] as string; + + try { + const response = await runWithRetry( + async () => { + await this.client.tokenManager.tokenReady(); + + const token = this._getToken(); + + const config: AxiosRequestConfig = { + ...initialRequestConfig, + method: type, + url, + data, + }; + + if ( + token && + config.headers?.Authorization && + token !== config.headers?.Authorization + ) { + config.headers.Authorization = token; + } + + let requestResponse: AxiosResponse; + try { + requestResponse = await this.client.axiosInstance.request(config); + } catch (error) { + if (isTokenExpiredError(error)) { + logger + .withExtraTags('_doRequest') + .debug( + `The token expired on a ${type.toUpperCase()} request. Reloading the token before retrying.`, + { url, config }, + ); + this.client.tokenManager.loadToken(); + } + + throw error; + } + + return requestResponse; + }, + { + delayBetweenRetries: (attemptNumber) => retryInterval(attemptNumber + 1), + retryAttempts: 10, + isRetryable: (error) => { + if (!(error instanceof AxiosError)) return false; + + if (error.status === 429 || isTokenExpiredError(error)) return true; + + return false; + }, + }, + )(); + + return { + body: response.data, + metadata: this.extractMetadata(response, clientRequestId), + }; + } catch (error) { + if (errorIsApiError(error)) { + throw new StreamAPIError(error.response?.data.message ?? error.message, { + code: error.response?.data.code, + status: error.status, + response: error.response, + }); + } else { + throw error; + } + } + } +} + +const errorIsApiError = (error: unknown): error is AxiosError => { + if (!(error instanceof AxiosError)) return false; + + return ( + typeof (error as AxiosError).response?.data?.code === 'number' + ); +}; + +const isTokenExpiredError = (error: unknown): boolean => + errorIsApiError(error) && error.response?.data.code === chatCodes.TOKEN_EXPIRED; diff --git a/src/campaign.ts b/src/campaign.ts index 97c5084977..2b550cc55f 100644 --- a/src/campaign.ts +++ b/src/campaign.ts @@ -1,77 +1 @@ -import type { StreamChat } from './client'; -import type { CampaignData, GetCampaignOptions } from './types'; - -export class Campaign { - id: string | null; - data?: CampaignData; - client: StreamChat; - - constructor(client: StreamChat, id: string | null, data?: CampaignData) { - this.client = client; - this.id = id; - this.data = data; - } - - async create() { - const body = { - id: this.id, - message_template: this.data?.message_template, - segment_ids: this.data?.segment_ids, - sender_id: this.data?.sender_id, - sender_mode: this.data?.sender_mode, - sender_visibility: this.data?.sender_visibility, - channel_template: this.data?.channel_template, - create_channels: this.data?.create_channels, - show_channels: this.data?.show_channels, - description: this.data?.description, - name: this.data?.name, - skip_push: this.data?.skip_push, - skip_webhook: this.data?.skip_webhook, - user_ids: this.data?.user_ids, - }; - - const result = await this.client.createCampaign(body); - - this.id = result.campaign.id; - this.data = result.campaign; - return result; - } - - verifyCampaignId() { - if (!this.id) { - throw new Error( - 'Campaign id is missing. Either create the campaign using campaign.create() or set the id during instantiation - const campaign = client.campaign(id)', - ); - } - } - - async start(options?: { scheduledFor?: string; stopAt?: string }) { - this.verifyCampaignId(); - - return await this.client.startCampaign(this.id as string, options); - } - - update(data: Partial) { - this.verifyCampaignId(); - - return this.client.updateCampaign(this.id as string, data); - } - - async delete() { - this.verifyCampaignId(); - - return await this.client.deleteCampaign(this.id as string); - } - - stop() { - this.verifyCampaignId(); - - return this.client.stopCampaign(this.id as string); - } - - get(options?: GetCampaignOptions) { - this.verifyCampaignId(); - - return this.client.getCampaign(this.id as string, options); - } -} +// Campaign functionality has been moved to the server-side SDK. diff --git a/src/channel.ts b/src/channel.ts index ac81a095da..3cd142f4ab 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -10,87 +10,77 @@ import { channelHasReadEvents, formatMessage, generateChannelTempCid, + localMessageToNewMessagePayload, logChatPromiseExecution, - normalizeQuerySort, } from './utils'; import type { StreamChat } from './client'; +import { chatLoggerSystem } from './logger'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants'; import type { AIState, APIResponse, - AscDesc, BanUserOptions, - ChannelAPIResponse, ChannelData, - ChannelFilters, - ChannelMemberAPIResponse, + ChannelGetOrCreateRequest, ChannelMemberResponse, - ChannelPushPreference, - ChannelQueryOptions, ChannelResponse, + ChannelStateResponseFields, ChannelUpdateOptions, CreateDraftResponse, - DeleteChannelAPIResponse, DeleteMessageOptions, - DraftMessagePayload, Event, EventAPIResponse, EventHandler, - EventTypes, - GetDraftResponse, - GetMultipleMessagesAPIResponse, - GetReactionsAPIResponse, + EventPayload, + EventType, GetRepliesAPIResponse, - LiveLocationPayload, + GetRepliesRequest, LocalMessage, - MarkReadOptions, - MarkUnreadOptions, - MemberFilters, - MemberSort, - Message, - MessageFilters, - MessageOptions, + MarkReadRequest, + MarkUnreadRequest, MessagePaginationOptions, + MessageRequest, MessageResponse, MessageSetType, - MuteChannelAPIResponse, - NewMemberPayload, - PartialUpdateChannel, - PartialUpdateChannelAPIResponse, - PartialUpdateMember, - PartialUpdateMemberAPIResponse, PinnedMessagePaginationOptions, PinnedMessagesSort, - PollVoteData, - QueryChannelAPIResponse, - QueryMembersOptions, - Reaction, + QueryMembersPayload, ReactionAPIResponse, - SearchAPIResponse, - SearchMessageSortBase, - SearchOptions, + ReactionResponse, SearchPayload, - SendMessageAPIResponse, SendMessageOptions, - SendReactionOptions, - StaticLocationPayload, - TruncateChannelAPIResponse, - TruncateOptions, + SharedLocation, UnBanUserOptions, - UpdateChannelAPIResponse, - UpdateChannelOptions, - UpdateLocationPayload, + UpdateChannelPartialRequest, + UpdateLiveLocationRequest, UpdateMessageOptions, UserResponse, } from './types'; -import type { Role } from './permissions'; -import type { CustomChannelData } from './custom_types'; +import type { RoleName } from './permissions'; import { StateStore } from './store'; +import type { + ChannelMemberRequest as Gen_ChannelMemberRequest, + ChannelPushPreferencesResponse as Gen_ChannelPushPreferencesResponse, + ChannelStopWatchingRequest as Gen_ChannelStopWatchingRequest, + CreateDraftRequest as Gen_CreateDraftRequest, + HideChannelRequest as Gen_HideChannelRequest, + MuteChannelRequest as Gen_MuteChannelRequest, + SendMessageRequest as Gen_SendMessageRequest, + ShowChannelRequest as Gen_ShowChannelRequest, + UnmuteChannelRequest as Gen_UnmuteChannelRequest, + UpdateChannelRequest as Gen_UpdateChannelRequest, + WSEvent, +} from './gen/models'; +import type { ChatApi } from './gen/chat/ChatApi'; +import { ChannelApi } from './gen/chat/ChannelApi'; + +const logger = chatLoggerSystem.getLogger('channel'); +const offlineDbLogger = chatLoggerSystem.getLogger('offline-db'); // todo: move to dedicated file export type SendMessageWithStateUpdateParams = { localMessage: LocalMessage; - message?: Message; + message?: MessageRequest; options?: SendMessageOptions; /** * Per-call override for the send/retry request (advanced). @@ -139,7 +129,7 @@ export type CustomDeleteMessageRequestFn = ( export type CustomMarkReadRequestFn = (params: { channel: Channel; - options?: MarkReadOptions; + options?: MarkReadRequest; }) => Promise; export type ChannelInstanceConfig = { @@ -153,20 +143,18 @@ export type ChannelInstanceConfig = { }; /** - * Channel - The Channel class manages it's own state. + * The Channel class manages its own state. */ -export class Channel { +export class Channel extends ChannelApi { _client: StreamChat; - type: string; - id: string | undefined; - data: Partial | undefined; - _data: Partial; + data: Partial | undefined; + _data: ChannelData; cid: string; /** */ - listeners: { [key: string]: (string | EventHandler)[] }; + listeners: Map>; state: ChannelState; /** - * This boolean is a vague indication of weather the channel exists on chat backend. + * This boolean is a vague indication of whether 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(). @@ -176,7 +164,7 @@ export class Channel { */ initialized: boolean; /** - * Indicates weather channel has been initialized by manually populating the state with some messages, members etc. + * Indicates whether 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; @@ -184,7 +172,7 @@ export class Channel { lastTypingEvent: Date | null; isTyping: boolean; disconnected: boolean; - push_preferences?: ChannelPushPreference; + push_preferences?: Gen_ChannelPushPreferencesResponse; public readonly configState = new StateStore({}); public readonly messageComposer: MessageComposer; public readonly messageReceiptsTracker: MessageReceiptsTracker; @@ -194,14 +182,13 @@ export class Channel { public readonly cooldownTimer: CooldownTimer; /** - * constructor - Create a channel - * - * @param {StreamChat} client the chat client - * @param {string} type the type of channel - * @param {string} [id] the id of the chat - * @param {ChannelData} data any additional custom params + * Creates a `Channel` instance bound to the given chat client. * - * @return {Channel} Returns a new uninitialized channel + * @param client - The chat client. + * @param type - The type of channel. + * @param id - The ID of the chat (optional). + * @param data - Any additional custom params. + * @returns A new uninitialized channel. */ constructor( client: StreamChat, @@ -219,15 +206,15 @@ export class Channel { throw new Error(`Invalid chat id ${id}, letters, numbers and "!-_" are allowed`); } + super(client, type, id); + this._client = client; - this.type = type; - this.id = id; // used by the frontend, gets updated: - this.data = data; + this.data = data as Partial; // this._data is used for the requests... this._data = { ...data }; this.cid = `${type}:${id}`; - this.listeners = {}; + this.listeners = new Map(); // perhaps the state variable should be private this.state = new ChannelState(this); this.initialized = false; @@ -296,15 +283,19 @@ export class Channel { }, defaults: { delete: async (id, o) => { - const result = await this.getClient().deleteMessage(id, o); + const result = await this.getClient().deleteMessage({ id, ...o }); return { message: result.message }; }, send: async (m, o) => { - const result = await this.sendMessage(m, o); + const result = await this.sendMessage({ message: m, ...o }); return { message: result.message }; }, update: async (m, o) => { - const result = await this.getClient().updateMessage(m, undefined, o); + const result = await this.getClient().updateMessage({ + id: m.id, + message: localMessageToNewMessagePayload(m), + ...o, + }); return { message: result.message }; }, }, @@ -312,9 +303,9 @@ export class Channel { } /** - * getClient - Get the chat client for this channel. If client.disconnect() was called, this function will error + * Returns the chat client for this channel. Throws if `client.disconnect()` was called. * - * @return {StreamChat} + * @returns The chat client. */ getClient(): StreamChat { if (this.disconnected === true) { @@ -324,62 +315,47 @@ export class Channel { } /** - * getConfig - Get the config for this channel id (cid) + * Returns the config for this channel ID (CID). * - * @return {Record} + * @returns The channel config. */ getConfig() { const client = this.getClient(); return client.configs[this.cid]; } + _sendMessage(request: Gen_SendMessageRequest) { + return super.sendMessage(request); + } + /** - * sendMessage - Send a message to this channel + * Sends a message to this channel. * - * @param {Message} 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} [options.pending_message_metadata] Metadata for the pending message - * @param {boolean} [options.force_moderation] Apply force moderation for server-side requests - * - * @return {Promise} The Server Response + * @param request - The send message request payload, including the message body and optional flags + * such as `skip_enrich_url`, `skip_push`, and `keep_channel_hidden`. + * @returns The server response. */ - async _sendMessage(message: Message, options?: SendMessageOptions) { - return await this.getClient().post( - this._channelURL() + '/message', - { - message, - ...options, - }, - ); - } - - async sendMessage(message: Message, options?: SendMessageOptions) { + override async sendMessage(request: Gen_SendMessageRequest) { try { const offlineDb = this.getClient().offlineDb; - if (offlineDb) { - const messageId = message.id; - if (messageId) { - return await offlineDb.queueTask({ - task: { - channelId: this.id as string, - channelType: this.type, - messageId, - payload: [message, options], - type: 'send-message', - }, - }); - } + const messageId = request.message?.id; + if (offlineDb && messageId) { + return await offlineDb.queueTask>>({ + task: { + channelId: this.id as string, + channelType: this.type, + messageId, + payload: [request], + type: 'send-message', + }, + }); } } catch (error) { - this._client.logger('error', `offlineDb:send-message`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('sendMessage', this.cid) + .error('Sending the message failed.', { error }); } - return await this._sendMessage(message, options); + return await this._sendMessage(request); } /** @@ -443,12 +419,12 @@ export class Channel { /** * Upload a file to this channel’s file endpoint (multipart). Forwards to the client’s `sendFile` implementation. * - * @param uri File source: URL string, `File`, `Buffer`, or readable stream (Node). - * @param name File name sent in the multipart body. - * @param contentType MIME type; defaults are applied when omitted. - * @param user Optional user payload appended to the form as JSON. - * @param axiosRequestConfig Optional Axios per-request config, merged after upload defaults (e.g. `onUploadProgress`, `signal` from `AbortController`). - * @return Promise resolving to `{ file: string, ... }` with the CDN URL. + * @param uri - File source: URL string, `File`, `Buffer`, or readable stream (Node). + * @param name - File name sent in the multipart body (optional). + * @param contentType - MIME type; defaults are applied when omitted (optional). + * @param user - User payload appended to the form as JSON (optional). + * @param axiosRequestConfig - Axios per-request config, merged after upload defaults, e.g. `onUploadProgress`, `signal` from `AbortController` (optional). + * @returns A promise resolving to `{ file: string, ... }` with the CDN URL. */ sendFile( uri: string | NodeJS.ReadableStream | Buffer | File, @@ -457,7 +433,7 @@ export class Channel { user?: UserResponse, axiosRequestConfig?: AxiosRequestConfig, ) { - return this.getClient().sendFile( + return this.getClient().api.sendFile( `${this._channelURL()}/file`, uri, name, @@ -468,14 +444,14 @@ export class Channel { } /** - * Upload an image to this channel’s image endpoint (multipart). Uses the same transport as `sendFile`. + * Upload an image to this channel's image endpoint (multipart). Uses the same transport as `sendFile`. * - * @param uri Image source: URL string, `File`, or readable stream (Node). For `Buffer` uploads, use `sendFile` toward the channel file endpoint instead. - * @param name File name sent in the multipart body. - * @param contentType MIME type. - * @param user Optional user payload appended to the form as JSON. - * @param axiosRequestConfig Optional Axios per-request config, merged after upload defaults (e.g. `onUploadProgress`, `signal`). - * @return Promise resolving to `{ file: string, ... }` with the CDN URL. + * @param uri - Image source: URL string, `File`, or readable stream (Node). For `Buffer` uploads, use `sendFile` toward the channel file endpoint instead. + * @param name - File name sent in the multipart body (optional). + * @param contentType - MIME type (optional). + * @param user - User payload appended to the form as JSON (optional). + * @param axiosRequestConfig - Axios per-request config, merged after upload defaults, e.g. `onUploadProgress`, `signal` (optional). + * @returns A promise resolving to `{ file: string, ... }` with the CDN URL. */ sendImage( uri: string | NodeJS.ReadableStream | File, @@ -484,7 +460,7 @@ export class Channel { user?: UserResponse, axiosRequestConfig?: AxiosRequestConfig, ) { - return this.getClient().sendFile( + return this.getClient().api.sendFile( `${this._channelURL()}/image`, uri, name, @@ -495,176 +471,80 @@ export class Channel { } deleteFile(url: string) { - return this.getClient().delete(`${this._channelURL()}/file`, { url }); + return this.deleteChannelFile({ url }); } deleteImage(url: string) { - return this.getClient().delete(`${this._channelURL()}/image`, { url }); + return this.deleteChannelImage({ url }); } /** - * sendEvent - Send an event on this channel - * - * @param {Event} event for example {type: 'message.read'} + * Sends an event on this channel. * - * @return {Promise} The Server Response + * @param event - For example `{ type: 'message.read' }`. + * @returns The server response. */ - async sendEvent(event: Event) { + override async sendEvent(request: { event: Event }) { this._checkInitialized(); - return await this.getClient().post(this._channelURL() + '/event', { - event, - }); + return await super.sendEvent(request); } /** - * search - Query messages - * - * @param {MessageFilters | string} query search query or object MongoDB style filters - * @param {{client_id?: string; connection_id?: string; query?: string; message_filter_conditions?: MessageFilters}} options Option object, {user_id: 'tommaso'} + * Queries messages. * - * @return {Promise} search messages response + * @param request - The search request payload (optional). The inner `payload` accepts + * MongoDB-style filters and additional options such as `user_id`. + * @returns The search messages response. */ - async search( - query: MessageFilters | string, - options: SearchOptions & { - client_id?: string; - connection_id?: string; - message_filter_conditions?: MessageFilters; - 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 = { - filter_conditions: { cid: this.cid } as ChannelFilters, - ...options, - sort: options.sort - ? normalizeQuerySort(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( - this.getClient().baseURL + '/search', - { - payload, - }, - ); + async search(request?: { payload?: SearchPayload }) { + return await this.getClient().search(request); } /** - * queryMembers - Query Members + * Queries members. * - * @param {MemberFilters} filterConditions object MongoDB style filters - * @param {MemberSort} [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} Query Members response + * @param request - The query members request payload (optional). The inner `payload` accepts + * MongoDB-style filters, sort directions (e.g. `[{ field: 'created_at', direction: -1 }]`), + * and pagination options (`limit`, `offset`). + * @returns The query members response. */ - async queryMembers( - filterConditions: MemberFilters, - sort: MemberSort = [], - options: QueryMembersOptions = {}, - ) { - let id: string | undefined; - const type = this.type; - let members: string[] | ChannelMemberResponse[] | undefined; + async queryMembers(request?: { payload?: Partial }) { + const payload = { + type: this.type, + // TODO: these should be probably optional in the OAPI spec + // filter_conditions: ... + } as QueryMembersPayload; + if (this.id) { - id = this.id; - } else if (this.data?.members && Array.isArray(this.data.members)) { - members = this.data.members; + payload.id = this.id; + } else if (Array.isArray(this.data?.members)) { + payload.members = this.data.members.map((m) => ({ + ...m, + // TODO: this should not be needed Gen_QueryMembersResponse should not come with user_id as optinal + user_id: (m.user_id ?? m.user?.id) as string, + })); } // Return a list of members - return await this.getClient().get( - this.getClient().baseURL + '/members', - { - payload: { - type, - id, - members, - sort: normalizeQuerySort(sort), - filter_conditions: filterConditions, - ...options, - }, + return await this.getClient().queryMembers({ + payload: { + ...payload, + ...request?.payload, }, - ); - } - - /** - * updateMemberPartial - Partial update a member - * - * @param {PartialUpdateMember} updates - * @param {{ user_id?: string }} [options] Option object, {user_id: 'jane'} to optionally specify the user id - - * @return {Promise} Updated member - */ - async updateMemberPartial(updates: PartialUpdateMember, options?: { userId?: string }) { - const url = new URL(`${this._channelURL()}/member`); - - if (options?.userId) { - url.searchParams.append('user_id', options.userId); - } - - return await this.getClient().patch( - url.toString(), - updates, - ); - } - - /** - * @deprecated Use `updateMemberPartial` instead - * partialUpdateMember - Partial update a member - * - * @param {string} user_id member user id - * @param {PartialUpdateMember} updates - * - * @return {Promise} Updated member - */ - async partialUpdateMember(user_id: string, updates: PartialUpdateMember) { - if (!user_id) { - throw Error('Please specify the user id'); - } - - return await this.getClient().patch( - this._channelURL() + `/member/${encodeURIComponent(user_id)}`, - updates, - ); + }); } /** - * sendReaction - Sends a reaction to a message. If offline support is enabled, it will make sure + * Sends a reaction to a message. If offline support is enabled, it will make sure * that sending the reaction is queued up if it fails due to bad internet conditions and executed * later. * - * @param {string} messageID the message id - * @param {Reaction} 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} The Server Response + * @param request - The send-reaction request payload, including the target message ID, the + * reaction object (e.g. `{ type: 'love' }`), and optional flags such as `enforce_unique` and + * `skip_push`. + * @returns The server response. */ - async sendReaction( - messageID: string, - reaction: Reaction, - options?: SendReactionOptions, - ) { - if (!messageID) { - throw Error(`Message id is missing`); - } - if (!reaction || Object.keys(reaction).length === 0) { - throw Error(`Reaction object is missing`); - } + async sendReaction(request: Parameters[0]) { + const { id: messageId } = request; try { const offlineDb = this.getClient().offlineDb; @@ -673,71 +553,36 @@ export class Channel { task: { channelId: this.id as string, channelType: this.type, - messageId: messageID, - payload: [messageID, reaction, options], + messageId, + payload: [request], type: 'send-reaction', }, }); } } catch (error) { - this._client.logger('error', `offlineDb:send-reaction`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('sendReaction', this.cid) + .error('Sending the reaction failed.', { error }); } - return this._sendReaction(messageID, reaction, options); + return this._sendReaction(request); } - /** - * sendReaction - Send a reaction about a message - * - * @param {string} messageID the message id - * @param {Reaction} 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} The Server Response - */ - async _sendReaction( - messageID: string, - reaction: Reaction, - options?: SendReactionOptions, - ) { - 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( - this.getClient().baseURL + `/messages/${encodeURIComponent(messageID)}/reaction`, - { - reaction, - ...options, - }, - ); + _sendReaction(request: Parameters[0]) { + return this.getClient().sendReaction(request); } - async deleteReaction(messageID: string, reactionType: string, user_id?: string) { + async deleteReaction(request: Parameters[0]) { this._checkInitialized(); - if (!reactionType || !messageID) { - throw Error( - 'Deleting a reaction requires specifying both the message and reaction type', - ); - } try { const offlineDb = this.getClient().offlineDb; if (offlineDb) { - const message = this.messagePaginator.getItem(messageID); + const message = this.messagePaginator.getItem(request.id); const reaction = { - created_at: '', - updated_at: '', - message_id: messageID, - type: reactionType, - user_id: (this.getClient().userID as string) ?? user_id, - }; + message_id: request.id, + type: request.type, + } as ReactionResponse; if (message) { await offlineDb.deleteReaction({ @@ -750,178 +595,108 @@ export class Channel { task: { channelId: this.id as string, channelType: this.type, - messageId: messageID, - payload: [messageID, reactionType], + messageId: request.id, + payload: [request], type: 'delete-reaction', }, }); } } catch (error) { - this._client.logger('error', `offlineDb:delete-reaction`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('deleteReaction', this.cid) + .error('Deleting the reaction failed.', { error }); } - return await this._deleteReaction(messageID, reactionType, user_id); + return await this._deleteReaction(request); } /** - * 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 + * Deletes a reaction by user and type. * - * @return {Promise} The Server Response + * @param request - The delete reaction request payload identifying the target message and reaction type. + * @returns The server response. */ - async _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 await this.getClient().delete(url, { user_id }); - } - - return await this.getClient().delete(url, {}); + async _deleteReaction(request: Parameters[0]) { + return await this.getClient().deleteReaction(request); } /** - * update - Edit the channel's custom properties + * Edit the channel using the inherited `update()` from `ChannelApi`. Caches the + * server-returned channel onto `this.data`. * - * @param {ChannelData} channelData The object to update the custom properties of this channel with - * @param {Message} [updateMessage] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param request - Channel update payload, e.g. `{ data: { name: 'foo' }, message }` (optional). + * @returns The server response. */ - async update( - channelData: Partial = {}, - updateMessage?: Message, - options?: ChannelUpdateOptions, - ) { - // Strip out reserved names that will result in API errors. - // TODO: this needs to be typed better - const reserved: Exclude< - keyof (ChannelResponse & ChannelData), - keyof CustomChannelData - >[] = [ - '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, - }); + override async update(request?: Gen_UpdateChannelRequest) { + const previousData = this.data; + const data = await super.update(request); + this.data = data.channel; + this._syncStateFromChannelData(this.data, previousData); + return data; } /** - * updatePartial - partial update channel properties - * - * @param {PartialUpdateChannel} partial update request + * Partial update of channel properties. * - * @return {Promise} + * @param update - The partial update request. + * @returns The server response. */ - async updatePartial(update: PartialUpdateChannel) { - const data = await this.getClient().patch( - this._channelURL(), - update, - ); + async updatePartial(update: UpdateChannelPartialRequest) { + const data = await this.updateChannelPartial(update); + + if (!this.getClient()._cacheEnabled) return data; + + const channel = data.channel; + const currentCapabilities = this.data?.own_capabilities ?? []; + const newCapabilities = channel?.own_capabilities; + + const capabilitiesChanged = + newCapabilities && + [...currentCapabilities].sort().join() !== [...newCapabilities].sort().join(); - const areCapabilitiesChanged = - [...(data.channel.own_capabilities || [])].sort().join() !== - [ - ...(Array.isArray(this.data?.own_capabilities) - ? (this.data?.own_capabilities as string[]) - : []), - ] - .sort() - .join(); const previousData = this.data; - this.data = data.channel; + this.data = channel; this._syncStateFromChannelData(this.data, previousData); // If the capabiltities are changed, we trigger the `capabilities.changed` event. - if (areCapabilitiesChanged) { + if (capabilitiesChanged) { this.getClient().dispatchEvent({ type: 'capabilities.changed', cid: this.cid, - own_capabilities: data.channel.own_capabilities, + own_capabilities: newCapabilities, }); } + return data; } /** - * enableSlowMode - enable slow mode + * Enables slow mode. * - * @param {number} coolDownInterval the cooldown interval in seconds - * @return {Promise} The server response + * @param coolDownInterval - The cooldown interval in seconds. + * @returns The server response. */ async enableSlowMode(coolDownInterval: number) { - const data = await this.getClient().post( - this._channelURL(), - { - cooldown: coolDownInterval, - }, - ); - const previousData = this.data; - this.data = data.channel; - this._syncStateFromChannelData(this.data, previousData); - return data; + return await this.update({ cooldown: coolDownInterval }); } /** - * disableSlowMode - disable slow mode + * Disables slow mode. * - * @return {Promise} The server response + * @returns The server response. */ async disableSlowMode() { - const data = await this.getClient().post( - this._channelURL(), - { - cooldown: 0, - }, - ); - const previousData = this.data; - this.data = data.channel; - this._syncStateFromChannelData(this.data, previousData); - return data; + return await this.update({ cooldown: 0 }); } - public async sendSharedLocation( - location: StaticLocationPayload | LiveLocationPayload, - userId?: string, - ) { + public async sendSharedLocation(location: SharedLocation & { message_id?: string }) { const result = await this.sendMessage({ - id: location.message_id, - shared_location: location, - user: userId ? { id: userId } : undefined, + message: { + id: location.message_id, + shared_location: location, + }, }); - if ((location as LiveLocationPayload).end_at) { + if (location.end_at) { this.getClient().dispatchEvent({ message: result.message, type: 'live_location_sharing.started', @@ -931,10 +706,10 @@ export class Channel { return result; } - public async stopLiveLocationSharing(payload: UpdateLocationPayload) { - const location = await this.getClient().updateLocation({ + public async stopLiveLocationSharing(payload: UpdateLiveLocationRequest) { + const location = await this.getClient().updateLiveLocation({ ...payload, - end_at: new Date().toISOString(), + end_at: new Date(), }); this.getClient().dispatchEvent({ live_location: location, @@ -943,361 +718,284 @@ export class Channel { } /** - * delete - Delete the channel. Messages are permanently removed. - * - * @param {boolean} [options.hard_delete] Defines if the channel is hard deleted or not + * Accepts an invitation to the channel. * - * @return {Promise} The server response + * @param options - The object to update the custom properties of this channel with (optional, defaults to `{}`). + * @returns The server response. */ - async delete(options: { hard_delete?: boolean } = {}) { - return await this.getClient().delete(this._channelURL(), { - ...options, - }); - } - - /** - * truncate - Removes all messages from the channel - * @param {TruncateOptions} [options] Defines truncation options - * @return {Promise} The server response - */ - async truncate(options: TruncateOptions = {}) { - return await this.getClient().post( - this._channelURL() + '/truncate', - options, - ); + async acceptInvite(options: ChannelUpdateOptions = {}) { + return await this.update({ accept_invite: true, ...options }); } /** - * acceptInvite - accept invitation to the channel - * - * @param {UpdateChannelOptions} [options] The object to update the custom properties of this channel with + * Rejects an invitation to the channel. * - * @return {Promise} The server response + * @param options - The object to update the custom properties of this channel with (optional, defaults to `{}`). + * @returns The server response. */ - async acceptInvite(options: UpdateChannelOptions = {}) { - return await this._update({ accept_invite: true, ...options }); + async rejectInvite(options: ChannelUpdateOptions = {}) { + return await this.update({ reject_invite: true, ...options }); } /** - * rejectInvite - reject invitation to the channel + * Adds members to the channel. * - * @param {UpdateChannelOptions} [options] The object to update the custom properties of this channel with - * - * @return {Promise} The server response - */ - async rejectInvite(options: UpdateChannelOptions = {}) { - return await this._update({ reject_invite: true, ...options }); - } - - /** - * addMembers - add members to the channel - * - * @param {string[] | Array} members An array of members to add to the channel - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param members - An array of members to add to the channel. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async addMembers( - members: string[] | Array, - message?: Message, + members: string[] | Array, + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ add_members: members, message, ...options }); + return await this.update({ + add_members: members.map((member) => + typeof member === 'string' ? { user_id: member } : member, + ), + message, + ...options, + }); } /** - * addFilterTags - add filter tags to the channel + * Adds filter tags to the channel. * - * @param {string[]} tags An array of tags to add to the channel - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param tags - An array of tags to add to the channel. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async addFilterTags( tags: string[], - message?: Message, + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ add_filter_tags: tags, message, ...options }); + return await this.update({ add_filter_tags: tags, message, ...options }); } /** - * removeFilterTags - remove filter tags from the channel + * Removes filter tags from the channel. * - * @param {string[]} tags An array of tags to remove from the channel - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param tags - An array of tags to remove from the channel. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async removeFilterTags( tags: string[], - message?: Message, + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ remove_filter_tags: tags, message, ...options }); + return await this.update({ remove_filter_tags: tags, message, ...options }); } /** - * addModerators - add moderators to the channel + * Adds moderators to the channel. * - * @param {string[]} members An array of member identifiers - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param members - An array of member identifiers. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async addModerators( members: string[], - message?: Message, + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ add_moderators: members, message, ...options }); + return await this.update({ add_moderators: members, message, ...options }); } /** - * assignRoles - sets member roles in a channel + * Sets member roles in a channel. * - * @param {{channel_role: Role, user_id: string}[]} roles List of role assignments - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param roles - List of role assignments. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async assignRoles( - roles: { channel_role: Role; user_id: string }[], - message?: Message, + roles: { channel_role: RoleName; user_id: string }[], + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ assign_roles: roles, message, ...options }); + return await this.update({ assign_roles: roles, message, ...options }); } /** - * inviteMembers - invite members to the channel + * Invite members to the channel. * - * @param {string[] | Array} members An array of members to invite to the channel - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param members - An array of members to invite to the channel. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async inviteMembers( - members: string[] | Required>[], - message?: Message, + members: string[] | Required>[], + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ invites: members, message, ...options }); + return await this.update({ + invites: members.map((member) => + typeof member === 'string' ? { user_id: member } : member, + ), + message, + ...options, + }); } /** - * removeMembers - remove members from channel + * Removes members from the channel. * - * @param {string[]} members An array of member identifiers - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param members - An array of member identifiers. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async removeMembers( members: string[], - message?: Message, + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ remove_members: members, message, ...options }); + return await this.update({ remove_members: members, message, ...options }); } /** - * demoteModerators - remove moderator role from channel members + * Removes the moderator role from channel members. * - * @param {string[]} members An array of member identifiers - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param members - An array of member identifiers. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async demoteModerators( members: string[], - message?: Message, + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ demote_moderators: members, message, ...options }); - } - - /** - * _update - executes channel update request - * @param payload Object Update Channel payload - * @return {Promise} 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( - this._channelURL(), - payload, - ); - const previousData = this.data; - this.data = data.channel; - this._syncStateFromChannelData(this.data, previousData); - return data; + return await this.update({ demote_moderators: members, message, ...options }); } /** - * mute - mutes the current channel - * @param {{ user_id?: string, expiration?: string }} opts expiration in minutes or user_id - * @return {Promise} The server response + * Mutes the current channel. * - * example with expiration: - * await channel.mute({expiration: moment.duration(2, 'weeks')}); + * @example + * // with expiration + * await channel.mute({ expiration: moment.duration(2, 'weeks') }); * - * example server side: - * await channel.mute({user_id: userId}); + * @example + * // server side + * await channel.mute({ user_id: userId }); * + * @param options - Mute options (optional, defaults to `{}`). + * @param options.expiration - Expiration in minutes (optional). + * @returns The server response. */ - async mute(opts: { expiration?: number; user_id?: string } = {}) { - return await this.getClient().post( - this.getClient().baseURL + '/moderation/mute/channel', - { - channel_cid: this.cid, - ...opts, - }, - ); + async mute(options?: Gen_MuteChannelRequest) { + return await this.getClient().muteChannel({ + channel_cids: [this.cid], + ...options, + }); } /** - * unmute - mutes the current channel - * @param {{ user_id?: string}} opts user_id - * @return {Promise} The server response + * Unmutes the current channel. + * + * @example + * // server side + * await channel.unmute({ user_id: userId }); * - * example server side: - * await channel.unmute({user_id: userId}); + * @param options - Unmute options (optional, defaults to `{}`). + * @param options.user_id - User ID (optional). + * @returns The server response. */ - async unmute(opts: { user_id?: string } = {}) { - return await this.getClient().post( - this.getClient().baseURL + '/moderation/unmute/channel', - { - channel_cid: this.cid, - ...opts, - }, - ); + async unmute(options?: Gen_UnmuteChannelRequest) { + return await this.getClient().unmuteChannel({ + channel_cids: [this.cid], + ...options, + }); } /** - * archive - archives the current channel - * @param {{ user_id?: string }} opts user_id if called server side - * @return {Promise} The server response - * - * example: - * await channel.archives(); + * Archives the current channel. * - * example server side: - * await channel.archive({user_id: userId}); + * @example + * await channel.archive(); * + * @returns The server response. */ - 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; + async archive() { + return await this.updateMemberPartial({ set: { archived: true } }); } /** - * unarchive - unarchives the current channel - * @param {{ user_id?: string }} opts user_id if called server side - * @return {Promise} The server response + * Unarchives the current channel. * - * example: + * @example * await channel.unarchive(); * - * example server side: - * await channel.unarchive({user_id: userId}); - * + * @returns The server response. */ - 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; + async unarchive() { + return await this.updateMemberPartial({ set: { archived: false } }); } /** - * pin - pins the current channel - * @param {{ user_id?: string }} opts user_id if called server side - * @return {Promise} The server response + * Pins the current channel. * - * example: + * @example * await channel.pin(); * - * example server side: - * await channel.pin({user_id: userId}); - * + * @returns The server response. */ - 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; + async pin() { + return await this.updateMemberPartial({ set: { pinned: true } }); } /** - * unpin - unpins the current channel - * @param {{ user_id?: string }} opts user_id if called server side - * @return {Promise} The server response + * Unpins the current channel. * - * example: + * @example * await channel.unpin(); * - * example server side: - * await channel.unpin({user_id: userId}); - * + * @returns The server response. */ - 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; + async unpin() { + return await this.updateMemberPartial({ set: { pinned: false } }); } /** - * 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} + * Returns the mute status for the current channel. + * + * @returns An object of the form `{ muted: true | false, createdAt: Date | null, expiresAt: Date | null }`. */ - muteStatus(): { - createdAt: Date | null; - expiresAt: Date | null; - muted: boolean; - } { + muteStatus() { this._checkInitialized(); return this.getClient()._muteStatus(this.cid); } - sendAction(messageID: string, formData: Record) { + sendAction(messageId: string, formData: Record) { this._checkInitialized(); - if (!messageID) { - throw Error(`Message id is missing`); + if (!messageId) { + throw Error(`MessageRequest id is missing`); } - return this.getClient().post( - this.getClient().baseURL + `/messages/${encodeURIComponent(messageID)}/action`, - { - message_id: messageID, - form_data: formData, - id: this.id, - type: this.type, - }, - ); + return this.getClient().runMessageAction({ + id: messageId, + form_data: formData, + }); } /** - * keystroke - First of the typing.start and typing.stop events based on the users keystrokes. - * Call this on every keystroke + * First of the `typing.start` and `typing.stop` events based on the user's 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 + * + * @param parentId - Set this field to `message.id` to indicate that the typing event is happening in a thread (optional). + * @param options - Optional override carrying a `user_id` (optional). */ - async keystroke(parent_id?: string, options?: { user_id: string }) { + async keystroke(parentId?: string, options?: { user_id: string }) { if (!this._isTypingIndicatorsEnabled()) { return; } @@ -1309,10 +1007,14 @@ export class Channel { if (diff === null || diff > 2000) { this.lastTypingEvent = new Date(); await this.sendEvent({ - type: 'typing.start', - parent_id, - ...(options || {}), - } as Event); + event: { + type: 'typing.start', + parent_id: parentId, + ...(options || {}), + created_at: new Date(), + custom: {}, + }, + }); } } @@ -1321,8 +1023,9 @@ export class Channel { * 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. + * @param state - The new state of the AI process, e.g. thinking, generating. + * @param options - Parameters such as `ai_message` to include additional details in the event (optional, defaults to `{}`). + * @param options.ai_message - Additional message detail to include in the event (optional). */ async updateAIState( messageId: string, @@ -1330,11 +1033,15 @@ export class Channel { options: { ai_message?: string } = {}, ) { await this.sendEvent({ - ...options, - type: 'ai_indicator.update', - message_id: messageId, - ai_state: state, - } as Event); + event: { + ...options, + type: 'ai_indicator.update', + message_id: messageId, + ai_state: state, + created_at: new Date(), + custom: {}, + }, + }); } /** @@ -1343,8 +1050,12 @@ export class Channel { */ async clearAIIndicator() { await this.sendEvent({ - type: 'ai_indicator.clear', - } as Event); + event: { + type: 'ai_indicator.clear', + created_at: new Date(), + custom: {}, + }, + }); } /** @@ -1353,26 +1064,37 @@ export class Channel { */ async stopAIResponse() { await this.sendEvent({ - type: 'ai_indicator.stop', - } as Event); + event: { + type: 'ai_indicator.stop', + created_at: new Date(), + custom: {}, + }, + }); } /** - * stopTyping - Sets last typing to null and sends the typing.stop event + * 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 + * + * @param parentId - Set this field to `message.id` to indicate that the typing event is happening in a thread (optional). + * @param options - Optional override carrying a `user_id` (optional). */ - async stopTyping(parent_id?: string, options?: { user_id: string }) { + async stopTyping(parentId?: 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); + event: { + type: 'typing.stop', + parent_id: parentId, + ...(options || {}), + created_at: new Date(), + custom: {}, + }, + }); } _isTypingIndicatorsEnabled(): boolean { @@ -1383,53 +1105,53 @@ export class Channel { } /** - * markRead - Send the mark read event for this user, only works if the `read_events` setting is enabled. Syncs the message delivery report candidates local state. + * Run this user's mark-read reporter for this channel. Delegates to + * `MessageDeliveryReporter`, which batches the underlying `markRead` request + * with the user's read receipts state. + * + * Use the inherited `markRead()` from `ChannelApi` for a direct, unbatched call. * - * @param {MarkReadOptions} data - * @return {Promise} Description + * @param data - Mark read options (optional, defaults to `{}`). */ - async markRead(data: MarkReadOptions = {}) { + async markReadViaReporter(data: MarkReadRequest = {}) { return await this.getClient().messageDeliveryReporter.markRead(this, data); } /** - * markAsReadRequest - Send the mark read event for this user, only works if the `read_events` setting is enabled + * Override of the inherited `markRead()` from `ChannelApi` that requires the + * channel to be initialized and respects the `read_events` channel config. * - * @param {MarkReadOptions} data - * @return {Promise} Description + * @param data - Mark read options (optional, defaults to `{}`). + * @returns The server response, or `null` if the request was skipped. */ - async markAsReadRequest(data: MarkReadOptions = {}) { + override async markRead(data?: MarkReadRequest) { this._checkInitialized(); - if (!this.getConfig()?.read_events && !this.getClient()._isUsingServerAuth()) { - return null; + if (!this.getConfig()?.read_events) { + throw new Error('Read events are disabled for this application'); } - return await this.getClient().post(this._channelURL() + '/read', { - ...data, - }); + return await super.markRead(data); } /** - * markUnread - Mark the channel as unread from messageID, only works if the `read_events` setting is enabled + * Marks the channel as unread from `messageId`. Only works when the `read_events` setting is enabled. * - * @param {MarkUnreadOptions} data - * @return {APIResponse} An API response + * @param data - Mark unread options. + * @returns An API response, or `null` if the request was skipped. */ - async markUnread(data: MarkUnreadOptions) { + override async markUnread(data?: MarkUnreadRequest) { this._checkInitialized(); - if (!this.getConfig()?.read_events && !this.getClient()._isUsingServerAuth()) { - return Promise.resolve(null); + if (!this.getConfig()?.read_events) { + throw new Error('Read events are disabled for this application'); } - return await this.getClient().post(this._channelURL() + '/unread', { - ...data, - }); + return await super.markUnread(data); } /** - * markReadLocally - Resets this user's unread count locally, without any backend call. Intended for + * Resets this user's unread count locally, without any backend call. Intended for * channels that have read events disabled (e.g. livestreams) when the client is created with the * `isLocalUnreadCountEnabled` option. Dispatches a dedicated, client-only `message.read_locally` event * that runs through the same `_handleChannelEvent` read logic as a real `message.read` (minus the @@ -1437,21 +1159,21 @@ export class Channel { * is enabled, the offline DB persists the reset for read-events-disabled channels, so the local * count stays consistent across app restarts. * - * @return {Event | undefined} The dispatched `message.read_locally` event, or `undefined` if there is no connected user. + * @returns The dispatched `message.read_locally` event, or `undefined` if there is no connected user. */ markReadLocally() { const client = this.getClient(); - if (!client.userID) return; + if (!client.userId) return; - const event: Event = { + const event: EventPayload<'message.read_locally'> = { channel_id: this.id, channel_type: this.type, cid: this.cid, - created_at: new Date().toISOString(), + created_at: new Date(), last_read_message_id: this.messagePaginator.headmostItem?.id, team: this.data?.team, type: 'message.read_locally', - user: client.user, + user: client.user as UserResponse, }; client.dispatchEvent(event); @@ -1459,7 +1181,7 @@ export class Channel { } /** - * clean - Cleans the channel state and fires stop typing if needed + * Cleans the channel state and fires stop typing if needed. */ clean() { if (this.lastKeyStroke) { @@ -1474,13 +1196,12 @@ export class Channel { } /** - * watch - Loads the initial channel state and watches for changes + * Loads the initial channel state and watches for changes. * - * @param {ChannelQueryOptions} options additional options for the query endpoint - * - * @return {Promise} The server response + * @param options - Additional options for the query endpoint (optional). + * @returns The server response. */ - async watch(options?: ChannelQueryOptions) { + async watch(options?: ChannelGetOrCreateRequest) { const defaultOptions = { state: true, watch: true, @@ -1505,133 +1226,94 @@ export class Channel { // so a channel opened via watch() alone — a deep-link restore, a search result, a freshly // created DM — already has its latest page loaded here. - this._client.logger( - 'info', - `channel:watch() - started watching channel ${this.cid}`, - { - tags: ['channel'], - channel: this, - }, - ); + logger.withExtraTags('watch', this.cid).info('Started watching the channel.'); return state; } /** - * stopWatching - Stops watching the channel + * Stops watching the channel. * - * @return {Promise} The server response + * @param request - The stop-watching request payload (optional). + * @returns The server response. */ - async stopWatching() { - const response = await this.getClient().post( - this._channelURL() + '/stop-watching', - {}, - ); + override async stopWatching(request?: Gen_ChannelStopWatchingRequest) { + const response = await super.stopWatching(request); - this._client.logger( - 'info', - `channel:watch() - stopped watching channel ${this.cid}`, - { - tags: ['channel'], - channel: this, - }, - ); + logger.withExtraTags('stopWatching', this.cid).info('Stopped watching the channel.'); return response; } /** - * getReplies - List the message replies for a parent message. + * List the message replies for a parent message. * - * The recommended way of working with threads is to use the Thread class. + * 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; user_id?: string }} options Pagination params, ie {limit:10, id_lte: 10} - * - * @return {Promise} A response with a list of messages + * @param request - The get-replies request payload, including the parent message ID, pagination + * params, and optional sort directions for `created_at`. + * @returns A response with a list of messages. */ - async getReplies( - parent_id: string, - options: MessagePaginationOptions & { user?: UserResponse; user_id?: string }, - sort?: { created_at: AscDesc }[], - ) { - const normalizedSort = sort ? normalizeQuerySort(sort) : undefined; - const data = await this.getClient().get( - this.getClient().baseURL + `/messages/${encodeURIComponent(parent_id)}/replies`, - { - sort: normalizedSort, - ...options, - }, - ); + async getReplies(request: GetRepliesRequest) { + const data = await this.getClient().getReplies(request); // Thread reply state is owned by the Thread object (Thread.messagePaginator); the returned // replies are consumed there. The channel message list is owned by channel.messagePaginator. return data; } + // TODO: find out v2 equivalent /** - * getPinnedMessages - List list pinned messages of the channel - * - * @param {PinnedMessagePaginationOptions & { user?: UserResponse; user_id?: string }} options Pagination params, ie {limit:10, id_lte: 10} - * @param {PinnedMessagesSort} sort defines sorting direction of pinned messages + * List pinned messages of the channel. * - * @return {Promise} A response with a list of messages + * @param options - Pagination params, e.g. `{ limit: 10, id_lte: 10 }`. + * @param sort - Defines sorting direction of pinned messages (optional, defaults to `[]`). + * @returns A response with a list of messages. */ async getPinnedMessages( - options: PinnedMessagePaginationOptions & { user?: UserResponse; user_id?: string }, + options: PinnedMessagePaginationOptions, sort: PinnedMessagesSort = [], ) { - return await this.getClient().get( + return await this.getClient().api.get( this._channelURL() + '/pinned_messages', { payload: { ...options, - sort: normalizeQuerySort(sort), + sort, }, }, ); } /** - * getReactions - List the reactions, supports pagination + * List the reactions; supports pagination. * - * @param {string} message_id The message id - * @param {{ limit?: number; offset?: number }} options The pagination options - * - * @return {Promise} Server response + * @param request - The request payload, including the target message ID and + * pagination options (`limit`, `offset`). + * @returns The server response. */ - getReactions(message_id: string, options: { limit?: number; offset?: number }) { - return this.getClient().get( - this.getClient().baseURL + `/messages/${encodeURIComponent(message_id)}/reactions`, - { - ...options, - }, - ); + getReactions(request: Parameters[0]) { + return this.getClient().getReactions(request); } /** - * getMessagesById - Retrieves a list of messages by ID + * Retrieves a list of messages by ID. * - * @param {string[]} messageIds The ids of the messages to retrieve from this channel - * - * @return {Promise} Server response + * @param messageIds - The IDs of the messages to retrieve from this channel. + * @returns Server response. */ getMessagesById(messageIds: string[]) { - return this.getClient().get( - this._channelURL() + '/messages', - { - ids: messageIds.join(','), - }, - ); + return this.getManyMessages({ ids: messageIds }); } /** - * 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} + * Returns the last time the user marked the channel as read. If the user never marked the channel as read, this will return `null`. + * + * @returns The last-read `Date`, `null` if never read, or `undefined` if the user is unset. */ lastRead() { - const { userID } = this.getClient(); - if (userID) { - return this.state.read[userID] ? this.state.read[userID].last_read : null; + const { userId } = this.getClient(); + if (userId) { + return this.state.read[userId] ? this.state.read[userId].last_read : null; } } @@ -1639,7 +1321,7 @@ export class Channel { 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().userId) return false; if (message.user?.id && this.getClient().userMuteStatus(message.user.id)) return false; @@ -1661,11 +1343,10 @@ export class Channel { } /** - * countUnread - Count of unread messages + * 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 + * @param lastRead - The time that the user read a message (optional, defaults to the current user's read state). + * @returns Unread count. */ countUnread(lastRead?: Date | null) { if (!lastRead) return this.state.unreadCount; @@ -1681,13 +1362,13 @@ export class Channel { } /** - * countUnreadMentions - Count the number of unread messages mentioning the current user + * Count the number of unread messages mentioning the current user. * - * @return {number} Unread mentions count + * @returns Unread mentions count. */ countUnreadMentions() { const lastRead = this.lastRead(); - const userID = this.getClient().userID; + const userId = this.getClient().userId; let count = 0; const latestMessages = this.messagePaginator.headItems; @@ -1696,7 +1377,7 @@ export class Channel { if ( this._countMessageAsUnread(message) && (!lastRead || message.created_at > lastRead) && - message.mentioned_users?.some((user) => user.id === userID) + message.mentioned_users?.some((user) => user.id === userId) ) { count++; } @@ -1705,12 +1386,12 @@ export class Channel { } /** - * create - Creates a new channel - * - * @return {Promise} The Server Response + * Creates a new channel. * + * @param options - Channel query options (optional). + * @returns The server response. */ - create = async (options?: ChannelQueryOptions) => { + create = async (options?: ChannelGetOrCreateRequest) => { const defaultOptions = { ...options, watch: false, @@ -1720,54 +1401,43 @@ export class Channel { return await this.query(defaultOptions, 'latest'); }; - async _query(options: ChannelQueryOptions = {}) { + /** + * Queries the API to load messages, members, or other channel fields. + * + * @param options - The query options (optional, defaults to `{}`). + * @param messageSetToAddToIfDoesNotExist - It's possible to load disjunct sets of a channel's + * messages into state. Use `current` to load the initial channel state or to extend the + * currently displayed messages; use `latest` to load/extend the latest messages; `new` is + * used for loading a specific message and its surroundings (optional, defaults to `'current'`). + * @returns A query response. + */ + async query( + options: ChannelGetOrCreateRequest = {}, + messageSetToAddToIfDoesNotExist: MessageSetType = 'current', + ) { // Make sure we wait for the connect promise if there is a pending one await this.getClient().wsPromise; - const createdById = - options.created_by?.id ?? - options.created_by_id ?? - this._data?.created_by?.id ?? - this._data?.created_by_id; - - if (this.getClient()._isUsingServerAuth() && typeof createdById !== 'string') { - this.getClient().logger( - 'warn', - 'Either `created_by` (with `id` property) or `created_by_id` are missing from both `Channel._data` and `options` parameter', - ); - } - - let queryURL = `${this.getClient().baseURL}/channels/${encodeURIComponent( - this.type, - )}`; - if (this.id) { - queryURL += `/${encodeURIComponent(this.id)}`; - } - - return await this.getClient().post(queryURL + '/query', { + const queryPayload: ChannelGetOrCreateRequest = { data: this._data, state: true, ...options, - }); - } + }; + + const state = this.id + ? await this.getOrCreate(queryPayload) + : await this.getClient().getOrCreateDistinctChannel({ + type: this.type, + ...queryPayload, + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const channel = state.channel!; - /** - * query - Query the API, get messages, members or other channel fields - * - * @param {ChannelQueryOptions} 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} Returns a query response - */ - async query( - options: ChannelQueryOptions = {}, - messageSetToAddToIfDoesNotExist: MessageSetType = 'current', - ) { - const state = await this._query(options); // update the channel id if it was missing if (!this.id) { - this.id = state.channel.id; - this.cid = state.channel.cid; + this.id = channel.id; + this.cid = channel.cid; // set the channel as active... const tempChannelCid = generateChannelTempCid( @@ -1789,12 +1459,12 @@ export class Channel { } } - this.getClient()._addChannelConfig(state.channel); + this.getClient()._addChannelConfig(channel); // the only config param that is necessary to be updated based on server config soon as the config is delivered - if (typeof state.channel.config?.shared_locations !== 'undefined') { + if (typeof channel.config?.shared_locations !== 'undefined') { this.messageComposer.updateConfig({ - location: { enabled: state.channel.config.shared_locations }, + location: { enabled: channel.config.shared_locations }, }); } @@ -1831,7 +1501,7 @@ export class Channel { this.messageComposer.initStateFromChannelResponse(state); const areCapabilitiesChanged = - [...(state.channel.own_capabilities || [])].sort().join() !== + [...(channel.own_capabilities || [])].sort().join() !== [ ...(this.data && Array.isArray(this.data?.own_capabilities) ? this.data.own_capabilities @@ -1840,7 +1510,7 @@ export class Channel { .sort() .join(); const previousData = this.data; - this.data = state.channel; + this.data = channel; this._syncStateFromChannelData(this.data, previousData); this.offlineMode = false; this.cooldownTimer.refresh(); @@ -1849,7 +1519,7 @@ export class Channel { this.getClient().dispatchEvent({ type: 'capabilities.changed', cid: this.cid, - own_capabilities: state.channel.own_capabilities, + own_capabilities: channel.own_capabilities ?? [], }); } @@ -1874,15 +1544,15 @@ export class Channel { } /** - * banUser - Bans a user from a channel + * Bans a user from a channel. * - * @param {string} targetUserID - * @param {BanUserOptions} options - * @returns {Promise} + * @param targetUserId - The user to ban. + * @param options - Ban options. + * @returns The server response. */ - async banUser(targetUserID: string, options: BanUserOptions) { + async banUser(targetUserId: string, options: BanUserOptions) { this._checkInitialized(); - return await this.getClient().banUser(targetUserID, { + return await this.getClient().banUser(targetUserId, { ...options, type: this.type, id: this.id, @@ -1890,45 +1560,39 @@ export class Channel { } /** - * 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 + * Hides the channel from `queryChannels` for the user until a message is added. + * If `clear_history` is set to `true`, all messages will be removed for the user. * - * @param {string | null} userId - * @param {boolean} clearHistory - * @returns {Promise} + * @param request - The hide channel request payload (optional). Pass `{ clear_history: true }` + * to clear message history for the user. + * @returns The server response. */ - async hide(userId: string | null = null, clearHistory = false) { + override async hide(request?: Gen_HideChannelRequest) { this._checkInitialized(); - - return await this.getClient().post(`${this._channelURL()}/hide`, { - user_id: userId, - clear_history: clearHistory, - }); + return await super.hide(request); } /** - * removes the hidden status for a channel + * Removes the hidden status for a channel. Ensures the channel is initialized first. * - * @param {string | null} userId - * @returns {Promise} + * @param request - The show channel request payload (optional). + * @returns The server response. */ - async show(userId: string | null = null) { + override async show(request?: Gen_ShowChannelRequest) { this._checkInitialized(); - return await this.getClient().post(`${this._channelURL()}/show`, { - user_id: userId, - }); + return await super.show(request); } /** - * unbanUser - Removes the bans for a user on a channel + * Removes the bans for a user on a channel. * - * @param {string} targetUserID - * @param {UnBanUserOptions} options - * @returns {Promise} + * @param targetUserId - The user to unban. + * @param options - Unban options (optional). + * @returns The server response. */ - async unbanUser(targetUserID: string, options?: UnBanUserOptions) { + async unbanUser(targetUserId: string, options?: UnBanUserOptions) { this._checkInitialized(); - return await this.getClient().unbanUser(targetUserID, { + return await this.getClient().unbanUser(targetUserId, { ...options, type: this.type, id: this.id, @@ -1936,15 +1600,15 @@ export class Channel { } /** - * shadowBan - Shadow bans a user from a channel + * Shadow bans a user from a channel. * - * @param {string} targetUserID - * @param {BanUserOptions} options - * @returns {Promise} + * @param targetUserId - The user to shadow ban. + * @param options - Ban options. + * @returns The server response. */ - async shadowBan(targetUserID: string, options: BanUserOptions) { + async shadowBan(targetUserId: string, options: BanUserOptions) { this._checkInitialized(); - return await this.getClient().shadowBan(targetUserID, { + return await this.getClient().shadowBan(targetUserId, { ...options, type: this.type, id: this.id, @@ -1952,218 +1616,176 @@ export class Channel { } /** - * removeShadowBan - Removes the shadow ban for a user on a channel + * Removes the shadow ban for a user on a channel. * - * @param {string} targetUserID - * @returns {Promise} + * @param targetUserId - The user to remove the shadow ban for. + * @returns The server response. */ - async removeShadowBan(targetUserID: string) { + async removeShadowBan(targetUserId: string) { this._checkInitialized(); - return await this.getClient().removeShadowBan(targetUserID, { + return await this.getClient().removeShadowBan(targetUserId, { type: this.type, id: this.id, }); } /** - * 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 + * Casts or cancels one or more votes on a poll. + * + * @param request - The cast-poll-vote request payload, including the target message ID, poll ID, + * and the vote to cast (or an empty payload to cancel). + * @returns The poll vote response. */ - async vote(messageId: string, pollId: string, vote: PollVoteData) { - return await this.getClient().castPollVote(messageId, pollId, vote); + async vote(request: Parameters[0]) { + return await this.getClient().castPollVote(request); } - async removeVote(messageId: string, pollId: string, voteId: string) { - return await this.getClient().removePollVote(messageId, pollId, voteId); + async removeVote(request: Parameters[0]) { + return await this.getClient().deletePollVote(request); } - /** - * createDraft - Creates or updates a draft message in a channel - * - * @param {DraftMessagePayload} message The draft message to create or update - * - * @return {Promise} Response containing the created draft - */ - async _createDraft(message: DraftMessagePayload) { - return await this.getClient().post( - this._channelURL() + '/draft', - { - message, - }, - ); + async _createDraft(request: Gen_CreateDraftRequest) { + return await super.createDraft(request); } /** - * createDraft - Creates or updates a draft message in a channel. If offline support is - * enabled, it will make sure that creating the draft is queued up if it fails due to - * bad internet conditions and executed later. - * - * @param {DraftMessagePayload} message The draft message to create or update - * - * @return {Promise} Response containing the created draft + * Creates or updates a draft message in a channel. If offline support is enabled, the + * call is queued so it is replayed on reconnect. */ - async createDraft(message: DraftMessagePayload) { + override async createDraft(request: Gen_CreateDraftRequest) { try { const offlineDb = this.getClient().offlineDb; if (offlineDb) { - return await offlineDb.queueTask({ + return (await offlineDb.queueTask({ task: { channelId: this.id as string, channelType: this.type, - threadId: message.parent_id, - payload: [message], + threadId: request.message?.parent_id, + payload: [request], type: 'create-draft', }, - }); + })) as Awaited>; } } catch (error) { - this._client.logger('error', `offlineDb:create-draft`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('createDraft', this.cid) + .error('Creating the draft in the offline database failed.', { error }); } - return this._createDraft(message); + return this._createDraft(request); } - /** - * deleteDraft - Deletes a draft message from a channel or a thread. - * - * @param {Object} options - * @param {string} options.parent_id Optional parent message ID for drafts in threads - * - * @return {Promise} API response - */ - async _deleteDraft({ parent_id }: { parent_id?: string } = {}) { - return await this.getClient().delete(this._channelURL() + '/draft', { - parent_id, - }); + async _deleteDraft(request?: Parameters[0]) { + return await super.deleteDraft(request); } /** - * deleteDraft - Deletes a draft message from a channel or a thread. If offline support is - * enabled, it will make sure that deleting the draft is queued up if it fails due to - * bad internet conditions and executed later. - * - * @param {Object} options - * @param {string} options.parent_id Optional parent message ID for drafts in threads - * - * @return {Promise} API response + * Deletes a draft message from a channel or a thread. If offline support is enabled, the + * call is queued so it is replayed on reconnect. */ - async deleteDraft(options: { parent_id?: string } = {}) { - const { parent_id } = options; + override async deleteDraft(request?: Parameters[0]) { try { const offlineDb = this.getClient().offlineDb; if (offlineDb) { - return await offlineDb.queueTask({ + return (await offlineDb.queueTask({ task: { channelId: this.id as string, channelType: this.type, - threadId: parent_id, - payload: [options], + threadId: request?.parent_id, + payload: [request], type: 'delete-draft', }, - }); + })) as Awaited>; } } catch (error) { - this._client.logger('error', `offlineDb:delete-draft`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('deleteDraft', this.cid) + .error('Deleting the draft from the offline database failed.', { error }); } - return this._deleteDraft(options); + return this._deleteDraft(request); } /** - * getDraft - Retrieves a draft message from a channel + * Listens to events on this channel. * - * @param {Object} options - * @param {string} options.parent_id Optional parent message ID for drafts in threads - * - * @return {Promise} Response containing the draft - */ - async getDraft({ parent_id }: { parent_id?: string } = {}) { - return await this.getClient().get(this._channelURL() + '/draft', { - parent_id, - }); - } - - /** - * on - Listen to events on this channel. + * @example + * channel.on('message.new', (event) => { + * console.log('my new message', event, channel.state.messages); + * }); * - * channel.on('message.new', event => {console.log("my new message", event, channel.messagePaginator.state.items)}) - * or - * channel.on(event => {console.log(event.type)}) + * @example + * channel.on((event) => { + * console.log(event.type); + * }); * - * @param {EventHandler | EventTypes} callbackOrString The event type to listen for (optional) - * @param {EventHandler} [callbackOrNothing] The callback to call + * @param callbackOrString - The event type to listen for, or the callback when listening to all events. + * @param callbackOrNothing - The callback to call when an event type was provided (optional). + * @returns An object with an `unsubscribe()` method. */ - on(eventType: EventTypes, callback: EventHandler): { unsubscribe: () => void }; + on( + eventType: T, + callback: EventHandler, + ): { unsubscribe: () => void }; on(callback: EventHandler): { unsubscribe: () => void }; on( - callbackOrString: EventHandler | EventTypes, + callbackOrString: EventHandler | string, callbackOrNothing?: EventHandler, ): { 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, - }, - ); + const key = callbackOrNothing ? (callbackOrString as EventType) : 'all'; + const callback = callbackOrNothing + ? callbackOrNothing + : (callbackOrString as EventHandler); + + const set = this.listeners.get(key) ?? new Set(); - this.listeners[key].push(callback); + logger + .withExtraTags('on', this.cid) + .debug(`Attaching a listener for the "${key}" event.`); + set.add(callback); + + if (!this.listeners.has(key)) { + this.listeners.set(key, set); + } 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); + logger + .withExtraTags('on', this.cid) + .debug(`Removing the listener for the "${key}" event.`); + set.delete(callback); + if (!set.size) { + this.listeners.delete(key); + } }, }; } /** - * off - Remove the event handler + * Removes the event handler. * + * @param callbackOrString - The event type, or the callback when removing an all-events listener. + * @param callbackOrNothing - The callback to remove when an event type was provided (optional). */ - off(eventType: EventTypes, callback: EventHandler): void; + off(eventType: T, callback: EventHandler): void; off(callback: EventHandler): void; - off( - callbackOrString: EventHandler | EventTypes, - callbackOrNothing?: EventHandler, - ): void { - const key = callbackOrNothing ? (callbackOrString as string) : 'all'; - const callback = callbackOrNothing ? callbackOrNothing : callbackOrString; - if (!(key in this.listeners)) { - this.listeners[key] = []; - } + off(callbackOrString: EventHandler | string, callbackOrNothing?: EventHandler): void { + const key = callbackOrNothing ? (callbackOrString as EventType) : 'all'; + const callback = callbackOrNothing + ? callbackOrNothing + : (callbackOrString as EventHandler); - 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((value) => value !== callback); + logger + .withExtraTags('off', this.cid) + .debug(`Removing the listener for the "${key}" event.`); + + const set = this.listeners.get(key); + + set?.delete(callback); + + if (!set?.size) { + this.listeners.delete(key); + } } private _patchReadState( @@ -2219,14 +1841,9 @@ export class Channel { _handleChannelEvent(event: Event) { // eslint-disable-next-line @typescript-eslint/no-this-alias const channel = this; - this._client.logger( - 'info', - `channel:_handleChannelEvent - Received event of type { ${event.type} } on ${this.cid}`, - { - tags: ['event', 'channel'], - channel: this, - }, - ); + logger + .withExtraTags('_handleChannelEvent', this.cid) + .debug(`Received an event of type "${event.type}".`, { event }); const channelState = channel.state; switch (event.type) { @@ -2533,7 +2150,7 @@ export class Channel { }; } - const currentUserId = this.getClient().userID; + const currentUserId = this.getClient().userId; if ( typeof currentUserId === 'string' && typeof memberCopy?.user?.id === 'string' && @@ -2658,8 +2275,9 @@ export class Channel { case 'channel.hidden': { const previousChannelData = channel.data; channel.data = { - ...channel.data, - blocked: !!event.channel?.blocked, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...channel.data!, + blocked: event.channel?.blocked ?? false, hidden: true, }; channel._syncStateFromChannelData(channel.data, previousChannelData); @@ -2672,8 +2290,9 @@ export class Channel { case 'channel.visible': { const previousChannelData = channel.data; channel.data = { - ...channel.data, - blocked: !!event.channel?.blocked, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...channel.data!, + blocked: event.channel?.blocked ?? false, hidden: false, }; channel._syncStateFromChannelData(channel.data, previousChannelData); @@ -2701,36 +2320,26 @@ export class Channel { default: } + const typedEvent = event as Extract; // any event can send over the online count - if (event.watcher_count !== undefined) { - channel.state.watcher_count = event.watcher_count; + if (typeof typedEvent.watcher_count !== 'undefined') { + channel.state.watcher_count = typedEvent.watcher_count; } } - _callChannelListeners = (event: Event) => { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const channel = this; - // gather and call the listeners - const listeners = []; - if (channel.listeners.all) { - listeners.push(...channel.listeners.all); - } - if (channel.listeners[event.type]) { - listeners.push(...channel.listeners[event.type]); - } + _callChannelListeners = (event: WSEvent) => { + const allSet = this.listeners.get('all'); + const targetSet = this.listeners.get(event.type); - // call the event and send it to the listeners - for (const listener of listeners) { - if (typeof listener !== 'string') { - listener(event); - } - } + [allSet, targetSet].forEach((set) => + set?.forEach((handleEvent) => handleEvent(event)), + ); }; /** - * _channelURL - Returns the channel url + * Returns the channel url. * - * @return {string} The channel url + * @returns The channel url. */ _channelURL = () => { if (!this.id) { @@ -2742,11 +2351,7 @@ export class Channel { }; _checkInitialized() { - if ( - !this.initialized && - !this.offlineMode && - !this.getClient()._isUsingServerAuth() - ) { + if (!this.initialized && !this.offlineMode) { throw Error( `Channel ${this.cid} hasn't been initialized yet. Make sure to call .watch() and wait for it to resolve`, ); @@ -2761,7 +2366,7 @@ export class Channel { this.state.syncMemberCountFromChannelData(data, fallbackData); } - _initializeState(state: ChannelAPIResponse) { + _initializeState(state: ChannelStateResponseFields) { const { state: clientState, user, userID } = this.getClient(); // add the members and users @@ -2775,7 +2380,9 @@ export class Channel { } } - this.state.membership = state.membership || {}; + if (state.membership) { + this.state.membership = state.membership; + } // Seed the message paginator's `lastMessageAt` aggregate from the server's authoritative // `last_message_at`. The first-page seed (Channel.query / client.hydrateActiveChannels) also @@ -2813,7 +2420,7 @@ export class Channel { const last_read = this.messagePaginator.lastMessageAt || new Date(); if (user) { readUpdates[user.id] = { - user, + user: user as UserResponse, last_read, unread_messages: 0, }; @@ -2860,7 +2467,9 @@ export class Channel { } } - _extendEventWithOwnReactions(event: Event) { + _extendEventWithOwnReactions( + event: EventPayload<'message.undeleted' | 'message.updated' | 'message.deleted'>, + ) { if (!event.message) { return; } @@ -2907,14 +2516,7 @@ export class Channel { } _disconnect() { - this._client.logger( - 'info', - `channel:disconnect() - Disconnecting the channel ${this.cid}`, - { - tags: ['connection', 'channel'], - channel: this, - }, - ); + logger.withExtraTags('_disconnect', this.cid).info('Disconnecting the channel.'); this.disconnected = true; this.messageReceiptsTracker.unregisterSubscriptions(); diff --git a/src/channel_batch_updater.ts b/src/channel_batch_updater.ts index 88ad0bfb1f..518b9b1f2d 100644 --- a/src/channel_batch_updater.ts +++ b/src/channel_batch_updater.ts @@ -1,211 +1 @@ -import type { StreamChat } from './client'; -import type { - APIResponse, - BatchChannelDataUpdate, - NewMemberPayload, - UpdateChannelsBatchFilters, - UpdateChannelsBatchResponse, -} from './types'; - -/** - * ChannelBatchUpdater - A class that provides convenience methods for batch channel operations - */ -export class ChannelBatchUpdater { - client: StreamChat; - - constructor(client: StreamChat) { - this.client = client; - } - - // Member operations - - /** - * addMembers - Add members to channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {string[] | NewMemberPayload[]} members Members to add - * @return {Promise} The server response - */ - async addMembers( - filter: UpdateChannelsBatchFilters, - members: string[] | NewMemberPayload[], - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'addMembers', - filter, - members, - }); - } - - /** - * removeMembers - Remove members from channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {string[]} members Member IDs to remove - * @return {Promise} The server response - */ - async removeMembers( - filter: UpdateChannelsBatchFilters, - members: string[], - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'removeMembers', - filter, - members, - }); - } - - /** - * inviteMembers - Invite members to channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {string[] | NewMemberPayload[]} members Members to invite - * @return {Promise} The server response - */ - async inviteMembers( - filter: UpdateChannelsBatchFilters, - members: string[] | NewMemberPayload[], - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'inviteMembers', - filter, - members, - }); - } - - /** - * addModerators - Add moderators to channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {string[]} members Member IDs to promote to moderator - * @return {Promise} The server response - */ - async addModerators( - filter: UpdateChannelsBatchFilters, - members: string[], - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'addModerators', - filter, - members, - }); - } - - /** - * demoteModerators - Remove moderator role from members in channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {string[]} members Member IDs to demote - * @return {Promise} The server response - */ - async demoteModerators( - filter: UpdateChannelsBatchFilters, - members: string[], - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'demoteModerators', - filter, - members, - }); - } - - /** - * assignRoles - Assign roles to members in channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {NewMemberPayload[]} members Members with role assignments - * @return {Promise} The server response - */ - async assignRoles( - filter: UpdateChannelsBatchFilters, - members: NewMemberPayload[], - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'assignRoles', - filter, - members, - }); - } - - // Visibility operations - - /** - * hide - Hide channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @return {Promise} The server response - */ - async hide( - filter: UpdateChannelsBatchFilters, - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'hide', - filter, - }); - } - - /** - * show - Show channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @return {Promise} The server response - */ - async show( - filter: UpdateChannelsBatchFilters, - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'show', - filter, - }); - } - - /** - * archive - Archive channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @return {Promise} The server response - */ - async archive( - filter: UpdateChannelsBatchFilters, - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'archive', - filter, - }); - } - - /** - * unarchive - Unarchive channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @return {Promise} The server response - */ - async unarchive( - filter: UpdateChannelsBatchFilters, - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'unarchive', - filter, - }); - } - - // Data operations - - /** - * updateData - Update data on channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {BatchChannelDataUpdate} data Data to update - * @return {Promise} The server response - */ - async updateData( - filter: UpdateChannelsBatchFilters, - data: BatchChannelDataUpdate, - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'updateData', - filter, - data, - }); - } -} +// ChannelBatchUpdater functionality has been moved to the server-side SDK. diff --git a/src/channel_manager.ts b/src/channel_manager.ts index 871599a209..b6f059a68a 100644 --- a/src/channel_manager.ts +++ b/src/channel_manager.ts @@ -1,12 +1,14 @@ import type { QueryChannelsResponseWithChannels, StreamChat } from './client'; import type { ChannelFilters, - ChannelOptions, ChannelSort, ChannelStateOptions, Event, - QueryChannelsAPIResponse, + EventPayload, + QueryChannelsRequest, + QueryChannelsResponse, } from './types'; +import { chatLoggerSystem } from './logger'; import type { ValueOrPatch } from './store'; import { isPatch, StateStore } from './store'; import type { Channel } from './channel'; @@ -29,15 +31,15 @@ import { } from './constants'; import { WithSubscriptions } from './utils/WithSubscriptions'; +const logger = chatLoggerSystem.getLogger('channel-manager'); + export type ChannelManagerPagination = { - filters: ChannelFilters; hasNext: boolean; isLoading: boolean; isLoadingNext: boolean; - options: ChannelOptions; - responseFilters?: ChannelFilters; - responseSort?: ChannelSort; - sort: ChannelSort; + options?: QueryChannelsRequest; + responseFilters?: QueryChannelsRequest['filter_conditions']; + responseSort?: QueryChannelsRequest['sort']; }; export type ChannelManagerState = { @@ -56,9 +58,7 @@ export type ChannelManagerState = { export type ChannelSetterParameterType = ValueOrPatch; export type ChannelSetterType = (arg: ChannelSetterParameterType) => void; -export type GenericEventHandlerType = ( - ...args: T -) => void | (() => void) | ((...args: T) => Promise) | Promise; +export type GenericEventHandlerType = (...args: T) => any; export type EventHandlerType = GenericEventHandlerType<[Event]>; export type EventHandlerOverrideType = GenericEventHandlerType< [ChannelSetterType, Event] @@ -92,10 +92,9 @@ export type ChannelManagerEventHandlerOverrides = Partial< Record >; -export type ExecuteChannelsQueryPayload = Pick< - ChannelManagerPagination, - 'filters' | 'sort' | 'options' -> & { stateOptions: ChannelStateOptions }; +export type ExecuteChannelsQueryPayload = Pick & { + stateOptions: ChannelStateOptions; +}; export const channelManagerEventToHandlerMapping: { [key in ChannelManagerEventTypes]: ChannelManagerEventHandlerNames; @@ -139,9 +138,7 @@ export type ChannelManagerOptions = { export type QueryChannelsRequestOutput = Channel[] | QueryChannelsResponseWithChannels; export type QueryChannelsRequestType = ( - filters: ChannelFilters, - sort?: ChannelSort, - options?: ChannelOptions, + options?: QueryChannelsRequest, stateOptions?: ChannelStateOptions, ) => Promise; @@ -160,18 +157,11 @@ export const DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS = { offset: 0, }; -const mapPredefinedFilterSortToChannelSort = ( - sort: NonNullable['sort'], -): ChannelSort => - (sort ?? []).map(({ direction = 1, field }) => ({ - [field]: direction, - })) as ChannelSort; - const getResponsePaginationParams = ({ queryChannelsResponse, sort, }: { - queryChannelsResponse?: Pick; + queryChannelsResponse?: Pick; sort: ChannelSort; }): Pick => { const predefinedFilter = queryChannelsResponse?.predefined_filter; @@ -182,18 +172,13 @@ const getResponsePaginationParams = ({ return { responseFilters: predefinedFilter.filter as ChannelFilters, - responseSort: - predefinedFilter.sort !== undefined - ? mapPredefinedFilterSortToChannelSort(predefinedFilter.sort) - : sort, + responseSort: predefinedFilter.sort ?? sort, }; }; -const getResponseFiltersAndSort = ( - pagination: ChannelManagerPagination, -): Pick => ({ - filters: pagination.responseFilters ?? pagination.filters, - sort: pagination.responseSort ?? pagination.sort, +const getResponseFiltersAndSort = (pagination: ChannelManagerPagination) => ({ + filters: pagination.responseFilters ?? pagination.options?.filter_conditions, + sort: pagination.responseSort ?? pagination.options?.sort, }); const omitResponsePaginationParams = (pagination: ChannelManagerPagination) => { @@ -245,8 +230,6 @@ export class ChannelManager extends WithSubscriptions { isLoading: false, isLoadingNext: false, hasNext: false, - filters: {}, - sort: {}, options: DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, }, initialized: false, @@ -255,7 +238,8 @@ export class ChannelManager extends WithSubscriptions { this.setEventHandlerOverrides(eventHandlerOverrides); this.setOptions(options); this.queryChannelsRequest = - queryChannelsOverride ?? ((...params) => this.client.queryChannels(...params)); + queryChannelsOverride ?? + ((...params) => this.client.queryChannelsAndHydrate(...params)); this.eventHandlers = new Map( Object.entries({ channelDeletedHandler: this.channelDeletedHandler, @@ -287,15 +271,13 @@ export class ChannelManager extends WithSubscriptions { }); const { channels, - pagination: { filters, options, sort }, + pagination: { options }, } = this.state.getLatestValue(); this.client.offlineDb?.executeQuerySafely( (db) => db.upsertCidsForQuery({ cids: channels.map((channel) => channel.cid), - filters, options, - sort, }), { method: 'upsertCidsForQuery' }, ); @@ -329,18 +311,16 @@ export class ChannelManager extends WithSubscriptions { payload: ExecuteChannelsQueryPayload, retryCount = 0, ): Promise => { - const { filters, sort, options, stateOptions } = payload; + const { options, stateOptions } = payload; const { offset, limit } = { ...DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, ...options, }; try { - const queryChannelsResponse = await this.queryChannelsRequest( - filters, - sort, - options, - { ...stateOptions, withResponse: true }, - ); + const queryChannelsResponse = await this.queryChannelsRequest(options, { + ...stateOptions, + withResponse: true, + }); const channels = isQueryChannelsResponseWithChannels(queryChannelsResponse) ? queryChannelsResponse.channels : queryChannelsResponse; @@ -351,7 +331,7 @@ export class ChannelManager extends WithSubscriptions { queryChannelsResponse: isQueryChannelsResponseWithChannels(queryChannelsResponse) ? queryChannelsResponse : undefined, - sort, + sort: options?.sort ?? [], }); const paginationWithoutResponseParams = omitResponsePaginationParams(pagination); @@ -376,18 +356,22 @@ export class ChannelManager extends WithSubscriptions { (db) => db.upsertCidsForQuery({ cids: channels.map((channel) => channel.cid), - filters: pagination.filters, + filters: pagination.options?.filter_conditions, options, - sort: pagination.sort, + sort: pagination.options?.sort, }), { method: 'upsertCidsForQuery' }, ); - } catch (err) { + } catch (error) { if (retryCount >= DEFAULT_QUERY_CHANNELS_RETRY_COUNT) { - console.warn(err); + logger + .withExtraTags('executeChannelsQuery') + .error('Failed to query channels after the maximum number of retries.', { + error, + }); const wrappedError = new Error( - `Maximum number of retries reached in queryChannels. Last error message is: ${err}`, + `Maximum number of retries reached in queryChannels. Last error message is: ${error}`, ); const state = this.state.getLatestValue(); @@ -413,13 +397,11 @@ export class ChannelManager extends WithSubscriptions { }; public queryChannels = async ( - filters: ChannelFilters, - sort: ChannelSort = [], - options: ChannelOptions = {}, + request?: QueryChannelsRequest, stateOptions: ChannelStateOptions = {}, ) => { const { - pagination: { isLoading, filters: filtersFromState }, + pagination: { isLoading, options: optionsFromState }, initialized, } = this.state.getLatestValue(); @@ -428,12 +410,18 @@ export class ChannelManager extends WithSubscriptions { !this.options.abortInFlightQuery && // TODO: Figure a proper way to either deeply compare these or // create hashes from each. - JSON.stringify(filtersFromState) === JSON.stringify(filters) + JSON.stringify(optionsFromState?.filter_conditions) === + JSON.stringify(request?.filter_conditions) ) { return; } - const executeChannelsQueryPayload = { filters, sort, options, stateOptions }; + const executeChannelsQueryPayload = { + filters: request?.filter_conditions, + sort: request?.sort, + options: request, + stateOptions, + }; try { this.stateOptions = stateOptions; @@ -443,9 +431,7 @@ export class ChannelManager extends WithSubscriptions { ...omitResponsePaginationParams(currentState.pagination), isLoading: true, isLoadingNext: false, - filters, - sort, - options, + options: request, }, error: undefined, })); @@ -454,9 +440,7 @@ export class ChannelManager extends WithSubscriptions { if (!initialized) { const channelsFromDB = await this.client.offlineDb.getChannelsForQuery({ userId: this.client.user.id, - filters, - options, - sort, + options: request, }); if (channelsFromDB) { @@ -481,7 +465,7 @@ export class ChannelManager extends WithSubscriptions { } await this.executeChannelsQuery(executeChannelsQueryPayload); } catch (error) { - this.client.logger('error', (error as Error).message); + logger.withExtraTags('queryChannels').error('Failed to query channels.', { error }); this.state.next((currentState) => ({ ...currentState, pagination: { ...currentState.pagination, isLoading: false }, @@ -492,7 +476,7 @@ export class ChannelManager extends WithSubscriptions { public loadNext = async () => { const { pagination, initialized } = this.state.getLatestValue(); - const { filters, sort, options, isLoadingNext, hasNext } = pagination; + const { options, isLoadingNext, hasNext } = pagination; if (!initialized || isLoadingNext || !hasNext) { return; @@ -507,8 +491,6 @@ export class ChannelManager extends WithSubscriptions { pagination: { ...pagination, isLoading: false, isLoadingNext: true }, }); const queryChannelsResponse = await this.queryChannelsRequest( - filters, - sort, options, this.stateOptions, ); @@ -530,7 +512,9 @@ export class ChannelManager extends WithSubscriptions { }, }); } catch (error) { - this.client.logger('error', (error as Error).message); + logger + .withExtraTags('loadNext') + .error('Failed to load the next page of channels.', { error }); this.state.next((currentState) => ({ ...currentState, pagination: { @@ -543,7 +527,8 @@ export class ChannelManager extends WithSubscriptions { } }; - private notificationAddedToChannelHandler = async (event: Event) => { + private notificationAddedToChannelHandler = async (event_: Event) => { + const event = event_ as EventPayload<'notification.added_to_channel'>; const { id, type, members } = event?.channel ?? {}; if ( @@ -573,7 +558,7 @@ export class ChannelManager extends WithSubscriptions { return; } - const { sort } = getResponseFiltersAndSort(pagination); + const { sort = [] } = getResponseFiltersAndSort(pagination); this.setChannels( promoteChannel({ @@ -584,7 +569,11 @@ export class ChannelManager extends WithSubscriptions { ); }; - private channelDeletedHandler = (event: Event) => { + private channelDeletedHandler = (event_: Event) => { + const event = event_ as EventPayload< + 'channel.deleted' | 'channel.hidden' | 'notification.removed_from_channel' + >; + const { channels } = this.state.getLatestValue(); if (!channels) { return; @@ -605,12 +594,14 @@ export class ChannelManager extends WithSubscriptions { private channelHiddenHandler = this.channelDeletedHandler; - private newMessageHandler = (event: Event) => { + private newMessageHandler = (event_: Event) => { + const event = event_ as EventPayload<'message.new'>; + const { pagination, channels } = this.state.getLatestValue(); if (!channels) { return; } - const { filters, sort } = getResponseFiltersAndSort(pagination); + const { filters, sort = [] } = getResponseFiltersAndSort(pagination); const channelType = event.channel_type; const channelId = event.channel_id; @@ -631,9 +622,9 @@ export class ChannelManager extends WithSubscriptions { if ( // filter is defined, target channel is archived and filter option is set to false - (considerArchivedChannels && isTargetChannelArchived && !filters.archived) || + (considerArchivedChannels && isTargetChannelArchived && !filters?.archived) || // filter is defined, target channel isn't archived and filter option is set to true - (considerArchivedChannels && !isTargetChannelArchived && filters.archived) || + (considerArchivedChannels && !isTargetChannelArchived && filters?.archived) || // sort option is defined, target channel is pinned (considerPinnedChannels && isTargetChannelPinned) || // list order is locked @@ -655,7 +646,9 @@ export class ChannelManager extends WithSubscriptions { ); }; - private notificationNewMessageHandler = async (event: Event) => { + private notificationNewMessageHandler = async (event_: Event) => { + const event = event_ as EventPayload<'notification.message_new'>; + const { id, type } = event?.channel ?? {}; if (!id || !type) { @@ -669,15 +662,15 @@ export class ChannelManager extends WithSubscriptions { }); const { channels, pagination } = this.state.getLatestValue(); - const { filters, sort } = getResponseFiltersAndSort(pagination); + const { filters, sort = [] } = getResponseFiltersAndSort(pagination); const considerArchivedChannels = shouldConsiderArchivedChannels(filters); const isTargetChannelArchived = isChannelArchived(channel); if ( !channels || - (considerArchivedChannels && isTargetChannelArchived && !filters.archived) || - (considerArchivedChannels && !isTargetChannelArchived && filters.archived) || + (considerArchivedChannels && isTargetChannelArchived && !filters?.archived) || + (considerArchivedChannels && !isTargetChannelArchived && filters?.archived) || !this.options.allowNotLoadedChannelPromotionForEvent?.['notification.message_new'] ) { return; @@ -692,7 +685,8 @@ export class ChannelManager extends WithSubscriptions { ); }; - private channelVisibleHandler = async (event: Event) => { + private channelVisibleHandler = async (event_: Event) => { + const event = event_ as EventPayload<'channel.visible' | 'channel.hidden'>; const { channel_type: channelType, channel_id: channelId } = event; if (!channelType || !channelId) { @@ -706,15 +700,15 @@ export class ChannelManager extends WithSubscriptions { }); const { channels, pagination } = this.state.getLatestValue(); - const { filters, sort } = getResponseFiltersAndSort(pagination); + const { filters, sort = [] } = getResponseFiltersAndSort(pagination); const considerArchivedChannels = shouldConsiderArchivedChannels(filters); const isTargetChannelArchived = isChannelArchived(channel); if ( !channels || - (considerArchivedChannels && isTargetChannelArchived && !filters.archived) || - (considerArchivedChannels && !isTargetChannelArchived && filters.archived) || + (considerArchivedChannels && isTargetChannelArchived && !filters?.archived) || + (considerArchivedChannels && !isTargetChannelArchived && filters?.archived) || !this.options.allowNotLoadedChannelPromotionForEvent?.['channel.visible'] ) { return; @@ -731,12 +725,13 @@ export class ChannelManager extends WithSubscriptions { private notificationRemovedFromChannelHandler = this.channelDeletedHandler; - private memberUpdatedHandler = (event: Event) => { + private memberUpdatedHandler = (event_: Event) => { + const event = event_ as EventPayload<'member.updated'>; const { pagination, channels } = this.state.getLatestValue(); - const { filters, sort } = getResponseFiltersAndSort(pagination); + const { filters, sort = [] } = getResponseFiltersAndSort(pagination); if ( !event.member?.user || - event.member.user.id !== this.client.userID || + event.member.user.id !== this.client.userId || !event.channel_type || !event.channel_id ) { diff --git a/src/channel_state.ts b/src/channel_state.ts index e61a4bff5f..3cf6fa9897 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -4,7 +4,6 @@ import type { Event, LocalMessage, MessageResponse, - MessageResponseBase, PendingMessageResponse, UserResponse, } from './types'; @@ -85,7 +84,7 @@ export class ChannelState { this.syncMemberCountFromChannelData(channel?.data); this.syncOwnCapabilitiesFromChannelData(channel?.data); this.pending_messages = []; - this.membership = {}; + this.membership = {} as ChannelMemberResponse; this.unreadCount = 0; } @@ -237,9 +236,9 @@ export class ChannelState { * Takes the message object, parses the dates, sets `__html` * and sets the status to `received` if missing; returns a new message object. * - * @param {MessageResponse} message `MessageResponse` object + * @param message - `MessageResponse` object */ - formatMessage = (message: MessageResponse | MessageResponseBase | LocalMessage) => + formatMessage = (message: MessageResponse | MessageResponse | LocalMessage) => formatMessage(message); /** diff --git a/src/client.ts b/src/client.ts index 456bf5edaa..82683c92cf 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,7 @@ /* eslint no-unused-vars: "off" */ /* global process */ -import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import axios from 'axios'; import https from 'https'; @@ -9,273 +9,63 @@ import { Channel } from './channel'; import { ClientState } from './client_state'; import { StableWSConnection } from './connection'; import { UploadManager } from './uploadManager'; -import { - DevToken, - InvalidWebhookError, - JWTUserToken, - parseSns as parseSnsHelper, - parseSqs as parseSqsHelper, - verifyAndParseWebhook as verifyAndParseWebhookHelper, - verifySignature, -} from './signing'; -import { TokenManager } from './token_manager'; +import { TokenManager, type TokenManagerMinimalUser } from './token_manager'; import { WSConnectionFallback } from './connection_fallback'; -import { Campaign } from './campaign'; -import { ChannelBatchUpdater } from './channel_batch_updater'; -import { Segment } from './segment'; -import { isErrorResponse, isWSFailure } from './errors'; +import { isWSFailure } from './errors'; +import { ApiClient } from './api-client'; import { - addFileToFormData, axiosParamsSerializer, - chatCodes, formatMessage, generateChannelTempCid, - isFunction, + getEnv, isOnline, isOwnUserBaseProperty, - normalizeQuerySort, randomId, - retryInterval, - sleep, - toUpdatedMessagePayload, } from './utils'; import type { - ActiveLiveLocationsAPIResponse, - AddUserGroupMembersOptions, - AddUserGroupMembersResponse, - APIErrorResponse, APIResponse, AppIdentifier, - 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, + ChannelStateResponseFields, Configs, ConnectAPIResponse, - CreateChannelOptions, - CreateChannelResponse, - CreateCommandOptions, - CreateCommandResponse, - CreateImportOptions, - CreateImportResponse, - CreateImportURLResponse, - CreatePollAPIResponse, - CreatePollData, - CreatePollOptionAPIResponse, - CreatePredefinedFilterOptions, - CreateReminderOptions, - CreateRoleAPIResponse, - CreateUserGroupOptions, - CreateUserGroupResponse, - CustomPermissionOptions, - DeactivateUsersOptions, - DeleteChannelsResponse, - DeleteCommandResponse, - DeleteMessageOptions, - DeleteRetentionPolicyResponse, - DeleteUserGroupOptions, - DeleteUserOptions, - Device, DeviceIdentifier, - DraftFilters, - DraftSort, - EndpointName, Event, - EventAPIResponse, EventHandler, - ExportChannelOptions, - ExportChannelRequest, - ExportChannelResponse, - ExportChannelStatusResponse, - ExportUsersRequest, - ExportUsersResponse, + EventType, FlagMessageResponse, - FlagReportsFilters, - FlagReportsPaginationOptions, - FlagReportsResponse, - FlagsFilters, - FlagsPaginationOptions, - FlagsResponse, FlagUserResponse, - FutureChannelBansResponse, - GetBlockedUsersAPIResponse, - GetCampaignOptions, - GetChannelTypeResponse, - GetCommandResponse, - GetHookEventsResponse, - GetImportResponse, - GetMessageAPIResponse, - GetMessageOptions, - GetPollAPIResponse, - GetPollOptionAPIResponse, - GetRateLimitsResponse, - GetRetentionPolicyResponse, - GetRetentionPolicyRunsOptions, - GetRetentionPolicyRunsResponse, - GetThreadAPIResponse, GetThreadOptions, - GetUnreadCountAPIResponse, - GetUnreadCountBatchAPIResponse, - GetUserGroupOptions, - GetUserGroupResponse, - ListChannelResponse, - ListCommandsResponse, - ListImportsPaginationOptions, - ListImportsResponse, - ListPredefinedFiltersOptions, - ListPredefinedFiltersResponse, - ListRolesAPIResponse, LocalMessage, - Logger, - MarkChannelsReadOptions, - MarkDeliveredOptions, - MessageFilters, - MessageFlagsFilters, - MessageFlagsPaginationOptions, - MessageFlagsResponse, - MessageResponse, - Mute, MuteUserOptions, MuteUserResponse, - NewMemberPayload, - OGAttachment, OwnUserResponse, - Pager, - PartialMessageUpdate, - PartialPollUpdate, + PartializeAllBut, PartialThreadUpdate, - PartialUserUpdate, - PermissionAPIResponse, - PermissionsAPIResponse, - PollAnswersAPIResponse, - PollData, - PollOptionData, - PollSort, - PollVote, - PollVoteData, - PollVotesAPIResponse, - PredefinedFilterResponse, - Product, - PushPreference, - PushProvider, - PushProviderConfig, - PushProviderID, - PushProviderListResponse, - PushProviderUpsertResponse, - QueryChannelsAPIResponse, - QueryDraftsResponse, - QueryFutureChannelBansOptions, - QueryMessageHistoryFilters, - QueryMessageHistoryOptions, - QueryMessageHistoryResponse, - QueryMessageHistorySort, - QueryPollsFilters, - QueryPollsOptions, - QueryPollsResponse, - QueryReactionsAPIResponse, - QueryReactionsOptions, - QueryRemindersOptions, - QueryRemindersResponse, - QuerySegmentsOptions, - QuerySegmentTargetsFilter, - QueryTeamUsageStatsOptions, - QueryTeamUsageStatsResponse, - QueryThreadsAPIResponse, - QueryThreadsOptions, - QueryUserGroupsOptions, - QueryUserGroupsResponse, - QueryVotesFilters, - QueryVotesOptions, - ReactionFilters, + QueryBannedUsersPayload, + QueryChannelsRequest, + QueryChannelsResponse, + QueryReactionsRequestWithId, + QueryThreadsRequest, ReactionResponse, - ReactionSort, - ReactivateUserOptions, - ReactivateUsersOptions, - ReminderAPIResponse, - RemoveUserGroupMembersOptions, - RemoveUserGroupMembersResponse, - ReviewFlagReportOptions, - ReviewFlagReportResponse, SdkIdentifier, - SearchAPIResponse, - SearchMessageSortBase, - SearchOptions, SearchPayload, - SearchRolesAPIResponse, - SearchRolesOptions, - SearchUserGroupsOptions, - SearchUserGroupsResponse, - SegmentData, - SegmentResponse, - SegmentTargetsResponse, - SegmentType, - SendFileAPIResponse, - SetRetentionPolicyResponse, - SharedLocationResponse, - SortParam, StreamChatOptions, - SyncOptions, - SyncResponse, - TaskResponse, - TaskStatus, - TestPushDataInput, - TestSNSDataInput, - TestSQSDataInput, TokenOrProvider, - TranslateResponse, UnBanUserOptions, - UpdateChannelsBatchOptions, - UpdateChannelsBatchResponse, - UpdateChannelTypeRequest, - UpdateChannelTypeResponse, - UpdateCommandOptions, - UpdateCommandResponse, - UpdateLocationPayload, - UpdateMessageAPIResponse, - UpdateMessageOptions, - UpdatePollAPIResponse, - UpdatePollOptionAPIResponse, - UpdatePredefinedFilterOptions, - UpdateReminderOptions, - UpdateSegmentData, - UpdateUserGroupOptions, - UpdateUserGroupResponse, - UpdateUsersAPIResponse, - UpsertPushPreferencesResponse, - UserCustomEvent, - UserFilters, - UserOptions, + UpdateUserPartialRequest, + UserMuteResponse, UserResponse, - UserSort, - VoteSort, } from './types'; -import { ErrorFromResponse } from './types'; import { InsightMetrics, postInsights } from './insights'; +import { chatLoggerSystem } from './logger'; import { Thread } from './thread'; import { Moderation } from './moderation'; import { ThreadManager } from './thread_manager'; @@ -300,13 +90,24 @@ import type { } from './configuration'; import { InstanceConfigurationService } from './configuration/InstanceConfigurationService'; import { StateStore } from './store'; - -function isString(x: unknown): x is string { - return typeof x === 'string' || x instanceof String; +import type { + GetApplicationResponse as Gen_GetApplicationResponse, + MarkDeliveredRequest as Gen_MarkDeliveredRequest, + QueryUsersPayload as Gen_QueryUsersPayload, + WSEvent, +} from './gen/models'; +import { ChatApi } from './gen-imports'; +import type { StreamResponse } from './types'; + +function isString(value: unknown): value is string { + return typeof value === 'string' || value instanceof String; } +const logger = chatLoggerSystem.getLogger('client'); +const offlineDbLogger = chatLoggerSystem.getLogger('offline-db'); + export type QueryChannelsResponseWithChannels = Omit< - QueryChannelsAPIResponse, + QueryChannelsResponse, 'channels' > & { channels: Channel[]; @@ -318,15 +119,20 @@ export type ChannelConfigsState = { configs: Configs; }; -export class StreamChat { +export type ClientUser = PartializeAllBut & { anon?: boolean }; + +export class StreamChat extends ChatApi { private static _instance?: unknown | StreamChat; // type is undefined|StreamChat, unknown is due to TS limitations with statics messageDeliveryReporter: MessageDeliveryReporter; /** * @internal */ uploadManager: UploadManager; - _user?: OwnUserResponse | UserResponse; - appSettingsPromise?: Promise; + /** + * @private + */ + _user?: ClientUser; + appSettingsPromise?: Promise>; activeChannels: { [key: string]: Channel; }; @@ -335,16 +141,14 @@ export class StreamChat { offlineDb?: AbstractOfflineDB; notifications: NotificationManager; reminders: ReminderManager; - anonymous: boolean; persistUserOnConnectionFailure?: boolean; axiosInstance: AxiosInstance; baseURL?: string; browser: boolean; cleaningIntervalRef?: NodeJS.Timeout; - clientID?: string; + clientId?: string; key: string; - listeners: Record void>>; - logger: Logger; + listeners: Map>; /** * 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. @@ -362,23 +166,44 @@ export class StreamChat { preventThreadCleanup = false; moderation: Moderation; mutedChannels: ChannelMute[]; - readonly mutedUsersStore: StateStore<{ mutedUsers: Mute[] }>; + readonly mutedUsersStore: StateStore<{ mutedUsers: UserMuteResponse[] }>; readonly configsStore: StateStore; blockedUsers: StateStore; node: boolean; options: StreamChatOptions; - secret?: string; setUserPromise: ConnectAPIResponse | null; state: ClientState; tokenManager: TokenManager; - user?: OwnUserResponse | UserResponse; + user?: ClientUser; userAgent?: string; - userID?: string; wsBaseURL?: string; wsConnection: StableWSConnection | null; wsFallback?: WSConnectionFallback; wsPromise: ConnectAPIResponse | null; - consecutiveFailures: number; + get anonymous(): boolean { + return this.user?.anon ?? false; + } + get userId() { + return this.user?.id; + } + /** + * @deprecated Use `userId` instead. + */ + get userID() { + return this.user?.id; + } + /** + * @deprecated Use `clientId` instead. + */ + get clientID() { + return this.clientId; + } + set clientID(id: string | undefined) { + this.clientId = id; + } + get api() { + return this.apiClient; + } insightMetrics: InsightMetrics; defaultWSTimeoutWithFallback: number; defaultWSTimeout: number; @@ -391,38 +216,39 @@ export class StreamChat { instanceConfigurationService = new InstanceConfigurationService(); /** - * Initialize a client + * Initializes 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.** * - * **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 initialize the client in user mode * new StreamChat('api_key') * @example initialize the client in user mode with options - * new StreamChat('api_key', { warmUp:true, timeout:5000 }) + * new StreamChat('api_key', { warmUp: true, timeout: 5000 }) * @example secret is optional and only used in server side mode - * 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, - ) { + * new StreamChat('api_key', 'secret', { httpsAgent: customAgent }) + * + * @param key - The API key. + * @param options - Additional options; here you can pass custom options to the axios instance (optional). + * @param options.browser - Enforce the client to be in browser mode (optional). + * @param options.warmUp - If `true`, the client will open a connection as soon as possible to speed up following requests (optional, defaults to `false`). + * @param options.logLevel - Minimum log level for the default sink (optional, defaults to `'info'`). + * @param options.logOptions - Per-scope sink/level overrides for `chatLoggerSystem` (optional). + * @param options.timeout - Request timeout (optional, defaults to `3000`). + * @param options.httpsAgent - Custom `httpsAgent` (optional, in Node defaults to `https.agent()`). + */ + constructor(key: string, options: StreamChatOptions = {}) { + // generated client requires ApiClient right away + super(new ApiClient()); + // but ApiClient relies on properties defined here so we set it after (can't pass `this` in super call) + this.apiClient.client = this; + // set the key this.key = key; - this.listeners = {}; + this.listeners = new Map(); this.state = new ClientState({ client: this }); // a list of channels to hide ws events from this.mutedChannels = []; - this.mutedUsersStore = new StateStore<{ mutedUsers: Mute[] }>({ + this.mutedUsersStore = new StateStore<{ mutedUsers: UserMuteResponse[] }>({ mutedUsers: [], }); this.configsStore = new StateStore<{ configs: Configs }>({ @@ -435,60 +261,37 @@ export class StreamChat { this.notifications = options?.notifications ?? new NotificationManager(); this.uploadManager = new UploadManager(this); - // 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.browser = options.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, isLocalUnreadCountEnabled: false, wsUrlParams: new URLSearchParams({}), - ...inputOptions, + ...options, }; - if (this.node && !this.options.httpsAgent) { - this.options.httpsAgent = new https.Agent({ - keepAlive: true, - keepAliveMsecs: 3000, - }); - } - - this.axiosInstance = axios.create(this.options); + this.axiosInstance = axios.create({ + timeout: 3000, + withCredentials: false, + httpsAgent: this.node + ? new https.Agent({ keepAlive: true, keepAliveMsecs: 3000 }) + : undefined, + ...this.options.axiosRequestConfig, + paramsSerializer: axiosParamsSerializer, + }); this.setBaseURL(this.options.baseURL || 'https://chat.stream-io-api.com'); - if ( - typeof process !== 'undefined' && - 'env' in process && - process.env.STREAM_LOCAL_TEST_RUN - ) { + const streamLocalTestRun = getEnv('STREAM_LOCAL_TEST_RUN'); + const streamLocalTestHost = getEnv('STREAM_LOCAL_TEST_HOST'); + if (streamLocalTestRun) { 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); + if (streamLocalTestHost) { + this.setBaseURL(`http://${streamLocalTestHost}`); } // WS connection is initialized when setUser is called @@ -500,69 +303,16 @@ export class StreamChat { // 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.tokenManager = new TokenManager(); 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 }); @@ -575,7 +325,7 @@ export class StreamChat { return this.mutedUsersStore.getLatestValue().mutedUsers; } - set mutedUsers(mutedUsers: Mute[]) { + set mutedUsers(mutedUsers: UserMuteResponse[]) { this.mutedUsersStore.next({ mutedUsers }); } @@ -588,44 +338,32 @@ export class StreamChat { } /** - * Get a client instance + * Returns a client instance. * - * This function always returns the same Client instance to avoid issues raised by multiple Client and WS connections + * 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** + * **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 initialize the client in user mode * StreamChat.getInstance('api_key') * @example initialize the client in user mode with options - * StreamChat.getInstance('api_key', { timeout:5000 }) + * StreamChat.getInstance('api_key', { timeout: 5000 }) * @example secret is optional and only used in server side mode - * 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 { + * StreamChat.getInstance('api_key', 'secret', { httpsAgent: customAgent }) + * + * @param key - The API key. + * @param options - Additional options; here you can pass custom options to the axios instance (optional). + * @param options.browser - Enforce the client to be in browser mode (optional). + * @param options.warmUp - If `true`, the client will open a connection as soon as possible to speed up following requests (optional, defaults to `false`). + * @param options.logLevel - Minimum log level for the default sink (optional, defaults to `'info'`). + * @param options.logOptions - Per-scope sink/level overrides for `chatLoggerSystem` (optional). + * @param options.timeout - Request timeout (optional, defaults to `3000`). + * @param options.httpsAgent - Custom `httpsAgent` (optional, in Node defaults to `https.agent()`). + * @returns The shared client instance. + */ + public static getInstance(key: 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); - } + StreamChat._instance = new StreamChat(key, options); } return StreamChat._instance as StreamChat; @@ -639,10 +377,6 @@ export class StreamChat { this.offlineDb = offlineDBInstance; } - devToken(userID: string) { - return DevToken(userID); - } - getAuthType() { return this.anonymous ? 'anonymous' : 'jwt'; } @@ -672,51 +406,44 @@ export class StreamChat { }; /** - * 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 + * Sets the current user and opens a WebSocket connection. * - * @return {ConnectAPIResponse} Returns a promise that resolves when the connection is setup + * @param user - Data about this user, e.g. `{ name: 'john' }`. + * @param userTokenOrProvider - A token string or an async provider that returns one. + * @returns A promise that resolves when the connection is set up. */ - connectUser = async ( - user: OwnUserResponse | UserResponse, - userTokenOrProvider: TokenOrProvider, - ) => { + connectUser = async (user: ClientUser, 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 + * Calling connectUser multiple times is potentially the result of a bad integration; however, + * if the user ID remains the same we don't throw an 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.', - ); + if (this.userId === user.id && this.setUserPromise) { + logger + .withExtraTags('connectUser') + .warn( + 'Detected consecutive calls to connectUser. Ideally, this function should only be called once.', + ); return this.setUserPromise; } - if (this.userID) { + 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.', - ); + if (this.node && !this.options.allowServerSideConnect) { + logger + .withExtraTags('connectUser') + .warn( + 'Do not use connectUser server-side. connectUser impacts MAU and concurrent connection usage, and therefore your bill. If you have a valid use case, set "allowServerSideConnect: true" in 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); @@ -740,43 +467,41 @@ export class StreamChat { }; /** - * @deprecated Please use connectUser() function instead. Its naming is more consistent with its functionality. + * Sets the current user and opens a WebSocket connection. * - * setUser - Set the current user and open a WebSocket connection + * @deprecated Use {@link StreamChat.connectUser} instead. Its naming is more consistent with its functionality. * - * @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 + * @param user - Data about this user, e.g. `{ name: 'john' }`. + * @param userTokenOrProvider - A token string or an async provider that returns one. + * @returns A promise that resolves when the connection is set up. */ setUser = this.connectUser; - _setToken = (user: UserResponse, userTokenOrProvider: TokenOrProvider) => + _setToken = (user: TokenManagerMinimalUser, userTokenOrProvider: TokenOrProvider) => this.tokenManager.setTokenOrProvider(userTokenOrProvider, user); - _setUser(user: OwnUserResponse | UserResponse) { + _setUser(user: TokenManagerMinimalUser) { /** * 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. + * 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. + * to call `client.openConnection` to reconnect to the WebSocket. * * This is mainly useful on mobile side. You can only receive push notifications - * if you don't have active websocket connection. + * if you don't have an 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 + * @param timeout - Max number of milliseconds to wait for the WebSocket close event before forcefully assuming + * successful disconnection. See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent (optional). */ closeConnection = async (timeout?: number) => { if (this.cleaningIntervalRef != null) { @@ -791,9 +516,9 @@ export class StreamChat { this.offlineDb?.executeQuerySafely( async (db) => { - if (this.userID) { + if (this.userId) { await db.upsertUserSyncStatus({ - userId: this.userID, + userId: this.userId, lastSyncedAt: new Date().toString(), }); } @@ -805,12 +530,16 @@ export class StreamChat { }; /** - * Creates an instance of ChannelManager. + * 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 + * @param config - The channel manager configuration. + * @param config.eventHandlerOverrides - The overrides for event handlers to be used (optional, + * defaults to `{}`). + * @param config.options - The options used for the channel manager (optional, defaults to `{}`). + * @param config.queryChannelsOverride - Override for the underlying `queryChannels` request (optional). + * @returns A new `ChannelManager` instance. */ createChannelManager = ({ eventHandlerOverrides = {}, @@ -829,19 +558,21 @@ export class StreamChat { }); /** - * Creates a new WebSocket connection with the current user. Returns empty promise, if there is an active connection + * Creates a new WebSocket connection with the current user. + * + * @returns The WebSocket connect promise, or an empty resolved promise if a connection is already active. */ openConnection = () => { - if (!this.userID) { + 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'], - }); + logger + .withExtraTags('openConnection') + .debug('A connection attempt is already in progress.'); return this.wsPromise; } @@ -849,224 +580,64 @@ export class StreamChat { (this.wsConnection?.isHealthy || this.wsFallback?.isHealthy()) && this._hasConnectionID() ) { - this.logger( - 'info', - 'client:openConnection() - openConnection called twice, healthy connection already exists', - { - tags: ['connection', 'client'], - }, - ); + logger + .withExtraTags('openConnection') + .debug('openConnection was called twice; a healthy connection already exists.'); return; } - this.clientID = `${this.userID}--${randomId()}`; + 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 + * Revokes tokens for a connected user issued before the given time. * - * @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(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 + * @param before - Cutoff date; tokens issued before this are revoked (optional, defaults to the current time). + * @returns The updated users response. */ - async revokeUsersToken(userIDs: string[], before?: Date | string | null) { - if (before === undefined) { - before = new Date().toISOString(); - } else { - before = this._normalizeDate(before); + async revokeTokens(before?: Date | null) { + if (!before) { + before = new Date(); } - const users: PartialUserUpdate[] = []; - for (const userID of userIDs) { - users.push({ - id: userID, - set: >{ + const users: UpdateUserPartialRequest[] = [ + { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + id: this.userId!, + set: { revoke_tokens_issued_before: before, }, - }); - } - - return await this.partialUpdateUsers(users); - } - - /** - * getAppSettings - retrieves application settings - */ - async getAppSettings() { - this.appSettingsPromise = this.get(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(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(this.baseURL + '/check_sqs', data); + return await this.updateUsersPartial({ users }); } /** - * testSNSSettings - Tests that the given or configured SNS configuration is valid + * Retrieves application settings. * - * @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', - } + * @returns The application settings response. */ - async testSNSSettings(data: TestSNSDataInput = {}) { - return await this.post(this.baseURL + '/check_sns', data); + async getAppSettings() { + return await (this.appSettingsPromise = this.getApp()); } /** - * Disconnects the websocket and removes the user from client. + * 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 + * @param timeout - Max number of milliseconds to wait for the WebSocket close event before forcefully assuming + * successful disconnection. See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent (optional). + * @returns The close-connection promise. */ disconnectUser = (timeout?: number) => { - this.logger('info', 'client:disconnect() - Disconnecting the client', { - tags: ['connection', 'client'], - }); + logger.withExtraTags('disconnectUser').info('Disconnecting the client.'); // remove the user specific fields delete this.user; delete this._user; - delete this.userID; - - this.anonymous = false; const closePromise = this.closeConnection(timeout); @@ -1087,7 +658,11 @@ export class StreamChat { .finally(() => { this.tokenManager.reset(); }) - .catch((err) => console.error(err)); + .catch((err) => + logger + .withExtraTags('disconnectUser') + .error('The close promise rejected during disconnect.', { error: err }), + ); // close the WS connection return closePromise; @@ -1097,335 +672,148 @@ export class StreamChat { * * @deprecated Please use client.disconnectUser instead. * - * Disconnects the websocket and removes the user from client. + * Disconnects the WebSocket and removes the user from client. */ disconnect = this.disconnectUser; /** - * connectAnonymousUser - Set an anonymous user and open a WebSocket connection + * Sets an anonymous user and opens a WebSocket connection. + * + * @returns A promise that resolves when the connection is set up. */ 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.', - ); + if (this.node && !this.options.allowServerSideConnect) { + logger + .withExtraTags('connectAnonymousUser') + .warn( + 'Do not use connectUser server-side. connectUser impacts MAU and concurrent connection usage, and therefore your bill. If you have a valid use case, set "allowServerSideConnect: true" in the client options to disable this warning.', + ); } - this.anonymous = true; - this.userID = randomId(); const anonymousUser = { - id: this.userID, + id: randomId(), anon: true, - } as UserResponse; + } satisfies TokenManagerMinimalUser; this._setToken(anonymousUser, ''); this._setUser(anonymousUser); - return this._setupConnection(); + return this.openConnection(); }; /** - * @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"} + * Sets up a temporary guest user. * - * @return {ConnectAPIResponse} Returns a promise that resolves when the connection is setup + * @param user - Data about this user, e.g. `{ name: 'john' }`. + * @returns A promise that resolves when the connection is set up. */ 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; - } + const response = await this.createGuest({ user }); - if (iat) { - extra.iat = iat; - } + const { + created_at: _created_at, + updated_at: _updated_at, + last_active: _last_active, + online: _online, + ...guestUser + } = response.user; - return JWTUserToken(this.secret, userID, extra, {}); + return await this.connectUser(guestUser as UserResponse, response.access_token); } /** - * on - Listen to events on all channels and users your watching + * Listens to events on all channels and users you're watching. * - * client.on('message.new', event => {console.log("my new message", event, channel.messagePaginator.state.items)}) - * or - * client.on(event => {console.log(event.type)}) + * @example + * client.on('message.new', (event) => { + * console.log('my new message', event, channel.state.messages); + * }); * - * @param {EventHandler | string} callbackOrString The event type to listen for (optional) - * @param {EventHandler} [callbackOrNothing] The callback to call + * @example + * client.on((event) => { + * console.log(event.type); + * }); * - * @return {{ unsubscribe: () => void }} Description + * @param callbackOrString - The event type to listen for, or the callback when listening to all events. + * @param callbackOrNothing - The callback to call when an event type was provided (optional). + * @returns An object with an `unsubscribe()` method. */ on(callback: EventHandler): { unsubscribe: () => void }; - on(eventType: string, callback: EventHandler): { unsubscribe: () => void }; + on( + eventType: T, + callback: EventHandler, + ): { unsubscribe: () => void }; on( callbackOrString: EventHandler | string, callbackOrNothing?: EventHandler, ): { unsubscribe: () => void } { - const key = callbackOrNothing ? (callbackOrString as string) : 'all'; + const key = callbackOrNothing ? (callbackOrString as EventType) : 'all'; const callback = callbackOrNothing ? callbackOrNothing : (callbackOrString as EventHandler); - if (!(key in this.listeners)) { - this.listeners[key] = []; + + const set = this.listeners.get(key) ?? new Set(); + + logger.withExtraTags('on').debug(`Attaching a listener for the "${key}" event.`); + set.add(callback); + + if (!this.listeners.has(key)) { + this.listeners.set(key, set); } - 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); + logger.withExtraTags('on').debug(`Removing the listener for the "${key}" event.`); + set.delete(callback); + if (!set.size) { + this.listeners.delete(key); + } }, }; } /** - * off - Remove the event handler + * Removes the event handler. * + * @param callbackOrString - The event type, or the callback when removing an all-events listener. + * @param callbackOrNothing - The callback to remove when an event type was provided (optional). */ off(callback: EventHandler): void; off(eventType: string, callback: EventHandler): void; off(callbackOrString: EventHandler | string, callbackOrNothing?: EventHandler) { - const key = callbackOrNothing ? (callbackOrString as string) : 'all'; + const key = callbackOrNothing ? (callbackOrString as EventType) : '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(type: string, url: string, response: AxiosResponse) { - 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 ( - type: string, - url: string, - data?: unknown, - options: AxiosRequestConfig & { - config?: AxiosRequestConfig & { maxBodyLength?: number }; - } = {}, - ): Promise => { - await this.tokenManager.tokenReady(); - const requestConfig = this._enrichAxiosOptions(options); - try { - let response: AxiosResponse; - 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(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(type, url, data, options); - } - return this.handleResponse(e.response); - } else { - throw e as AxiosError; - } - } - }; - - get(url: string, params?: AxiosRequestConfig['params']) { - return this.doAxiosRequest('get', url, null, { params }); - } - - put(url: string, data?: unknown) { - return this.doAxiosRequest('put', url, data); - } - - post(url: string, data?: unknown) { - return this.doAxiosRequest('post', url, data); - } - patch(url: string, data?: unknown) { - return this.doAxiosRequest('patch', url, data); - } - - delete(url: string, params?: AxiosRequestConfig['params']) { - return this.doAxiosRequest('delete', url, null, { params }); - } - - sendFile( - url: string, - uri: string | NodeJS.ReadableStream | Buffer | File, - name?: string, - contentType?: string, - user?: UserResponse, - axiosRequestConfig?: AxiosRequestConfig, - ) { - const data = addFileToFormData(uri, name, contentType || 'multipart/form-data'); - if (user != null) data.append('user', JSON.stringify(user)); - - return this.doAxiosRequest('postForm', url, data, { - headers: data.getHeaders ? data.getHeaders() : {}, // node vs browser - config: { - timeout: 0, - maxContentLength: Infinity, - maxBodyLength: Infinity, - ...axiosRequestConfig, - }, - }); - } + logger.withExtraTags('off').debug(`Removing the listener for the "${key}" event.`); - errorFromResponse(response: AxiosResponse) { - const message = - typeof response.data.code !== 'undefined' - ? `StreamChat error code ${response.data.code}: ${response.data.message}` - : `StreamChat error HTTP code: ${response.status}`; + const set = this.listeners.get(key); - return new ErrorFromResponse(message, { - code: response.data.code ?? null, - response, - status: response.status, - }); - } + set?.delete(callback); - handleResponse(response: AxiosResponse) { - const data = response.data; - if (isErrorResponse(response)) { - throw this.errorFromResponse(response); + if (!set?.size) { + this.listeners.delete(key); } - return data; } dispatchEvent = (event: Event) => { if (!event.received_at) event.received_at = new Date(); // client event handlers - const postListenerCallbacks = this._handleClientEvent(event); + const postListenerCallbacks = this._handleClientEvent(event as WSEvent); // channel event handlers - const cid = event.cid; + const cid = (event as Extract).cid; const channel = cid ? this.activeChannels[cid] : undefined; if (channel) { - channel._handleChannelEvent(event); + channel._handleChannelEvent(event as WSEvent); } this._callClientListeners(event); if (channel) { - channel._callChannelListeners(event); + channel._callChannelListeners(event as WSEvent); } postListenerCallbacks.forEach((c) => c()); @@ -1436,14 +824,14 @@ export class StreamChat { }; /** - * Updates the members, watchers and read references of the currently active channels that contain this user + * Updates the members, watchers and read references of the currently active channels that contain this user. * - * @param {UserResponse} user + * @param user - The updated user. */ _updateMemberWatcherReferences = (user: UserResponse) => { const refMap = this.state.userChannelReferences[user.id] || {}; - for (const channelID in refMap) { - const channel = this.activeChannels[channelID]; + 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; @@ -1459,21 +847,20 @@ export class StreamChat { }; /** - * @deprecated Please _updateMemberWatcherReferences instead. + * @deprecated Please use `_updateMemberWatcherReferences` instead. * @private */ _updateUserReferences = this._updateMemberWatcherReferences; /** - * @private + * Updates the messages from the currently active channels that contain this user, with the updated user object. * - * Updates the messages from the currently active channels that contain this user, - * with updated user object. + * @private * - * @param {UserResponse} user + * @param user - The updated user. */ _updateUserMessageReferences = (user: UserResponse) => { - // Scan all active channels rather than a user->channel reference map. Message authors are no + // Scan all active channels rather than a user->channel reference map. MessageRequest authors are no // longer registered as channel references (that registration was removed along with // `Channel._trackLatestMessage`); `reflectUserUpdate` filters by author id internally, so it is // a no-op on channels without this user's messages. @@ -1488,15 +875,16 @@ export class StreamChat { }; /** - * @private + * Deletes the messages from the currently active channels that contain this user. * - * Deletes the messages from the currently active channels that contain this user + * If `hardDelete` is `true`, all the content of the message will be stripped down. + * Otherwise, only `message.type` will be set as `'deleted'`. * - * If hardDelete is true, all the content of message will be stripped down. - * Otherwise, only 'message.type' will be set as 'deleted'. + * @private * - * @param {UserResponse} user - * @param {boolean} hardDelete + * @param user - The user whose messages should be deleted. + * @param hardDelete - Whether to fully strip the message content (optional, defaults to `false`). + * @param deletedAt - Timestamp to mark messages as deleted at (optional). */ _deleteUserMessageReference = ( user: UserResponse, @@ -1523,23 +911,28 @@ export class StreamChat { }; /** - * @private + * Handle the following user-related events: + * - `user.presence.changed` + * - `user.updated` + * - `user.deleted` * - * Handle following user related events: - * - user.presence.changed - * - user.updated - * - user.deleted + * @private * - * @param {Event} event + * @param event - The user event. */ - _handleUserEvent = (event: Event) => { + _handleUserEvent = ( + event: Extract< + WSEvent, + { type: 'user.presence.changed' | 'user.updated' | 'user.deleted' } + >, + ) => { 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) { + if (event.user.id === this.userId) { const user = { ...this.user } as NonNullable; const _user = { ...this._user } as NonNullable; @@ -1585,23 +978,18 @@ export class StreamChat { this._deleteUserMessageReference( event.user, event.hard_delete, - event.user.deleted_at ? new Date(event.user.deleted_at) : null, + event.user.deleted_at, ); } }; - _handleClientEvent(event: Event) { + _handleClientEvent(event: WSEvent) { // 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, - }, - ); + logger + .withExtraTags('_handleClientEvent') + .debug(`Received an event of type "${event.type}".`, { event }); if ( event.type === 'user.presence.changed' || @@ -1612,11 +1000,7 @@ export class StreamChat { } if (event.type === 'user.messages.deleted' && !event.cid && event.user) { - this._deleteUserMessageReference( - event.user, - event.hard_delete, - event.created_at ? new Date(event.created_at) : null, - ); + this._deleteUserMessageReference(event.user, event.hard_delete, event.created_at); } if (event.type === 'health.check' && event.me) { @@ -1627,7 +1011,7 @@ export class StreamChat { client.blockedUsers.partialNext({ userIds: event.me.blocked_user_ids ?? [] }); } - if (event.channel && event.type === 'notification.message_new') { + if (event.type === 'notification.message_new' && event.channel) { const { channel } = event; this._addChannelConfig(channel); } @@ -1708,58 +1092,41 @@ export class StreamChat { } _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]); - } + const allSet = this.listeners.get('all'); + const targetSet = this.listeners.get(event.type); - // call the event and send it to the listeners - for (const listener of listeners) { - listener(event); - } + [allSet, targetSet].forEach((set) => + set?.forEach((handleEvent) => handleEvent(event)), + ); }; recoverState = async () => { - this.logger( - 'info', - `client:recoverState() - Start of recoverState with connectionID ${this._getConnectionID()}`, - { - tags: ['connection'], - }, - ); + logger + .withExtraTags('recoverState') + .info(`Starting state recovery with connection ID ${this._getConnectionID()}.`); 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 }, - ); + logger + .withExtraTags('recoverState') + .info(`Starting the query for ${cids.length} channel(s).`); - this.logger('info', 'client:recoverState() - Querying channels finished', { - tags: ['connection', 'client'], + await this.queryChannelsAndHydrate({ + filter_conditions: { + cid: { $in: cids }, + }, + limit: 30, + sort: [{ field: 'last_message_at', direction: -1 }], }); + + logger.withExtraTags('recoverState').info('Finished querying channels.'); this.dispatchEvent({ type: 'connection.recovered', - } as Event); + }); } else { this.dispatchEvent({ type: 'connection.recovered', - } as Event); + }); } this.wsPromise = Promise.resolve(); @@ -1770,16 +1137,16 @@ export class StreamChat { * @private */ async connect() { - if (!this.userID || !this._user) { + if (!this.userId || !this._user) { throw Error( 'Call connectUser or connectAnonymousUser before starting the connection', ); } if (!this.wsBaseURL) { - throw Error('Websocket base url not set'); + throw Error('Property wsBaseURL is not set'); } - if (!this.clientID) { - throw Error('clientID is not set'); + if (!this.clientId) { + throw Error('Property clientId is not set'); } if (!this.wsConnection && (this.options.warmUp || this.options.enableInsights)) { @@ -1808,14 +1175,13 @@ export class StreamChat { ? this.defaultWSTimeoutWithFallback : this.defaultWSTimeout, ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { // run fallback only if it's WS/Network error and not a normal API error // make sure browser is online before even trying the longpoll if (this.options.enableWSFallback && isWSFailure(error) && isOnline()) { - this.logger('info', 'client:connect() - WS failed, fallback to longpoll', { - tags: ['connection', 'client'], - }); + logger + .withExtraTags('connect') + .warn('The WebSocket connection failed; falling back to long-polling.'); this.dispatchEvent({ type: 'transport.changed', mode: 'longpoll' }); this.wsConnection._destroyCurrentWSConnection(); @@ -1831,14 +1197,14 @@ export class StreamChat { } /** - * Check the connectivity with server for warmup purpose. + * Checks connectivity with the server for warmup purposes. * * @private */ _sayHi() { const client_request_id = randomId(); const opts = { headers: { 'x-client-request-id': client_request_id } }; - this.doAxiosRequest('get', this.baseURL + '/hi', null, opts).catch((e) => { + this.api.doAxiosRequest('get', this.baseURL + '/hi', null, opts).catch((e) => { if (this.options.enableInsights) { postInsights('http_hi_failed', { api_key: this.key, @@ -1850,244 +1216,52 @@ export class StreamChat { } /** - * queryUsers - Query users and watch user presence + * Queries users and watches user presence. * - * @param {UserFilters} filterConditions MongoDB style filter conditions - * @param {UserSort} sort Sort options, for instance [{last_active: -1}]. - * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_active: -1}, {created_at: 1}] - * @param {UserOptions} options Option object, {presence: true} - * - * @return {Promise<{ users: Array }>} User Query Response + * @param request - The query users request payload (optional). The inner `payload` accepts + * MongoDB-style filter conditions, sort directions (e.g. `[{ field: 'last_active', direction: -1 }]`), + * and options such as `presence`. + * @returns The user query response. */ - async queryUsers( - filterConditions: UserFilters, - sort: UserSort = [], - options: UserOptions = {}, - ) { - const defaultOptions = { - presence: false, - }; - + override async queryUsers(request?: { payload?: Gen_QueryUsersPayload }) { // Make sure we wait for the connect promise if there is a pending one await this.wsPromise; - if (!this._hasConnectionID()) { - defaultOptions.presence = false; - } - - // Return a list of users - const data = await this.get }>( - this.baseURL + '/users', - { - payload: { - filter_conditions: filterConditions, - sort: normalizeQuerySort(sort), - ...defaultOptions, - ...options, - }, - }, - ); - + const data = await super.queryUsers(request); this.state.updateUsers(data.users); return data; } /** - * queryUserGroups - List user groups with cursor-based pagination. + * Queries user bans. * - * @param {QueryUserGroupsOptions} options The query options - * - * @return {Promise} User Group Query Response + * @param request - The query banned users request payload (optional). The inner `payload` + * accepts MongoDB-style filter conditions, sort directions + * (e.g. `[{ field: 'created_at', direction: 1 }]`), and options such as `limit`, `offset`, + * and `exclude_expired_bans`. + * @returns The ban query response. */ - async queryUserGroups(options: QueryUserGroupsOptions = {}) { - return await this.get(this.baseURL + '/usergroups', options); + async queryBannedUsers(request?: { payload?: QueryBannedUsersPayload }) { + // Return a list of user bans + return await super.queryBannedUsers(request); } /** - * createUserGroup - Create a user group + * Queries channels and returns the full API response including top-level metadata such as + * `predefined_filter`. * - * @param {CreateUserGroupOptions} options The create options + * This exists as a compatibility bridge, as changing `queryChannelsRequest()` to return + * `QueryChannelsResponse` would be a breaking change because it currently returns + * only the channel list. In the next major release, the request/response APIs should + * be consolidated so callers can access the full response through the primary API. * - * @return {Promise} User Group Create Response + * @param request - The query channels request payload (optional). Accepts MongoDB-style filter + * conditions, sort directions (e.g. `[{ field: 'created_at', direction: -1 }]`), and options + * such as `predefined_filter`, `filter_values`, and `sort_values`. + * @returns The full query channels response. */ - async createUserGroup(options: CreateUserGroupOptions) { - return await this.post( - this.baseURL + '/usergroups', - options, - ); - } - - /** - * getUserGroup - Get a user group by ID - * - * @param {string} id The user group ID - * @param {GetUserGroupOptions} options Optional query options - * - * @return {Promise} User Group Get Response - */ - async getUserGroup(id: string, options: GetUserGroupOptions = {}) { - return await this.get( - `${this.baseURL}/usergroups/${encodeURIComponent(id)}`, - options, - ); - } - - /** - * searchUserGroups - Search user groups by prefix for autocomplete - * - * @param {SearchUserGroupsOptions} options The search options - * - * @return {Promise} User Group Search Response - */ - async searchUserGroups(options: SearchUserGroupsOptions) { - return await this.get( - this.baseURL + '/usergroups/search', - options, - ); - } - - /** - * updateUserGroup - Update a user group by ID - * - * @param {string} id The user group ID - * @param {UpdateUserGroupOptions} options The update options - * - * @return {Promise} User Group Update Response - */ - async updateUserGroup(id: string, options: UpdateUserGroupOptions) { - return await this.put( - `${this.baseURL}/usergroups/${encodeURIComponent(id)}`, - options, - ); - } - - /** - * deleteUserGroup - Delete a user group by ID - * - * @param {string} id The user group ID - * @param {DeleteUserGroupOptions} options Optional query options - * - * @return {Promise} User Group Delete Response - */ - async deleteUserGroup(id: string, options: DeleteUserGroupOptions = {}) { - return await this.delete( - `${this.baseURL}/usergroups/${encodeURIComponent(id)}`, - options, - ); - } - - /** - * addUserGroupMembers - Add members to a user group - * - * @param {string} id The user group ID - * @param {AddUserGroupMembersOptions} options The add-members options - * - * @return {Promise} User Group Add Members Response - */ - async addUserGroupMembers(id: string, options: AddUserGroupMembersOptions) { - return await this.post( - `${this.baseURL}/usergroups/${encodeURIComponent(id)}/members`, - options, - ); - } - - /** - * removeUserGroupMembers - Remove members from a user group - * - * @param {string} id The user group ID - * @param {RemoveUserGroupMembersOptions} options The remove-members options - * - * @return {Promise} User Group Remove Members Response - */ - async removeUserGroupMembers(id: string, options: RemoveUserGroupMembersOptions) { - return await this.post( - `${this.baseURL}/usergroups/${encodeURIComponent(id)}/members/delete`, - options, - ); - } - - /** - * queryBannedUsers - Query user bans - * - * @param {BannedUsersFilters} filterConditions MongoDB style filter conditions - * @param {BannedUsersSort} sort Sort options [{created_at: 1}]. - * @param {BannedUsersPaginationOptions} options Option object, {limit: 10, offset:0, exclude_expired_bans: true} - * - * @return {Promise} Ban Query Response - */ - async queryBannedUsers( - filterConditions: BannedUsersFilters = {}, - sort: BannedUsersSort = [], - options: BannedUsersPaginationOptions = {}, - ) { - // Return a list of user bans - return await this.get(this.baseURL + '/query_banned_users', { - payload: { - filter_conditions: filterConditions, - sort: normalizeQuerySort(sort), - ...options, - }, - }); - } - - /** - * queryFutureChannelBans - Query future channel bans created by a user - * - * @param {QueryFutureChannelBansOptions} options Option object with user_id, exclude_expired_bans, limit, offset - * @returns {Promise} Future Channel Bans Response - */ - async queryFutureChannelBans(options: QueryFutureChannelBansOptions = {}) { - return await this.get( - this.baseURL + '/query_future_channel_bans', - { - payload: options, - }, - ); - } - - /** - * queryMessageFlags - Query message flags - * - * @param {MessageFlagsFilters} filterConditions MongoDB style filter conditions - * @param {MessageFlagsPaginationOptions} options Option object, {limit: 10, offset:0} - * - * @return {Promise} Message Flags Response - */ - async queryMessageFlags( - filterConditions: MessageFlagsFilters = {}, - options: MessageFlagsPaginationOptions = {}, - ) { - // Return a list of message flags - return await this.get( - this.baseURL + '/moderation/flags/message', - { - payload: { filter_conditions: filterConditions, ...options }, - }, - ); - } - - /** - * queryChannelsRequestWithResponse - Queries channels and returns the full API response - * including top-level metadata such as `predefined_filter`. - * - * This exists as a compatibility bridge, as changing `queryChannelsRequest()` to return - * `QueryChannelsAPIResponse` would be a breaking change because it currently returns - * only the channel list. In the next major release, the request/response APIs should - * be consolidated so callers can access the full response through the primary API. - * - * @param {ChannelFilters} filterConditions object MongoDB style filters. Can be empty object when using predefined_filter in options. - * @param {ChannelSort} [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 [{last_updated: -1}, {created_at: 1}] - * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters. - * - * @return {Promise} full search channels response - */ - async queryChannelsRequestWithResponse( - filterConditions: ChannelFilters, - sort: ChannelSort = [], - options: ChannelOptions = {}, - ): Promise { + override async queryChannels(request?: QueryChannelsRequest) { const defaultOptions: ChannelOptions = { state: true, watch: true, @@ -2096,102 +1270,72 @@ export class StreamChat { // Make sure we wait for the connect promise if there is a pending one await this.wsPromise; + + // TODO: probably serverside only thing, remove at some point if (!this._hasConnectionID()) { defaultOptions.watch = false; } - const { predefined_filter, filter_values, sort_values, ...restOptions } = options; - const normalizedSort = normalizeQuerySort(sort); + const { + predefined_filter, + filter_values, + sort_values, + filter_conditions, + ...restOptions + } = request ?? {}; // Build payload based on whether we're using a predefined filter or traditional filters - const payload = predefined_filter + const payload: QueryChannelsRequest = predefined_filter ? { predefined_filter, filter_values, sort_values, - sort: normalizedSort, ...defaultOptions, ...restOptions, } : { - filter_conditions: filterConditions, - sort: normalizedSort, + filter_conditions, ...defaultOptions, ...restOptions, }; - return await this.post(this.baseURL + '/channels', payload); - } - - /** - * queryChannelsRequest - Queries channels and returns the raw channel response list. - * - * This preserves the historical return shape for backwards compatibility. Use - * `queryChannelsRequestWithResponse()` when response level metadata such as - * `predefined_filter` is needed. In the next major release these APIs should be - * consolidated into a single full-response API. - * - * @param {ChannelFilters} filterConditions object MongoDB style filters. Can be empty object when using predefined_filter in options. - * @param {ChannelSort} [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 [{last_updated: -1}, {created_at: 1}] - * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters. - * - * @return {Promise>} search channels response - */ - async queryChannelsRequest( - filterConditions: ChannelFilters, - sort: ChannelSort = [], - options: ChannelOptions = {}, - ) { - const data = await this.queryChannelsRequestWithResponse( - filterConditions, - sort, - options, - ); - - // FIXME: In the next major release, return the full QueryChannelsAPIResponse - // instead of only `data.channels` so top-level metadata such as - // `predefined_filter` is not lost. - return data.channels; + return await super.queryChannels(payload); } /** - * queryChannels - Query channels + * Queries channels and hydrates them into `Channel` instances on this client. * - * @param {ChannelFilters} filterConditions object MongoDB style filters - * @param {ChannelSort} [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 [{last_updated: -1}, {created_at: 1}] - * @param {ChannelOptions} [options] Options object - * @param {ChannelStateOptions} [stateOptions] State options object. These options will only be used for state management and won't be sent in the request. - * - stateOptions.skipInitialization - Skips the initialization of the state for the channels matching the ids in the list. - * - stateOptions.skipHydration - Skips returning the channels as instances of the Channel class and rather returns the raw query response. - * - stateOptions.withResponse - Returns the full query response with hydrated channels. This is a compatibility bridge for internal callers that need response-level metadata while the default return value remains `Channel[]`. + * Use the inherited `queryChannels()` from `ChatApi` when only the raw API response + * is needed; this method wraps it with state hydration, `channels.queried` dispatch, + * and offline-db sync. * - * @return {Promise>} search channels response + * @param options - The query channels request payload (optional). Accepts MongoDB-style filter + * conditions, sort directions (e.g. `[{ field: 'created_at', direction: -1 }]`), and options + * such as `predefined_filter`, `filter_values`, and `sort_values`. + * @param stateOptions - Options that only affect state management and aren't sent in the request + * (optional, defaults to `{}`). + * @param stateOptions.skipInitialization - Skips the initialization of the state for the + * channels matching the IDs in the list (optional). + * @param stateOptions.skipHydration - Skips returning the channels as instances of the `Channel` + * class and instead returns the raw query response (optional). + * @param stateOptions.withResponse - Returns the full query response with hydrated channels. + * This is a compatibility bridge for internal callers that need response-level metadata while + * the default return value remains `Channel[]` (optional). + * @returns The hydrated channel list, or the full response when `withResponse` is `true`. */ - async queryChannels( - filterConditions: ChannelFilters, - sort: ChannelSort, - options: ChannelOptions, - stateOptions: ChannelStateOptions & { withResponse: true }, + async queryChannelsAndHydrate( + options?: QueryChannelsRequest, + stateOptions?: ChannelStateOptions & { withResponse: true }, ): Promise; - async queryChannels( - filterConditions?: ChannelFilters, - sort?: ChannelSort, - options?: ChannelOptions, + async queryChannelsAndHydrate( + options?: QueryChannelsRequest, stateOptions?: ChannelStateOptions, ): Promise; - async queryChannels( - filterConditions: ChannelFilters, - sort: ChannelSort = [], - options: ChannelOptions = {}, + async queryChannelsAndHydrate( + options?: QueryChannelsRequest, stateOptions: ChannelStateOptions = {}, ): Promise { - const queryChannelsResponse = await this.queryChannelsRequestWithResponse( - filterConditions, - sort, - options, - ); + const queryChannelsResponse = await this.queryChannels(options); const channels = queryChannelsResponse.channels; this.dispatchEvent({ @@ -2221,33 +1365,24 @@ export class StreamChat { } /** - * queryReactions - Query reactions + * Queries reactions for a message and hydrates any cached offline reactions before the network + * request. * - * @param {ReactionFilters} filter object MongoDB style filters - * @param {ReactionSort} [sort] Sort options, for instance {created_at: -1}. - * @param {QueryReactionsOptions} [options] Pagination object - * - * @return {Promise<{ QueryReactionsAPIResponse } search channels response + * @param request - The query reactions request payload, including the target message ID, + * MongoDB-style filters, sort directions (e.g. `[{ field: 'created_at', direction: -1 }]`), + * and pagination options. + * @returns The query reactions response. */ - async queryReactions( - messageID: string, - filter: ReactionFilters, - sort: ReactionSort = [], - options: QueryReactionsOptions = {}, - ) { - const payload = { - filter, - sort: normalizeQuerySort(sort), - ...options, - }; + async queryReactionsAndHydrate(request: QueryReactionsRequestWithId) { + const { filter, next, id: messageId, sort, limit } = request; - if (this.offlineDb?.getReactions && !options.next) { + if (this.offlineDb?.getReactions && !next) { try { const reactionsFromDb = await this.offlineDb.getReactions({ - messageId: messageID, + messageId, filters: filter, sort, - limit: options.limit, + limit, }); if (reactionsFromDb) { @@ -2257,23 +1392,20 @@ export class StreamChat { }); } } catch (e) { - this.logger('warn', 'An error has occurred while querying offline reactions', { - error: e, - }); + offlineDbLogger + .withExtraTags('queryReactionsAndHydrate') + .warn('An error occurred while querying offline reactions.', { error: e }); } } // Make sure we wait for the connect promise if there is a pending one await this.wsPromise; - return await this.post( - this.baseURL + '/messages/' + encodeURIComponent(messageID) + '/reactions', - payload, - ); + return await this.queryReactions(request); } hydrateActiveChannels( - channelsFromApi: ChannelAPIResponse[] = [], + channelsFromApi: ChannelStateResponseFields[] = [], stateOptions: ChannelStateOptions = {}, queryChannelsOptions?: ChannelOptions, ) { @@ -2281,6 +1413,8 @@ export class StreamChat { const channels: Channel[] = []; for (const channelState of channelsFromApi) { + if (!channelState.channel) continue; + this._addChannelConfig(channelState.channel); const c = this.channel(channelState.channel.type, channelState.channel.id); const previousData = c.data; @@ -2337,50 +1471,29 @@ export class StreamChat { } /** - * search - Query messages - * - * @param {ChannelFilters} filterConditions MongoDB style filter conditions - * @param {MessageFilters | string} query search query or object MongoDB style filters - * @param {SearchOptions} [options] Option object, {user_id: 'tommaso'} + * Queries messages. * - * @return {Promise} search messages response + * @param request - The search request payload (optional). The inner `payload` accepts + * MongoDB-style filter conditions, a search query, and options such as `user_id`. + * @returns The search messages response. */ - async search( - filterConditions: ChannelFilters, - query: string | MessageFilters, - options: SearchOptions = {}, - ) { - if (options.offset && options.next) { - throw Error(`Cannot specify offset with next`); - } - const payload: SearchPayload = { - filter_conditions: filterConditions, - ...options, - sort: options.sort - ? normalizeQuerySort(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`); + override async search(request?: { payload?: SearchPayload }) { + if (request?.payload?.offset && request?.payload?.next) { + throw Error(`Cannot specify "offset" with "next"`); } // Make sure we wait for the connect promise if there is a pending one await this.wsPromise; - return await this.get(this.baseURL + '/search', { payload }); + return await super.search(request); } /** - * setLocalDevice - Set the device info for the current client(device) that will be sent via WS connection automatically - * - * @param {BaseDeviceFields} device the device object - * @param {string} device.id device id - * @param {string} device.push_provider the push provider + * Sets the device info for the current client. It will be sent via the WS connection automatically. * + * @param device - The device object. + * @param device.id - Device ID. + * @param device.push_provider - The push provider. */ setLocalDevice(device: BaseDeviceFields) { if ( @@ -2388,140 +1501,12 @@ export class StreamChat { ((this.wsConnection?.isHealthy || this.wsFallback?.isHealthy()) && this._hasConnectionID()) ) { - throw new Error('you can only set device before opening a websocket connection'); + throw new Error('Device cannot be set before opening a WebSocket connection'); } this.options.device = device; } - /** - * addDevice - Adds a push device for a user. - * - * @param {string} id the device id - * @param {PushProvider} push_provider the push provider - * @param {string} [userID] the user id (defaults to current user) - * @param {string} [push_provider_name] user provided push provider name for multi bundle support - * - */ - async addDevice( - id: string, - push_provider: PushProvider, - userID?: string, - push_provider_name?: string, - ) { - return await this.post(this.baseURL + '/devices', { - id, - push_provider, - ...(userID != null ? { user_id: userID } : {}), - ...(push_provider_name != null ? { push_provider_name } : {}), - }); - } - - /** - * getDevices - Returns the devices associated with a current user - * - * @param {string} [userID] User ID. Only works on serverside - * - * @return {Device[]} Array of devices - */ - async getDevices(userID?: string) { - return await this.get( - this.baseURL + '/devices', - userID ? { user_id: userID } : {}, - ); - } - - /** - * getUnreadCount - Returns unread counts for a single user - * - * @param {string} [userID] User ID. - * - * @return {} - */ - async getUnreadCount(userID?: string) { - return await this.get( - this.baseURL + '/unread', - userID ? { user_id: userID } : {}, - ); - } - - /** - * getUnreadCountBatch - Returns unread counts for multiple users at once. Only works server side. - * - * @param {string[]} [userIDs] List of user IDs to fetch unread counts for. - * - * @return {} - */ - async getUnreadCountBatch(userIDs: string[]) { - return await this.post( - this.baseURL + '/unread_batch', - { user_ids: userIDs }, - ); - } - - /** - * setPushPreferences - Applies the list of push preferences. - * - * @param {PushPreference[]} A list of push preferences. - * - * @return {} - */ - async setPushPreferences(preferences: PushPreference[]) { - return await this.post( - this.baseURL + '/push_preferences', - { preferences }, - ); - } - - /** - * removeDevice - Removes the device with the given id. Clientside users can only delete their own devices - * - * @param {string} id The device id - * @param {string} [userID] The user id. Only specify this for serverside requests - * - */ - async removeDevice(id: string, userID?: string) { - return await this.delete(this.baseURL + '/devices', { - id, - ...(userID ? { user_id: userID } : {}), - }); - } - - /** - * getRateLimits - Returns the rate limits quota and usage for the current app, possibly filter for a specific platform and/or endpoints. - * Only available server-side. - * - * @param {object} [params] The params for the call. If none of the params are set, all limits for all platforms are returned. - * @returns {Promise} - */ - getRateLimits(params?: { - android?: boolean; - endpoints?: EndpointName[]; - ios?: boolean; - serverSide?: boolean; - web?: boolean; - }) { - const { serverSide, web, android, ios, endpoints } = params || {}; - return this.get(this.baseURL + '/rate_limits', { - server_side: serverSide, - web, - android, - ios, - endpoints: endpoints ? endpoints.join(',') : undefined, - }); - } - - /** - * getHookEvents - Get available events for hooks (webhook, SQS, and SNS) - * - * @param {Product[]} [products] Optional array of products to filter events by (e.g., [Product.Chat, Product.Video]) - * @returns {Promise} Response containing available hook events - */ - async getHookEvents(products?: Product[]) { - const params = products && products.length > 0 ? { product: products.join(',') } : {}; - return await this.get(this.baseURL + '/hook/events', params); - } - _addChannelConfig({ cid, config }: ChannelResponse) { if (this._cacheEnabled()) { this.configs = { @@ -2532,27 +1517,28 @@ export class StreamChat { } /** - * channel - Returns a new channel with the given type, id and custom data - * - * If you want to create a unique conversation between 2 or more users; you can leave out the ID parameter and provide the list of members. - * Make sure to await channel.create() or channel.watch() before accessing channel functions: - * ie. channel = client.channel("messaging", {members: ["tommaso", "thierry"]}) - * await channel.create() to assign an ID to channel + * Returns a new channel with the given type, ID and custom data. * - * @param {string} channelType The channel type - * @param {string | ChannelData | null} [channelIDOrCustom] The channel ID, you can leave this out if you want to create a conversation channel - * @param {object} [custom] Custom data to attach to the channel + * If you want to create a unique conversation between 2 or more users, you can leave out the ID + * parameter and provide the list of members. + * Make sure to await `channel.create()` or `channel.watch()` before accessing channel functions, + * i.e. `channel = client.channel('messaging', { members: ['tommaso', 'thierry'] })` then + * `await channel.create()` to assign an ID to the channel. * - * @return {channel} The channel object, initialize it using channel.watch() + * @param channelType - The channel type. + * @param channelIdOrCustom - The channel ID; you can leave this out if you want to create a + * conversation channel (optional). + * @param custom - Custom data to attach to the channel (optional, defaults to `{}`). + * @returns The channel object; initialize it using `channel.watch()`. */ - channel(channelType: string, channelID?: string | null, custom?: ChannelData): Channel; + channel(channelType: string, channelId?: string | null, custom?: ChannelData): Channel; channel(channelType: string, custom?: ChannelData): Channel; channel( channelType: string, - channelIDOrCustom?: string | ChannelData | null, + channelIdOrCustom?: string | ChannelData | null, custom: ChannelData = {}, ) { - if (!this.userID && !this._isUsingServerAuth()) { + if (!this.userId) { throw Error('Call connectUser or connectAnonymousUser before creating a channel'); } @@ -2563,28 +1549,28 @@ export class StreamChat { } // support channel("messaging", {options}) - if (channelIDOrCustom && typeof channelIDOrCustom === 'object') { - return this.getChannelByMembers(channelType, channelIDOrCustom); + if (channelIdOrCustom && typeof channelIdOrCustom === 'object') { + return this.getChannelByMembers(channelType, channelIdOrCustom); } // support channel("messaging", undefined, {options}) - if (!channelIDOrCustom && typeof custom === 'object' && custom.members?.length) { + if (!channelIdOrCustom && typeof custom === 'object' && custom.members?.length) { return this.getChannelByMembers(channelType, custom); } // support channel("messaging", null, {options}) // support channel("messaging", undefined, {options}) // support channel("messaging", "", {options}) - if (!channelIDOrCustom) { + if (!channelIdOrCustom) { return new Channel(this, channelType, undefined, custom); } - return this.getChannelById(channelType, channelIDOrCustom, custom); + return this.getChannelById(channelType, channelIdOrCustom, custom); } /** * It's a helper method for `client.channel()` method, used to create unique conversation or - * channel based on member list instead of id. + * channel based on member list instead of ID. * * If the channel already exists in `activeChannels` list, then we simply return it, since that * means the same channel was already requested or created. @@ -2593,16 +1579,15 @@ export class StreamChat { * * @private * - * @param {string} channelType The channel type - * @param {object} [custom] Custom data to attach to the channel - * - * @return {channel} The channel object, initialize it using channel.watch() + * @param channelType - The channel type. + * @param custom - Custom data to attach to the channel. + * @returns The channel object; initialize it using `channel.watch()`. */ getChannelByMembers = (channelType: string, custom: ChannelData) => { // Check if the channel already exists. // Only allow 1 channel object per cid - const memberIds = (custom.members ?? []).map((member: string | NewMemberPayload) => - typeof member === 'string' ? member : (member.user_id ?? ''), + const memberIds = (custom.members ?? []).map((member) => + typeof member === 'string' ? member : member.user_id, ); const membersStr = memberIds.sort().join(','); const tempCid = generateChannelTempCid(channelType, memberIds); @@ -2648,43 +1633,48 @@ export class StreamChat { }; /** - * Its a helper method for `client.channel()` method, used to channel given the id of channel. + * It's a helper method for `client.channel()`, used to retrieve a channel given its ID. * * If the channel already exists in `activeChannels` list, then we simply return it, since that * means the same channel was already requested or created. * - * Otherwise we create a new instance of Channel class and return it. + * Otherwise we create a new instance of `Channel` class and return it. * * @private * - * @param {string} channelType The channel type - * @param {string} [channelID] The channel ID - * @param {object} [custom] Custom data to attach to the channel - * - * @return {channel} The channel object, initialize it using channel.watch() + * @param channelType - The channel type. + * @param channelId - The channel ID. + * @param custom - Custom data to attach to the channel. + * @returns The channel object; initialize it using `channel.watch()`. */ - getChannelById = (channelType: string, channelID: string, custom: ChannelData) => { - if (typeof channelID === 'string' && ~channelID.indexOf(':')) { - throw Error(`Invalid channel id ${channelID}, can't contain the : character`); + getChannelById = (channelType: string, channelId: string, custom: ChannelData) => { + if (typeof channelId === 'string' && ~channelId.indexOf(':')) { + throw Error(`Invalid channel id ${channelId}, can't contain the : character`); } // only allow 1 channel object per cid - const cid = `${channelType}:${channelID}`; + const cid = `${channelType}:${channelId}`; if ( cid in this.activeChannels && this.activeChannels[cid] && !this.activeChannels[cid].disconnected ) { const channel = this.activeChannels[cid]; - if (Object.keys(custom).length > 0) { + // Only overwrite the existing channel's custom data when the caller actually provided some. + // A caller passing other fields (e.g. `{ members }`, or even `{ members: undefined }`) yields a + // non-empty object with no `.custom`; the previous `Object.keys(custom).length > 0` guard let + // that through and then set `custom: custom.custom` (undefined), wiping the channel's existing + // custom data (e.g. its name). Guarding on `custom.custom` keeps genuine custom updates while + // leaving the existing custom intact when the caller omits it. + if (custom.custom !== undefined) { const previousData = channel.data; - channel.data = { ...channel.data, ...custom }; + channel.data = { ...channel.data, custom: custom.custom }; channel._syncStateFromChannelData(channel.data, previousData); - channel._data = { ...channel._data, ...custom }; + channel._data = { ...channel._data, custom: custom.custom }; } return channel; } - const channel = new Channel(this, channelType, channelID, custom); + const channel = new Channel(this, channelType, channelId, custom); if (this._cacheEnabled()) { this.activeChannels[channel.cid] = channel; } @@ -2693,592 +1683,212 @@ export class StreamChat { }; /** - * partialUpdateUser - Update the given user object - * - * @param {PartialUserUpdate} partialUserObject which should contain id and any of "set" or "unset" params; - * example: {id: "user1", set:{field: value}, unset:["field2"]} + * Bans a user from all channels. * - * @return {Promise<{ users: { [key: string]: UserResponse } }>} list of updated users + * @param targetUserId - The user to ban. + * @param options - Ban options (optional). + * @returns The server response. */ - async partialUpdateUser(partialUserObject: PartialUserUpdate) { - return await this.partialUpdateUsers([partialUserObject]); + async banUser(targetUserId: string, options?: BanUserOptions) { + return await this.api.post(this.baseURL + '/moderation/ban', { + target_user_id: targetUserId, + ...options, + }); } /** - * upsertUsers - Batch upsert the list of users + * Revoke a global ban for a user. * - * @param {UserResponse[]} users list of users - * - * @return {Promise<{ users: { [key: string]: UserResponse } }>} + * @param targetUserId - The user to unban. + * @param options - Unban options (optional). + * @returns The server response. */ - async upsertUsers(users: UserResponse[]) { - const userMap: { [key: string]: UserResponse } = {}; - for (const userObject of users) { - if (!userObject.id) { - throw Error('User ID is required when updating a user'); - } - userMap[userObject.id] = userObject; - } - - return await this.post(this.baseURL + '/users', { - users: userMap, + async unbanUser(targetUserId: string, options?: UnBanUserOptions) { + return await this.api.delete(this.baseURL + '/moderation/ban', { + target_user_id: targetUserId, + ...options, }); } /** - * @deprecated Please use upsertUsers() function instead. - * - * updateUsers - Batch update the list of users - * - * @param {UserResponse[]} users list of users - * @return {Promise<{ users: { [key: string]: UserResponse } }>} - */ - updateUsers = this.upsertUsers; - - /** - * upsertUser - Update or Create the given user object + * Shadow bans a user from all channels. * - * @param {UserResponse} userObject user object, the only required field is the user id. IE {id: "myuser"} is valid - * - * @return {Promise<{ users: { [key: string]: UserResponse } }>} + * @param targetUserId - The user to shadow ban. + * @param options - Ban options (optional). + * @returns The server response. */ - upsertUser(userObject: UserResponse) { - return this.upsertUsers([userObject]); + async shadowBan(targetUserId: string, options?: BanUserOptions) { + return await this.banUser(targetUserId, { + shadow: true, + ...options, + }); } /** - * @deprecated Please use upsertUser() function instead. - * - * updateUser - Update or Create the given user object - * - * @param {UserResponse} userObject user object, the only required field is the user id. IE {id: "myuser"} is valid - * @return {Promise<{ users: { [key: string]: UserResponse } }>} - */ - updateUser = this.upsertUser; - - /** - * partialUpdateUsers - Batch partial update of users - * - * @param {PartialUserUpdate[]} users list of partial update requests + * Revoke a global shadow ban for a user. * - * @return {Promise<{ users: { [key: string]: UserResponse } }>} + * @param targetUserId - The user to remove the shadow ban for. + * @param options - Unban options (optional). + * @returns The server response. */ - async partialUpdateUsers(users: PartialUserUpdate[]) { - for (const userObject of users) { - if (!userObject.id) { - throw Error('User ID is required when updating a user'); - } + async removeShadowBan(targetUserId: string, options?: UnBanUserOptions) { + return await this.unbanUser(targetUserId, { + shadow: true, + ...options, + }); + } + async blockUser(blockedUserId: string) { + const result = await this.blockUsers({ + blocked_user_id: blockedUserId, + }); + if (this._cacheEnabled()) { + this.blockedUsers.next(({ userIds }) => ({ + userIds: userIds.concat(blockedUserId), + })); } - - return await this.patch(this.baseURL + '/users', { users }); + return result; } - async deleteUser( - userID: string, - params?: { - delete_conversation_channels?: boolean; - hard_delete?: boolean; - mark_messages_deleted?: boolean; - }, - ) { - return await this.delete< - APIResponse & { user: UserResponse } & { - task_id?: string; - } - >(this.baseURL + `/users/${encodeURIComponent(userID)}`, params); + override async getBlockedUsers() { + const result = await super.getBlockedUsers(); + if (this._cacheEnabled()) { + this.blockedUsers.partialNext({ + userIds: result.blocks.map(({ blocked_user_id }) => blocked_user_id), + }); + } + return result; } - /** - * restoreUsers - Restore soft deleted users - * - * @param {string[]} user_ids which users to restore - * - * @return {APIResponse} An API response - */ - async restoreUsers(user_ids: string[]) { - return await this.post(this.baseURL + `/users/restore`, { - user_ids, + async unblockUser(blockedUserId: string) { + const result = await this.unblockUsers({ + blocked_user_id: blockedUserId, }); + if (this._cacheEnabled()) { + this.blockedUsers.next(({ userIds }) => ({ + userIds: userIds.filter((id) => id !== blockedUserId), + })); + } + return result; } /** - * reactivateUser - Reactivate one user + * Mutes a user. * - * @param {string} userID which user to reactivate - * @param {ReactivateUserOptions} [options] - * - * @return {UserResponse} Reactivated user + * @param targetId - The user to mute. + * @param options - UserMuteResponse options (optional, defaults to `{}`). + * @returns The server response. */ - async reactivateUser(userID: string, options?: ReactivateUserOptions) { - return await this.post( - this.baseURL + `/users/${encodeURIComponent(userID)}/reactivate`, - { ...options }, - ); + async muteUser(targetId: string, options: MuteUserOptions = {}) { + return await this.api.post(this.baseURL + '/moderation/mute', { + target_id: targetId, + ...options, + }); } /** - * reactivateUsers - Reactivate many users asynchronously + * Unmutes a user. * - * @param {string[]} user_ids which users to reactivate - * @param {ReactivateUsersOptions} [options] - * - * @return {TaskResponse} A task ID + * @param targetId - The user to unmute. + * @returns The server response. */ - async reactivateUsers(user_ids: string[], options?: ReactivateUsersOptions) { - return await this.post( - this.baseURL + `/users/reactivate`, - { user_ids, ...options }, - ); + async unmuteUser(targetId: string) { + return await this.api.post(this.baseURL + '/moderation/unmute', { + target_id: targetId, + }); } /** - * deactivateUser - Deactivate one user - * - * @param {string} userID which user to deactivate - * @param {DeactivateUsersOptions} [options] + * Checks whether a user is muted. Can be used after `connectUser()` is called. * - * @return {UserResponse} Deactivated user + * @param targetId - The user ID to check. + * @returns `true` if the user is muted, otherwise `false`. */ - async deactivateUser(userID: string, options?: DeactivateUsersOptions) { - return await this.post( - this.baseURL + `/users/${encodeURIComponent(userID)}/deactivate`, - { ...options }, - ); + userMuteStatus(targetId: string) { + if (!this.user || !this.wsPromise) { + throw new Error('Make sure to await connectUser() first.'); + } + + for (let i = 0; i < this.mutedUsers.length; i += 1) { + if (this.mutedUsers[i].target?.id === targetId) return true; + } + return false; } /** - * deactivateUsers - Deactivate many users asynchronously + * Flag a message. * - * @param {string[]} user_ids which users to deactivate - * @param {DeactivateUsersOptions} [options] - * - * @return {TaskResponse} A task ID + * @param targetMessageId - The message to flag. + * @param options - Flag options (optional, defaults to `{}`). + * @param options.reason - Reason for flagging (optional). + * @returns The server response. */ - async deactivateUsers(user_ids: string[], options?: DeactivateUsersOptions) { - return await this.post( - this.baseURL + `/users/deactivate`, - { user_ids, ...options }, - ); - } - - async exportUser(userID: string, options?: Record) { - return await this.get< - APIResponse & { - messages: MessageResponse[]; - reactions: ReactionResponse[]; - user: UserResponse; - } - >(this.baseURL + `/users/${encodeURIComponent(userID)}/export`, { ...options }); + async flagMessage(targetMessageId: string, options: { reason?: string } = {}) { + return await this.api.post(this.baseURL + '/moderation/flag', { + target_message_id: targetMessageId, + ...options, + }); } - /** banUser - bans a user from all channels + /** + * Flag a user. * - * @param {string} targetUserID - * @param {BanUserOptions} [options] - * @returns {Promise} + * @param targetId - The user to flag. + * @param options - Flag options (optional, defaults to `{}`). + * @param options.reason - Reason for flagging (optional). + * @returns The server response. */ - async banUser(targetUserID: string, options?: BanUserOptions) { - return await this.post(this.baseURL + '/moderation/ban', { - target_user_id: targetUserID, + async flagUser(targetId: string, options: { reason?: string } = {}) { + return await this.api.post(this.baseURL + '/moderation/flag', { + target_user_id: targetId, ...options, }); } - /** unbanUser - revoke global ban for a user + /** + * Unflag a message. * - * @param {string} targetUserID - * @param {UnBanUserOptions} [options] - * @returns {Promise} + * @param targetMessageId - The message to unflag. + * @returns The server response. */ - async unbanUser(targetUserID: string, options?: UnBanUserOptions) { - return await this.delete(this.baseURL + '/moderation/ban', { - target_user_id: targetUserID, - ...options, + async unflagMessage(targetMessageId: string) { + return await this.api.post(this.baseURL + '/moderation/unflag', { + target_message_id: targetMessageId, }); } - /** shadowBan - shadow bans a user from all channels + /** + * Unflag a user. * - * @param {string} targetUserID - * @param {BanUserOptions} [options] - * @returns {Promise} + * @param targetId - The user to unflag. + * @returns The server response. */ - async shadowBan(targetUserID: string, options?: BanUserOptions) { - return await this.banUser(targetUserID, { - shadow: true, - ...options, + async unflagUser(targetId: string) { + return await this.api.post(this.baseURL + '/moderation/unflag', { + target_user_id: targetId, }); } - /** removeShadowBan - revoke global shadow ban for a user + /** + * Unblocks a message blocked by automod. * - * @param {string} targetUserID - * @param {UnBanUserOptions} [options] - * @returns {Promise} + * @param targetMessageId - The message to unblock. + * @returns The server response. */ - async removeShadowBan(targetUserID: string, options?: UnBanUserOptions) { - return await this.unbanUser(targetUserID, { - shadow: true, - ...options, - }); - } - async blockUser(blockedUserID: string, user_id?: string) { - const result = await this.post(this.baseURL + '/users/block', { - blocked_user_id: blockedUserID, - ...(user_id ? { user_id } : {}), - }); - if (this._cacheEnabled()) { - this.blockedUsers.next(({ userIds }) => ({ - userIds: userIds.concat(blockedUserID), - })); - } - return result; - } - - async getBlockedUsers(user_id?: string) { - const result = await this.get( - this.baseURL + '/users/block', - { - ...(user_id ? { user_id } : {}), - }, - ); - if (this._cacheEnabled()) { - this.blockedUsers.partialNext({ - userIds: result.blocks.map(({ blocked_user_id }) => blocked_user_id), - }); - } - return result; - } - - async unBlockUser(blockedUserID: string, userID?: string) { - const result = await this.post(this.baseURL + '/users/unblock', { - blocked_user_id: blockedUserID, - ...(userID ? { user_id: userID } : {}), - }); - if (this._cacheEnabled()) { - this.blockedUsers.next(({ userIds }) => ({ - userIds: userIds.filter((id) => id !== blockedUserID), - })); - } - return result; - } - - /** getSharedLocations - * - * @returns {Promise} The server response - * - */ - async getSharedLocations() { - return await this.get( - this.baseURL + `/users/live_locations`, - ); - } - - /** muteUser - mutes a user - * - * @param {string} targetID - * @param {string} [userID] Only used with serverside auth - * @param {MuteUserOptions} [options] - * @returns {Promise} - */ - async muteUser(targetID: string, userID?: string, options: MuteUserOptions = {}) { - return await this.post(this.baseURL + '/moderation/mute', { - target_id: targetID, - ...(userID ? { user_id: userID } : {}), - ...options, - }); - } - - /** unmuteUser - unmutes a user - * - * @param {string} targetID - * @param {string} [currentUserID] Only used with serverside auth - * @returns {Promise} - */ - async unmuteUser(targetID: string, currentUserID?: string) { - return await this.post(this.baseURL + '/moderation/unmute', { - target_id: targetID, - ...(currentUserID ? { user_id: currentUserID } : {}), - }); - } - - /** userMuteStatus - check if a user is muted or not, can be used after connectUser() is called - * - * @param {string} targetID - * @returns {boolean} - */ - userMuteStatus(targetID: string) { - if (!this.user || !this.wsPromise) { - throw new Error('Make sure to await connectUser() first.'); - } - - for (let i = 0; i < this.mutedUsers.length; i += 1) { - if (this.mutedUsers[i].target.id === targetID) return true; - } - return false; - } - - /** - * flagMessage - flag a message - * @param {string} targetMessageID - * @param {string} [options.user_id] currentUserID, only used with serverside auth - * @returns {Promise} - */ - async flagMessage( - targetMessageID: string, - options: { reason?: string; user_id?: string } = {}, - ) { - return await this.post(this.baseURL + '/moderation/flag', { - target_message_id: targetMessageID, - ...options, - }); - } - - /** - * flagUser - flag a user - * @param {string} targetID - * @param {string} [options.user_id] currentUserID, only used with serverside auth - * @returns {Promise} - */ - async flagUser(targetID: string, options: { reason?: string; user_id?: string } = {}) { - return await this.post(this.baseURL + '/moderation/flag', { - target_user_id: targetID, - ...options, - }); - } - - /** - * unflagMessage - unflag a message - * @param {string} targetMessageID - * @param {string} [options.user_id] currentUserID, only used with serverside auth - * @returns {Promise} - */ - async unflagMessage(targetMessageID: string, options: { user_id?: string } = {}) { - return await this.post(this.baseURL + '/moderation/unflag', { - target_message_id: targetMessageID, - ...options, - }); - } - - /** - * unflagUser - unflag a user - * @param {string} targetID - * @param {string} [options.user_id] currentUserID, only used with serverside auth - * @returns {Promise} - */ - async unflagUser(targetID: string, options: { user_id?: string } = {}) { - return await this.post(this.baseURL + '/moderation/unflag', { - target_user_id: targetID, - ...options, - }); - } - - /** - * _queryFlags - Query flags. - * - * Note: Do not use this. - * It is present for internal usage only. - * This function can, and will, break and/or be removed at any point in time. - * - * @private - * @param {FlagsFilters} filterConditions MongoDB style filter conditions - * @param {FlagsPaginationOptions} options Option object, {limit: 10, offset:0} - * - * @return {Promise} Flags Response - */ - async _queryFlags( - filterConditions: FlagsFilters = {}, - options: FlagsPaginationOptions = {}, - ) { - // Return a list of flags - return await this.post(this.baseURL + '/moderation/flags', { - filter_conditions: filterConditions, - ...options, - }); - } - - /** - * _queryFlagReports - Query flag reports. - * - * Note: Do not use this. - * It is present for internal usage only. - * This function can, and will, break and/or be removed at any point in time. - * - * @private - * @param {FlagReportsFilters} filterConditions MongoDB style filter conditions - * @param {FlagReportsPaginationOptions} options Option object, {limit: 10, offset:0} - * - * @return {Promise} Flag Reports Response - */ - async _queryFlagReports( - filterConditions: FlagReportsFilters = {}, - options: FlagReportsPaginationOptions = {}, - ) { - // Return a list of message flags - return await this.post(this.baseURL + '/moderation/reports', { - filter_conditions: filterConditions, - ...options, - }); - } - - /** - * _reviewFlagReport - review flag report - * - * Note: Do not use this. - * It is present for internal usage only. - * This function can, and will, break and/or be removed at any point in time. - * - * @private - * @param {string} [id] flag report to review - * @param {string} [reviewResult] flag report review result - * @param {string} [options.user_id] currentUserID, only used with serverside auth - * @param {string} [options.review_details] custom information about review result - * @returns {Promise>} - */ - async _reviewFlagReport( - id: string, - reviewResult: string, - options: ReviewFlagReportOptions = {}, - ) { - return await this.patch( - this.baseURL + `/moderation/reports/${encodeURIComponent(id)}`, + async unblockMessage(targetMessageId: string) { + return await this.api.post( + this.baseURL + '/moderation/unblock_message', { - review_result: reviewResult, - ...options, + target_message_id: targetMessageId, }, ); } /** - * unblockMessage - unblocks message blocked by automod - * - * - * @param {string} targetMessageID - * @param {string} [options.user_id] currentUserID, only used with serverside auth - * @returns {Promise} - */ - async unblockMessage(targetMessageID: string, options: { user_id?: string } = {}) { - return await this.post(this.baseURL + '/moderation/unblock_message', { - target_message_id: targetMessageID, - ...options, - }); - } - - // alias for backwards compatibility - _unblockMessage = this.unblockMessage; - - /** - * @deprecated use markChannelsRead instead - * - * markAllRead - marks all channels for this user as read - * @param {MarkAllReadOptions} [data] - * - * @return {Promise} - */ - markAllRead = this.markChannelsRead; - - /** - * markChannelsRead - marks channels read - - * it accepts a map of cid:messageid pairs, if messageid is empty, the whole channel will be marked as read - * - * @param {MarkChannelsReadOptions } [data] - * - * @return {Promise} - */ - async markChannelsRead(data: MarkChannelsReadOptions = {}) { - await this.post(this.baseURL + '/channels/read', { ...data }); - } - - createCommand(data: CreateCommandOptions) { - return this.post(this.baseURL + '/commands', data); - } - - getCommand(name: string) { - return this.get( - this.baseURL + `/commands/${encodeURIComponent(name)}`, - ); - } - - updateCommand(name: string, data: UpdateCommandOptions) { - return this.put( - this.baseURL + `/commands/${encodeURIComponent(name)}`, - data, - ); - } - - deleteCommand(name: string) { - return this.delete( - this.baseURL + `/commands/${encodeURIComponent(name)}`, - ); - } - - listCommands() { - return this.get(this.baseURL + `/commands`); - } - - createChannelType(data: CreateChannelOptions) { - const channelData = Object.assign({}, { commands: ['all'] }, data); - return this.post(this.baseURL + '/channeltypes', channelData); - } - - getChannelType(channelType: string) { - return this.get( - this.baseURL + `/channeltypes/${encodeURIComponent(channelType)}`, - ); - } - - updateChannelType(channelType: string, data: UpdateChannelTypeRequest) { - return this.put( - this.baseURL + `/channeltypes/${encodeURIComponent(channelType)}`, - data, - ); - } - - deleteChannelType(channelType: string) { - return this.delete( - this.baseURL + `/channeltypes/${encodeURIComponent(channelType)}`, - ); - } - - listChannelTypes() { - return this.get(this.baseURL + `/channeltypes`); - } - - /** - * translateMessage - adds the translation to the message - * - * @param {string} messageId - * @param {string} language - * - * @return {MessageResponse} Response that includes the message - */ - async translateMessage(messageId: string, language: string) { - return await this.post( - this.baseURL + `/messages/${encodeURIComponent(messageId)}/translate`, - { language }, - ); - } - - /** - * translate - translates the given text to provided language - * - * @param {string} text - * @param {string} destination_language - * @param {string} source_language + * Transforms an expiration value into an ISO string. * - * @return {TranslateResponse} Response that includes the message - */ - async translate(text: string, destination_language: string, source_language: string) { - return await this.post(this.baseURL + `/translate`, { - text, - source_language, - destination_language, - }); - } - - /** - * _normalizeExpiration - transforms expiration value into ISO string - * @param {undefined|null|number|string|Date} timeoutOrExpirationDate expiration date or timeout. Use number type to set timeout in seconds, string or Date to set exact expiration date + * @param timeoutOrExpirationDate - Expiration date or timeout. Use `number` to set the timeout + * in seconds, `string` or `Date` to set the exact expiration date (optional). + * @returns The expiration as an ISO string, or `null`. */ _normalizeExpiration(timeoutOrExpirationDate?: null | number | string | Date) { let pinExpires: null | string = null; @@ -3295,9 +1905,11 @@ export class StreamChat { } /** - * _messageId - extracts string message id from either message object or message id - * @param {string | { id: string }} messageOrMessageId message object or message id - * @param {string} errorText error message to report in case of message id absence + * Extracts a string message ID from either a message object or a message ID. + * + * @param messageOrMessageId - MessageRequest object or message ID. + * @param errorText - Error message to report in case of message ID absence. + * @returns The extracted message ID. */ _validateAndGetMessageId( messageOrMessageId: string | { id: string }, @@ -3316,328 +1928,145 @@ export class StreamChat { } /** - * pinMessage - pins the message - * @param {string | { id: string }} messageOrMessageId message object or message id - * @param {undefined|null|number|string|Date} timeoutOrExpirationDate expiration date or timeout. Use number type to set timeout in seconds, string or Date to set exact expiration date - * @param {undefined|string | { id: string }} [pinnedBy] who will appear as a user who pinned a message. Only for server-side use. Provide `undefined` when pinning message client-side - * @param {undefined|number|string|Date} pinnedAt date when message should be pinned. It affects the order of pinned messages. Use negative number to set relative time in the past, string or Date to set exact date of pin + * Pins the message. + * + * @param messageOrMessageId - MessageRequest object or message ID. + * @param timeoutOrExpirationDate - Expiration date or timeout. Use `number` to set the timeout + * in seconds, `string` or `Date` to set the exact expiration date (optional). + * @param pinnedAt - Date when the message should be pinned. It affects the order of pinned + * messages. Use a negative number to set relative time in the past, `string` or `Date` to + * set the exact date of pin (optional). + * @returns The updated message response. */ pinMessage( messageOrMessageId: string | { id: string }, timeoutOrExpirationDate?: null | number | string | Date, - pinnedBy?: string | { id: string }, pinnedAt?: number | string | Date, ) { - const messageId = this._validateAndGetMessageId( + const id = this._validateAndGetMessageId( messageOrMessageId, - 'Please specify the message id when calling unpinMessage', - ); - return this.partialUpdateMessage( - messageId, - { - set: { - pinned: true, - pin_expires: this._normalizeExpiration(timeoutOrExpirationDate), - pinned_at: this._normalizeExpiration(pinnedAt), - }, - } as unknown as PartialMessageUpdate, - pinnedBy, + 'Please specify the message id when calling pinMessage', ); + return this.updateMessagePartial({ + id, + set: { + pinned: true, + pin_expires: this._normalizeExpiration(timeoutOrExpirationDate), + pinned_at: this._normalizeExpiration(pinnedAt), + }, + }); } /** - * unpinMessage - unpins the message that was previously pinned - * @param {string | { id: string }} messageOrMessageId message object or message id - * @param {string | { id: string }} [userId] + * Unpins the message that was previously pinned. + * + * @param messageOrMessageId - MessageRequest object or message ID. + * @returns The updated message response. */ - unpinMessage( - messageOrMessageId: string | { id: string }, - userId?: string | { id: string }, - ) { - const messageId = this._validateAndGetMessageId( + unpinMessage(messageOrMessageId: string | { id: string }) { + const id = this._validateAndGetMessageId( messageOrMessageId, 'Please specify the message id when calling unpinMessage', ); - return this.partialUpdateMessage( - messageId, - { - set: { pinned: false }, - } as unknown as PartialMessageUpdate, - userId, - ); + return this.updateMessagePartial({ + id, + set: { pinned: false }, + }); } /** - * updateMessage - Update the given message - * - * @param {Omit & { mentioned_users?: string[] }} message object, id needs to be specified - * @param {string | { id: string }} [partialUserOrUserId] - * @param {boolean} [options.skip_enrich_url] Do not try to enrich the URLs within message - * - * @return {{ message: LocalMessage | MessageResponse }} Response that includes the message + * Updates the given message. When an `offlineDb` is registered the call is queued + * so it is replayed on reconnect. */ - async updateMessage( - message: LocalMessage | Partial, - partialUserOrUserId?: string | { id: string }, - options?: UpdateMessageOptions, + override async updateMessage( + request: Parameters[0] & { message: { cid?: string } }, ) { - if (!message.id) { - throw Error('Please specify the message.id when calling updateMessage'); - } - - const messageId = message.id as string; - try { if (this.offlineDb) { - return await this.offlineDb.queueTask({ + return await this.offlineDb.queueTask< + Awaited> + >({ task: { - ...getPendingTaskChannelData(message.cid), - messageId, - payload: [message, partialUserOrUserId, options], + ...getPendingTaskChannelData(request.message?.cid), + messageId: request.id, + payload: [request], type: 'update-message', }, }); } } catch (error) { - this.logger('error', `offlineDb:updateMessage`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('updateMessage') + .error('Updating the message failed.', { error }); } - return await this._updateMessage(message, partialUserOrUserId, options); + return await this._updateMessage(request); } - async _updateMessage( - message: LocalMessage | Partial, - partialUserOrUserId?: string | { id: string }, - options?: UpdateMessageOptions, - ) { - if (!message.id) { - throw Error('Please specify the message.id when calling updateMessage'); - } - - // should not include user object - const payload = toUpdatedMessagePayload(message); - - // add user_id (if exists) - if (typeof partialUserOrUserId === 'string') { - payload.user_id = partialUserOrUserId; - } else if (typeof partialUserOrUserId?.id === 'string') { - payload.user_id = partialUserOrUserId.id; - } - - return await this.post( - this.baseURL + `/messages/${encodeURIComponent(message.id as string)}`, - { - message: payload, - ...options, - }, - ); - } - - /** - * partialUpdateMessage - Update the given message id while retaining additional properties - * - * @param {string} id the message id - * - * @param {PartialUpdateMessage} partialMessageObject which should contain id and any of "set" or "unset" params; - * example: {id: "user1", set:{text: "hi"}, unset:["color"]} - * @param {string | { id: string }} [userId] - * - * @param {boolean} [options.skip_enrich_url] Do not try to enrich the URLs within message - * - * @return {{ message: MessageResponse }} Response that includes the updated message - */ - async partialUpdateMessage( - id: string, - partialMessageObject: PartialMessageUpdate, - partialUserOrUserId?: string | { id: string }, - options?: UpdateMessageOptions, - ) { - if (!id) { - throw Error('Please specify the message.id when calling partialUpdateMessage'); - } - - let user: { id: string } | undefined = undefined; - - if (typeof partialUserOrUserId === 'string') { - user = { id: partialUserOrUserId }; - } else if (typeof partialUserOrUserId?.id === 'string') { - user = { id: partialUserOrUserId.id }; - } - - return await this.put( - this.baseURL + `/messages/${encodeURIComponent(id)}`, - { - ...partialMessageObject, - ...options, - user, - }, - ); - } - - /** - * Updates message fields without storing them in the database, only sends update event. - * - * Available only on the server-side. - * - * @param messageId the message id to update. - * @param partialMessageObject the message payload. - * @param partialUserOrUserId the user id linked to this action. - * @param options additional options. - */ - async ephemeralUpdateMessage( - messageId: string, - partialMessageObject: PartialMessageUpdate, - partialUserOrUserId?: string | { id: string }, - options?: UpdateMessageOptions, - ) { - if (!messageId) throw Error('messageId is required'); - - let user: { id: string } | undefined = undefined; - if (typeof partialUserOrUserId === 'string') { - user = { id: partialUserOrUserId }; - } else if (typeof partialUserOrUserId?.id === 'string') { - user = { id: partialUserOrUserId.id }; - } - - return await this.patch( - `${this.baseURL}/messages/${encodeURIComponent(messageId)}/ephemeral`, - { - ...partialMessageObject, - ...options, - user, - }, - ); + async _updateMessage(request: Parameters[0]) { + return await super.updateMessage(request); } /** - * deleteMessage - Delete a message - * - * @param {string} messageID The id of the message to delete - * @param {boolean | DeleteMessageOptions | undefined} [optionsOrHardDelete] - * @return {Promise} The API response + * Deletes a message. When an `offlineDb` is registered the call is queued so it + * is replayed on reconnect. */ - // fixme: remove the signature with optionsOrHardDelete boolean with the next major release - async deleteMessage( - messageID: string, - optionsOrHardDelete?: DeleteMessageOptions | boolean, - ): Promise { - let options: DeleteMessageOptions = {}; - if (typeof optionsOrHardDelete === 'boolean') { - options = optionsOrHardDelete ? { hardDelete: true } : {}; - } else if (optionsOrHardDelete?.deleteForMe) { - options = { deleteForMe: true }; - } else if (optionsOrHardDelete?.hardDelete) { - options = { hardDelete: true }; - } - + override async deleteMessage(request: Parameters[0]) { try { if (this.offlineDb) { - if (options.hardDelete) { - await this.offlineDb.hardDeleteMessage({ id: messageID }); + if (request.hard) { + await this.offlineDb.hardDeleteMessage({ id: request.id }); } else { await this.offlineDb.softDeleteMessage({ - id: messageID, - deleteForMe: options.deleteForMe, + id: request.id, + deleteForMe: request.delete_for_me, }); } - return await this.offlineDb.queueTask( - { - task: { - messageId: messageID, - payload: [messageID, options], - type: 'delete-message', - }, + return await this.offlineDb.queueTask< + Awaited> + >({ + task: { + messageId: request.id, + payload: [request], + type: 'delete-message', }, - ); + }); } } catch (error) { - this.logger('error', `offlineDb:deleteMessage`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('deleteMessage') + .error('Deleting the message failed.', { error }); } - return this._deleteMessage(messageID, options); + return this._deleteMessage(request); } - // fixme: remove the signature with optionsOrHardDelete boolean with the next major release - async _deleteMessage( - messageID: string, - optionsOrHardDelete?: DeleteMessageOptions | boolean, - ): Promise { - // this is a API call method, we do not route hardDelete: true and deleteForMe: true to deleteForMe: true - // and expect to receive error response from the server - const { deleteForMe, hardDelete } = ( - typeof optionsOrHardDelete === 'boolean' - ? { hardDelete: optionsOrHardDelete } - : (optionsOrHardDelete ?? {}) - ) as DeleteMessageOptions; - - let params = {}; - if (hardDelete) { - params = { hard: true }; - } - if (deleteForMe) { - params = { ...params, delete_for_me: true }; - } - const result = await this.delete( - this.baseURL + `/messages/${encodeURIComponent(messageID)}`, - params, - ); + async _deleteMessage(request: Parameters[0]) { + const result = await super.deleteMessage(request); // necessary to populate the below values as the server does not return the message in the response as deleted - if (deleteForMe) { + if (request.delete_for_me) { result.message.deleted_for_me = true; result.message.type = 'deleted'; } - return result; - } - - /** - * undeleteMessage - Undelete a message - * - * undeletes a message that was previous soft deleted. Hard deleted messages - * cannot be undeleted. This is only allowed to be called from server-side - * clients. - * - * @param {string} messageID The id of the message to undelete - * @param {string} userID The id of the user who undeleted the message - * - * @return {{ message: MessageResponse }} Response that includes the message - */ - async undeleteMessage(messageID: string, userID: string) { - return await this.post( - this.baseURL + `/messages/${encodeURIComponent(messageID)}/undelete`, - { undeleted_by: userID }, - ); - } - async getMessage(messageID: string, options?: GetMessageOptions) { - return await this.get( - this.baseURL + `/messages/${encodeURIComponent(messageID)}`, - { - ...options, - }, - ); + return result; } /** - * queryThreads - returns the list of threads of current user. - * - * @param {QueryThreadsOptions} options Options object for pagination and limiting the participants and replies. - * @param {number} options.limit Limits the number of threads to be returned. - * @param {boolean} options.watch Subscribes the user to the channels of the threads. - * @param {number} options.participant_limit Limits the number of participants returned per threads. - * @param {number} options.reply_limit Limits the number of replies returned per threads. - * @param {ThreadFilters} options.filter MongoDB style filters for threads - * @param {ThreadSort} options.sort MongoDB style sort for threads + * Returns the list of threads of the current user. * - * @returns {{ threads: Thread[], next: string }} Returns the list of threads and the next cursor. + * @param options - Options object for pagination and limiting the participants and replies + * (optional, defaults to `{}`). + * @param options.limit - Limits the number of threads to be returned (optional). + * @param options.watch - Subscribes the user to the channels of the threads (optional). + * @param options.participant_limit - Limits the number of participants returned per thread (optional). + * @param options.reply_limit - Limits the number of replies returned per thread (optional). + * @param options.filter - MongoDB style filters for threads (optional). + * @param options.sort - MongoDB style sort for threads (optional). + * @returns The list of threads and the next cursor. */ - async queryThreads(options: QueryThreadsOptions = {}) { + async queryThreadsAndHydrate(options: QueryThreadsRequest = {}) { const optionsWithDefaults = { limit: 10, participant_limit: 10, @@ -3657,22 +2086,15 @@ export class StreamChat { requestBody.filter = optionsWithDefaults.filter; } - if ( - optionsWithDefaults.sort && - (Array.isArray(optionsWithDefaults.sort) - ? optionsWithDefaults.sort.length > 0 - : Object.keys(optionsWithDefaults.sort).length > 0) - ) { - requestBody.sort = normalizeQuerySort(optionsWithDefaults.sort); + if (optionsWithDefaults.sort && optionsWithDefaults.sort.length > 0) { + requestBody.sort = optionsWithDefaults.sort; } - const response = await this.post( - `${this.baseURL}/threads`, - requestBody, - ); + const response = await this.queryThreads(requestBody); // Hydrate the polls for the parent messages of the threads - const parentMessages = response.threads.map((thread) => thread.parent_message); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const parentMessages = response.threads.map((thread) => thread.parent_message!); this.polls.hydratePollCache(parentMessages); return { @@ -3684,19 +2106,19 @@ export class StreamChat { } /** - * getThread - returns the thread of a message by its id. - * - * @param {string} messageId The message id - * @param {GetThreadOptions} options Options object for pagination and limiting the participants and replies. - * @param {boolean} options.watch Subscribes the user to the channel of the thread. - * @param {number} options.participant_limit Limits the number of participants returned per threads. - * @param {number} options.reply_limit Limits the number of replies returned per threads. + * Returns the thread of a message by its ID, wrapped in a hydrated `Thread` instance. * - * @returns {Thread} Returns the thread. + * @param messageId - The message ID. + * @param options - Options object for pagination and limiting the participants and replies + * (optional, defaults to `{}`). + * @param options.watch - Subscribes the user to the channel of the thread (optional). + * @param options.participant_limit - Limits the number of participants returned per thread (optional). + * @param options.reply_limit - Limits the number of replies returned per thread (optional). + * @returns The thread. */ - async getThread(messageId: string, options: GetThreadOptions = {}) { + async getThreadAndHydrate(messageId: string, options: GetThreadOptions = {}) { if (!messageId) { - throw new Error('Please specify the messageId when calling getThread'); + throw new Error('Please specify the messageId when calling getThreadAndHydrate'); } const optionsWithDefaults = { @@ -3706,21 +2128,20 @@ export class StreamChat { ...options, }; - const response = await this.get( - `${this.baseURL}/threads/${encodeURIComponent(messageId)}`, - optionsWithDefaults, - ); + const response = await this.getThread({ + message_id: messageId, + ...optionsWithDefaults, + }); return new Thread({ client: this, threadData: response.thread }); } /** - * partialUpdateThread - updates the given thread - * - * @param {string} messageId The id of the thread message which needs to be updated. - * @param {PartialThreadUpdate} partialThreadObject should contain "set" or "unset" params for any of the thread's non-reserved fields. + * Updates the given thread. * - * @returns {GetThreadAPIResponse} Returns the updated thread. + * @param messageId - The ID of the thread message which needs to be updated. + * @param partialThreadObject - Should contain `set` or `unset` params for any of the thread's non-reserved fields. + * @returns The updated thread. */ async partialUpdateThread(messageId: string, partialThreadObject: PartialThreadUpdate) { if (!messageId) { @@ -3750,10 +2171,10 @@ export class StreamChat { } } - return await this.patch( - `${this.baseURL}/threads/${encodeURIComponent(messageId)}`, - partialThreadObject, - ); + return await this.updateThreadPartial({ + message_id: messageId, + ...partialThreadObject, + }); } getUserAgent = (): string => { @@ -3794,74 +2215,17 @@ export class StreamChat { }; /** - * @deprecated use sdkIdentifier instead - * @param userAgent + * Sets the user agent string. + * + * @deprecated Use `sdkIdentifier` instead. + * + * @param userAgent - The user agent string. */ setUserAgent(userAgent: string) { this.userAgent = userAgent; } - /** - * _isUsingServerAuth - Returns true if we're using server side auth - */ - _isUsingServerAuth = () => !!this.secret; - - _cacheEnabled = () => !this._isUsingServerAuth() || !this.options.disableCache; - - _enrichAxiosOptions( - options: AxiosRequestConfig & { config?: AxiosRequestConfig } = { - params: {}, - headers: {}, - config: {}, - }, - ): AxiosRequestConfig { - const token = this._getToken(); - const authorization = token ? { Authorization: token } : undefined; - let signal: AbortSignal | null = null; - if (this.nextRequestAbortController !== null) { - signal = this.nextRequestAbortController.signal; - this.nextRequestAbortController = null; - } - - if (!options.headers?.['x-client-request-id']) { - options.headers = { - ...options.headers, - 'x-client-request-id': randomId(), - }; - } - - const { - params: axiosRequestConfigParams, - headers: axiosRequestConfigHeaders, - ...axiosRequestConfigRest - } = this.options.axiosRequestConfig || {}; - - return { - params: { - user_id: this.userID, - connection_id: this._getConnectionID(), - api_key: this.key, - ...options.params, - ...(axiosRequestConfigParams || {}), - }, - headers: { - ...authorization, - 'stream-auth-type': this.getAuthType(), - 'X-Stream-Client': this.getUserAgent(), - ...options.headers, - ...(axiosRequestConfigHeaders || {}), - }, - ...(signal ? { signal } : {}), - ...options.config, - ...(axiosRequestConfigRest || {}), - }; - } - - _getToken() { - if (!this.tokenManager || this.anonymous) return null; - - return this.tokenManager.getToken(); - } + _cacheEnabled = () => !this.options.disableCache; _startCleaning() { // eslint-disable-next-line @typescript-eslint/no-this-alias @@ -3878,1560 +2242,110 @@ export class StreamChat { } /** - * encode ws url payload + * Encodes the WS URL payload. + * * @private - * @returns json string + * + * @param client_request_id - The client request ID (optional). + * @returns The JSON-encoded payload string. */ _buildWSPayload = (client_request_id?: string) => JSON.stringify({ - user_id: this.userID, + user_id: this.userId, user_details: this._user, device: this.options.device, client_request_id, }); /** - * checks signature of a request - * @param {string | Buffer} rawBody - * @param {string} signature from HTTP header - * @returns {boolean} - */ - verifyWebhook(requestBody: string | Buffer, xSignature: string) { - return !!this.secret && verifySignature(requestBody, xSignature, this.secret); - } - - /** - * Verify and parse an HTTP webhook event. - * - * Decompresses `rawBody` when gzipped (detected from the body bytes), - * verifies the `X-Signature` header against the app's API secret, and - * returns the parsed `Event`. Works whether or not Stream is currently - * compressing payloads for this app, and stays correct behind - * middleware that auto-decompresses the request. - * - * @param rawBody Raw HTTP request body bytes Stream signed - * @param signature Value of the `X-Signature` header - * @throws {InvalidWebhookError} When the signature does not match or - * the gzip envelope is malformed. - */ - verifyAndParseWebhook(rawBody: string | Buffer, signature: string) { - if (!this.secret) { - throw new InvalidWebhookError( - 'cannot verify webhook signature without an API secret on the client', - ); - } - return verifyAndParseWebhookHelper(rawBody, signature, this.secret); - } - - /** - * Parse an SQS firehose event: decodes the message `Body` (base64 + - * optional gzip) and returns the parsed `Event`. No HMAC verification - * (Stream does not sign SQS bodies). - * - * @param messageBody SQS message `Body` string - * @throws {InvalidWebhookError} When the base64 / gzip envelope is malformed. - */ - parseSqs(messageBody: string) { - return parseSqsHelper(messageBody); - } - - /** - * Parse an SNS-delivered event (unwraps envelope JSON when needed, then - * same decode path as SQS). No HMAC verification. - * - * @param notificationBody Raw SNS POST body or pre-extracted `Message` string - * @throws {InvalidWebhookError} When the envelope cannot be decoded. - */ - parseSns(notificationBody: string) { - return parseSnsHelper(notificationBody); - } - - /** getPermission - gets the definition for a permission - * - * @param {string} name - * @returns {Promise} - */ - getPermission(name: string) { - return this.get( - `${this.baseURL}/permissions/${encodeURIComponent(name)}`, - ); - } - - /** createPermission - creates a custom permission - * - * @param {CustomPermissionOptions} permissionData the permission data - * @returns {Promise} - */ - createPermission(permissionData: CustomPermissionOptions) { - return this.post(`${this.baseURL}/permissions`, { - ...permissionData, - }); - } - - /** updatePermission - updates an existing custom permission - * - * @param {string} id - * @param {Omit} permissionData the permission data - * @returns {Promise} - */ - updatePermission(id: string, permissionData: Omit) { - return this.put( - `${this.baseURL}/permissions/${encodeURIComponent(id)}`, - { - ...permissionData, + * Queries poll answers. + * + * @param request - The query poll answers request payload, including the poll ID, optional vote + * filter conditions, sort directions, and pagination options (`limit`, `offset`). + * @param request.poll_id - The poll ID. + * @param request.filter - Vote filter conditions. + * @returns The poll answers. + */ + async queryPollAnswers({ + poll_id, + filter, + ...options + }: Parameters[0]) { + return await this.queryPollVotes({ + poll_id, + filter: { + ...filter, + is_answer: true, }, - ); - } - - /** deletePermission - deletes a custom permission - * - * @param {string} name - * @returns {Promise} - */ - deletePermission(name: string) { - return this.delete( - `${this.baseURL}/permissions/${encodeURIComponent(name)}`, - ); - } - - /** listPermissions - returns the list of all permissions for this application - * - * @returns {Promise} - */ - listPermissions() { - return this.get(`${this.baseURL}/permissions`); - } - - /** createRole - creates a custom role - * - * @param {string} name the new role name - * @returns {Promise} - */ - createRole(name: string) { - return this.post(`${this.baseURL}/roles`, { name }); - } - - /** listRoles - returns the list of all roles for this application - * - * @returns {Promise} - */ - listRoles() { - return this.get(`${this.baseURL}/roles`); - } - - /** listRoles - returns the list of all roles for this application - * - * @returns {Promise} - */ - searchRoles(options: SearchRolesOptions) { - return this.get(`${this.baseURL}/roles/search`, options); - } - - /** deleteRole - deletes a custom role - * - * @param {string} name the role name - * @returns {Promise} - */ - deleteRole(name: string) { - return this.delete(`${this.baseURL}/roles/${encodeURIComponent(name)}`); - } - - /** sync - returns all events that happened for a list of channels since last sync - * @param {string[]} channel_cids list of channel CIDs - * @param {string} last_sync_at last time the user was online and in sync. RFC3339 ie. "2020-05-06T15:05:01.207Z" - * @param {SyncOptions} options See JSDoc in the type fields for more info - * - * @returns {Promise} - */ - sync(channel_cids: string[], last_sync_at: string, options: SyncOptions = {}) { - return this.post(`${this.baseURL}/sync`, { - channel_cids, - last_sync_at, ...options, }); } /** - * sendUserCustomEvent - Send a custom event to a user - * - * @param {string} targetUserID target user id - * @param {UserCustomEvent} event for example {type: 'friendship-request'} - * - * @return {Promise} The Server Response - */ - async sendUserCustomEvent(targetUserID: string, event: UserCustomEvent) { - return await this.post( - `${this.baseURL}/users/${encodeURIComponent(targetUserID)}/event`, - { - event, - }, - ); - } - - /** - * Creates a new block list - * - * @param {BlockList} blockList - The block list to create - * @param {string} blockList.name - The name of the block list - * @param {string[]} blockList.words - List of words to block - * @param {string} [blockList.team] - Team ID the block list belongs to - * - * @returns {Promise} The server response - */ - createBlockList(blockList: BlockList) { - return this.post(`${this.baseURL}/blocklists`, blockList); - } - - /** - * Lists all block lists - * - * @param {Object} [data] - Query parameters - * @param {string} [data.team] - Team ID to filter block lists by - * - * @returns {Promise} Response containing array of block lists - */ - listBlockLists(data?: { team?: string }) { - return this.get( - `${this.baseURL}/blocklists`, - data, - ); - } - - /** - * Gets a specific block list - * - * @param {string} name - The name of the block list to retrieve - * @param {Object} [data] - Query parameters - * @param {string} [data.team] - Team ID that blocklist belongs to - * - * @returns {Promise} Response containing the block list - */ - getBlockList(name: string, data?: { team?: string }) { - return this.get( - `${this.baseURL}/blocklists/${encodeURIComponent(name)}`, - data, - ); - } - - /** - * Updates an existing block list - * - * @param {string} name - The name of the block list to update - * @param {Object} data - The update data - * @param {string[]} data.words - New list of words to block - * @param {string} [data.team] - Team ID that blocklist belongs to - * - * @returns {Promise} The server response - */ - updateBlockList(name: string, data: { words: string[]; team?: string }) { - return this.put( - `${this.baseURL}/blocklists/${encodeURIComponent(name)}`, - data, - ); - } - - /** - * Deletes a block list - * - * @param {string} name - The name of the block list to delete - * @param {Object} [data] - Query parameters - * @param {string} [data.team] - Team ID that blocklist belongs to + * Uploads a file to the configured storage (defaults to Stream CDN). * - * @returns {Promise} The server response + * @param uri - The file to upload. + * @param name - The name of the file (optional). + * @param contentType - The content type of the file (optional). + * @param user - User information (optional). + * @param axiosRequestConfig - Axios config, e.g. `onUploadProgress` for progress tracking (optional). + * @returns Response containing the file URL. */ - deleteBlockList(name: string, data?: { team?: string }) { - return this.delete( - `${this.baseURL}/blocklists/${encodeURIComponent(name)}`, - data, - ); - } - - exportChannels( - request: Array, - options: ExportChannelOptions = {}, + uploadFile_( + uri: string | NodeJS.ReadableStream | Buffer | File, + name?: string, + contentType?: string, + user?: UserResponse, + axiosRequestConfig?: AxiosRequestConfig, ) { - const payload = { channels: request, ...options }; - return this.post( - `${this.baseURL}/export_channels`, - payload, - ); - } - - exportUsers(request: ExportUsersRequest) { - return this.post( - `${this.baseURL}/export/users`, - request, - ); - } - - exportChannel(request: ExportChannelRequest, options?: ExportChannelOptions) { - return this.exportChannels([request], options); - } - - getExportChannelStatus(id: string) { - return this.get( - `${this.baseURL}/export_channels/${encodeURIComponent(id)}`, - ); - } - - campaign(idOrData: string | CampaignData, data?: CampaignData) { - if (idOrData && typeof idOrData === 'object') { - return new Campaign(this, null, idOrData); - } - - return new Campaign(this, idOrData, data); - } - - /** - * channelBatchUpdater - Returns a ChannelBatchUpdater instance for batch channel operations - * - * @return {ChannelBatchUpdater} A ChannelBatchUpdater instance - */ - channelBatchUpdater() { - return new ChannelBatchUpdater(this); - } - - segment(type: SegmentType, idOrData: string | SegmentData, data?: SegmentData) { - if (typeof idOrData === 'string') { - return new Segment(this, type, idOrData, data); - } - - return new Segment(this, type, null, idOrData); - } - - validateServerSideAuth() { - if (!this.secret) { - throw new Error( - 'This feature can be used server-side only. Please initialize the client with a secret to use this feature.', - ); - } - } - - /** - * createSegment - Creates a segment - * - * @private - * @param {SegmentType} type Segment type - * @param {string} id Segment ID - * @param {string} name Segment name - * @param {SegmentData} params Segment data - * - * @return {{segment: SegmentResponse} & APIResponse} The created Segment - */ - createSegment(type: SegmentType, id: string | null, data?: SegmentData) { - this.validateServerSideAuth(); - const body = { - id, - type, - ...data, - }; - return this.post<{ segment: SegmentResponse }>(this.baseURL + `/segments`, body); - } - - /** - * createUserSegment - Creates a user segment - * - * @param {string} id Segment ID - * @param {string} name Segment name - * @param {SegmentData} data Segment data - * - * @return {Segment} The created Segment - */ - createUserSegment(id: string | null, data?: SegmentData) { - this.validateServerSideAuth(); - return this.createSegment('user', id, data); - } - - /** - * createChannelSegment - Creates a channel segment - * - * @param {string} id Segment ID - * @param {string} name Segment name - * @param {SegmentData} data Segment data - * - * @return {Segment} The created Segment - */ - createChannelSegment(id: string | null, data?: SegmentData) { - this.validateServerSideAuth(); - return this.createSegment('channel', id, data); - } - - getSegment(id: string) { - this.validateServerSideAuth(); - return this.get<{ segment: SegmentResponse } & APIResponse>( - this.baseURL + `/segments/${encodeURIComponent(id)}`, - ); - } - - /** - * updateSegment - Update a segment - * - * @param {string} id Segment ID - * @param {Partial} data Data to update - * - * @return {Segment} Updated Segment - */ - updateSegment(id: string, data: Partial) { - this.validateServerSideAuth(); - return this.put<{ segment: SegmentResponse }>( - this.baseURL + `/segments/${encodeURIComponent(id)}`, - data, + return this.api.sendFile( + `${this.baseURL}/uploads/file`, + uri, + name, + contentType, + user, + axiosRequestConfig, ); } /** - * addSegmentTargets - Add targets to a segment - * - * @param {string} id Segment ID - * @param {string[]} targets Targets to add to the segment + * Uploads an image to the configured storage (defaults to Stream CDN). * - * @return {APIResponse} API response + * @param uri - The image to upload. + * @param name - The name of the image (optional). + * @param contentType - The content type of the image (optional). + * @param user - User information (optional). + * @param axiosRequestConfig - Axios config, e.g. `onUploadProgress` for progress tracking (optional). + * @returns Response containing the image URL. */ - addSegmentTargets(id: string, targets: string[]) { - this.validateServerSideAuth(); - const body = { target_ids: targets }; - return this.post( - this.baseURL + `/segments/${encodeURIComponent(id)}/addtargets`, - body, - ); - } - - querySegmentTargets( - id: string, - filter: QuerySegmentTargetsFilter | null = {}, - sort: SortParam[] | null | [] = [], - options = {}, + uploadImage_( + uri: string | NodeJS.ReadableStream | File, + name?: string, + contentType?: string, + user?: UserResponse, + axiosRequestConfig?: AxiosRequestConfig, ) { - this.validateServerSideAuth(); - return this.post<{ targets: SegmentTargetsResponse[]; next?: string } & APIResponse>( - this.baseURL + `/segments/${encodeURIComponent(id)}/targets/query`, - { - filter: filter || {}, - sort: sort || [], - ...options, - }, - ); - } - /** - * removeSegmentTargets - Remove targets from a segment - * - * @param {string} id Segment ID - * @param {string[]} targets Targets to add to the segment - * - * @return {APIResponse} API response - */ - removeSegmentTargets(id: string, targets: string[]) { - this.validateServerSideAuth(); - const body = { target_ids: targets }; - return this.post( - this.baseURL + `/segments/${encodeURIComponent(id)}/deletetargets`, - body, - ); - } - - /** - * querySegments - Query Segments - * - * @param {filter} filter MongoDB style filter conditions - * @param {QuerySegmentsOptions} options Options for sorting/paginating the results - * - * @return {Segment[]} Segments - */ - querySegments(filter: {}, sort?: SortParam[], options: QuerySegmentsOptions = {}) { - this.validateServerSideAuth(); - return this.post< - { - segments: SegmentResponse[]; - next?: string; - prev?: string; - } & APIResponse - >(this.baseURL + `/segments/query`, { - filter, - sort, - ...options, - }); - } - - /** - * deleteSegment - Delete a Campaign Segment - * - * @param {string} id Segment ID - * - * @return {Promise} The Server Response - */ - deleteSegment(id: string) { - this.validateServerSideAuth(); - return this.delete(this.baseURL + `/segments/${encodeURIComponent(id)}`); - } - - /** - * segmentTargetExists - Check if a target exists in a segment - * - * @param {string} segmentId Segment ID - * @param {string} targetId Target ID - * - * @return {Promise} The Server Response - */ - segmentTargetExists(segmentId: string, targetId: string) { - this.validateServerSideAuth(); - return this.get( - this.baseURL + - `/segments/${encodeURIComponent(segmentId)}/target/${encodeURIComponent(targetId)}`, + return this.api.sendFile( + `${this.baseURL}/uploads/image`, + uri, + name, + contentType, + user, + axiosRequestConfig, ); } - /** - * createCampaign - Creates a Campaign + * Marks the channels as delivered for the given messages and the user. * - * @param {CampaignData} params Campaign data - * - * @return {Campaign} The Created Campaign + * @param request - Mark delivered options. + * @returns The server response, or `undefined` if there are no messages to mark. */ - createCampaign(params: CampaignData) { - this.validateServerSideAuth(); - return this.post< - { - campaign: CampaignResponse; - users: { - next?: string; - prev?: string; - }; - } & APIResponse - >(this.baseURL + `/campaigns`, { ...params }); - } - - getCampaign(id: string, options?: GetCampaignOptions) { - this.validateServerSideAuth(); - return this.get< - { - campaign: CampaignResponse; - users: { - next?: string; - prev?: string; - }; - } & APIResponse - >(this.baseURL + `/campaigns/${encodeURIComponent(id)}`, { ...options?.users }); - } - - startCampaign(id: string, options?: { scheduledFor?: string; stopAt?: string }) { - this.validateServerSideAuth(); - return this.post< - { - campaign: CampaignResponse; - users: { - next?: string; - prev?: string; - }; - } & APIResponse - >(this.baseURL + `/campaigns/${encodeURIComponent(id)}/start`, { - scheduled_for: options?.scheduledFor, - stop_at: options?.stopAt, - }); - } - - /** - * queryCampaigns - Query Campaigns - * - * - * @return {Campaign[]} Campaigns - */ - async queryCampaigns( - filter: CampaignFilters, - sort?: CampaignSort, - options?: CampaignQueryOptions, - ) { - this.validateServerSideAuth(); - return await this.post< - { - campaigns: CampaignResponse[]; - next?: string; - prev?: string; - } & APIResponse - >(this.baseURL + `/campaigns/query`, { - filter, - sort, - ...(options || {}), - }); - } - - /** - * updateCampaign - Update a Campaign - * - * @param {string} id Campaign ID - * @param {Partial} params Campaign data - * - * @return {Campaign} Updated Campaign - */ - updateCampaign(id: string, params: Partial) { - this.validateServerSideAuth(); - return this.put<{ - campaign: CampaignResponse; - users: { - next?: string; - prev?: string; - }; - }>(this.baseURL + `/campaigns/${encodeURIComponent(id)}`, params); - } - - /** - * deleteCampaign - Delete a Campaign - * - * @param {string} id Campaign ID - * - * @return {Promise} The Server Response - */ - deleteCampaign(id: string) { - this.validateServerSideAuth(); - return this.delete( - this.baseURL + `/campaigns/${encodeURIComponent(id)}`, - ); - } - - /** - * stopCampaign - Stop a Campaign - * - * @param {string} id Campaign ID - * - * @return {Campaign} Stopped Campaign - */ - stopCampaign(id: string) { - this.validateServerSideAuth(); - return this.post<{ campaign: CampaignResponse }>( - this.baseURL + `/campaigns/${encodeURIComponent(id)}/stop`, - ); - } - - /** - * enrichURL - Get OpenGraph data of the given link - * - * @param {string} url link - * @return {OGAttachment} OG Attachment - */ - enrichURL(url: string) { - return this.get(this.baseURL + `/og`, { url }); - } - - /** - * getTask - Gets status of a long running task - * - * @param {string} id Task ID - * - * @return {TaskStatus} The task status - */ - getTask(id: string) { - return this.get( - `${this.baseURL}/tasks/${encodeURIComponent(id)}`, - ); - } - - /** - * deleteChannels - Deletes a list of channel - * - * @param {string[]} cids Channel CIDs - * @param {boolean} [options.hard_delete] Defines if the channel is hard deleted or not - * - * @return {DeleteChannelsResponse} Result of the soft deletion, if server-side, it holds the task ID as well - */ - async deleteChannels(cids: string[], options: { hard_delete?: boolean } = {}) { - return await this.post( - this.baseURL + `/channels/delete`, - { - cids, - ...options, - }, - ); - } - - /** - * deleteUsers - Batch Delete Users - * - * @param {string[]} user_ids which users to delete - * @param {DeleteUserOptions} options Configuration how to delete users - * - * @return {TaskResponse} A task ID - */ - async deleteUsers(user_ids: string[], options: DeleteUserOptions = {}) { - if ( - typeof options.user !== 'undefined' && - !['soft', 'hard', 'pruning'].includes(options.user) - ) { - throw new Error( - 'Invalid delete user options. user must be one of [soft hard pruning]', - ); - } - if ( - typeof options.conversations !== 'undefined' && - !['soft', 'hard'].includes(options.conversations) - ) { - throw new Error( - 'Invalid delete user options. conversations must be one of [soft hard]', - ); - } - if ( - typeof options.messages !== 'undefined' && - !['soft', 'hard', 'pruning'].includes(options.messages) - ) { - throw new Error( - 'Invalid delete user options. messages must be one of [soft hard pruning]', - ); - } - return await this.post(this.baseURL + `/users/delete`, { - user_ids, - ...options, - }); - } - - /** - * _createImportURL - Create an Import upload url. - * - * Note: Do not use this. - * It is present for internal usage only. - * This function can, and will, break and/or be removed at any point in time. - * - * @private - * @param {string} filename filename of uploaded data - * @return {APIResponse & CreateImportResponse} An ImportTask - */ - async _createImportURL(filename: string) { - return await this.post( - this.baseURL + `/import_urls`, - { - filename, - }, - ); - } - - /** - * _createImport - Create an Import Task. - * - * Note: Do not use this. - * It is present for internal usage only. - * This function can, and will, break and/or be removed at any point in time. - * - * @private - * @param {string} path path of uploaded data - * @param {CreateImportOptions} options import options - * @return {APIResponse & CreateImportResponse} An ImportTask - */ - async _createImport(path: string, options: CreateImportOptions = { mode: 'upsert' }) { - return await this.post( - this.baseURL + `/imports`, - { - path, - ...options, - }, - ); - } - - /** - * _getImport - Get an Import Task. - * - * Note: Do not use this. - * It is present for internal usage only. - * This function can, and will, break and/or be removed at any point in time. - * - * @private - * @param {string} id id of Import Task - * - * @return {APIResponse & GetImportResponse} An ImportTask - */ - async _getImport(id: string) { - return await this.get( - this.baseURL + `/imports/${encodeURIComponent(id)}`, - ); - } - - /** - * _listImports - Lists Import Tasks. - * - * Note: Do not use this. - * It is present for internal usage only. - * This function can, and will, break and/or be removed at any point in time. - * - * @private - * @param {ListImportsPaginationOptions} options pagination options - * - * @return {APIResponse & ListImportsResponse} An ImportTask - */ - async _listImports(options: ListImportsPaginationOptions) { - return await this.get( - this.baseURL + `/imports`, - options, - ); - } - - /** - * upsertPushProvider - Create or Update a push provider - * - * Note: Works only for v2 push version is enabled on app settings. - * - * @param {PushProviderConfig} configuration of the provider you want to create or update - * - * @return {APIResponse & PushProviderUpsertResponse} A push provider - */ - async upsertPushProvider(pushProvider: PushProviderConfig) { - return await this.post( - this.baseURL + `/push_providers`, - { - push_provider: pushProvider, - }, - ); - } - - /** - * deletePushProvider - Delete a push provider - * - * Note: Works only for v2 push version is enabled on app settings. - * - * @param {PushProviderID} type and foreign id of the push provider to be deleted - * - * @return {APIResponse} An API response - */ - async deletePushProvider({ type, name }: PushProviderID) { - return await this.delete( - this.baseURL + - `/push_providers/${encodeURIComponent(type)}/${encodeURIComponent(name)}`, - ); - } - - /** - * listPushProviders - Get all push providers in the app - * - * Note: Works only for v2 push version is enabled on app settings. - * - * @return {APIResponse & PushProviderListResponse} A push provider - */ - async listPushProviders() { - return await this.get( - this.baseURL + `/push_providers`, - ); - } - - /** - * creates an abort controller that will be used by the next HTTP Request. - */ - createAbortControllerForNextRequest() { - return (this.nextRequestAbortController = new AbortController()); - } - - /** - * commits a pending message, making it visible in the channel and for other users - * @param id the message id - * - * @return {APIResponse & MessageResponse} The message - */ - async commitMessage(id: string) { - return await this.post( - this.baseURL + `/messages/${encodeURIComponent(id)}/commit`, - ); - } - - /** - * Creates a poll - * @param poll PollData The poll that will be created - * @param userId string The user id (only serverside) - * @returns {APIResponse & CreatePollAPIResponse} The poll - */ - async createPoll(poll: CreatePollData, userId?: string) { - return await this.post(this.baseURL + `/polls`, { - ...poll, - ...(userId ? { user_id: userId } : {}), - }); - } - - /** - * Retrieves a poll - * @param id string The poll id - * @param userId string The user id (only serverside) - * @returns {APIResponse & GetPollAPIResponse} The poll - */ - async getPoll(id: string, userId?: string): Promise { - return await this.get( - this.baseURL + `/polls/${encodeURIComponent(id)}`, - userId ? { user_id: userId } : {}, - ); - } - - /** - * Updates a poll - * @param poll PollData The poll that will be updated - * @param userId string The user id (only serverside) - * @returns {APIResponse & PollResponse} The poll - */ - async updatePoll(poll: PollData, userId?: string) { - return await this.put(this.baseURL + `/polls`, { - ...poll, - ...(userId ? { user_id: userId } : {}), - }); - } - - /** - * Partially updates a poll - * @param id string The poll id - * @param {PartialPollUpdate} partialPollObject which should contain id and any of "set" or "unset" params; - * @param userId string The user id (only serverside) - * example: {id: "44f26af5-f2be-4fa7-9dac-71cf893781de", set:{field: value}, unset:["field2"]} - * @returns {APIResponse & UpdatePollAPIResponse} The poll - */ - async partialUpdatePoll( - id: string, - partialPollObject: PartialPollUpdate, - userId?: string, - ): Promise { - return await this.patch( - this.baseURL + `/polls/${encodeURIComponent(id)}`, - { - ...partialPollObject, - ...(userId ? { user_id: userId } : {}), - }, - ); - } - - /** - * Delete a poll - * @param id string The poll id - * @param userId string The user id (only serverside) - * @returns - */ - async deletePoll(id: string, userId?: string): Promise { - return await this.delete( - this.baseURL + `/polls/${encodeURIComponent(id)}`, - { - ...(userId ? { user_id: userId } : {}), - }, - ); - } - - /** - * Close a poll - * @param id string The poll id - * @param userId string The user id (only serverside) - * @returns {APIResponse & UpdatePollAPIResponse} The poll - */ - closePoll(id: string, userId?: string): Promise { - return this.partialUpdatePoll( - id, - { - set: { - is_closed: true, - } as PartialPollUpdate['set'], - }, - userId, - ); - } - - /** - * Creates a poll option - * @param pollId string The poll id - * @param option PollOptionData The poll option that will be created - * @param userId string The user id (only serverside) - * @returns {APIResponse & PollOptionResponse} The poll option - */ - async createPollOption(pollId: string, option: PollOptionData, userId?: string) { - return await this.post( - this.baseURL + `/polls/${encodeURIComponent(pollId)}/options`, - { - ...option, - ...(userId ? { user_id: userId } : {}), - }, - ); - } - - /** - * Retrieves a poll option - * @param pollId string The poll id - * @param optionId string The poll option id - * @param userId string The user id (only serverside) - * @returns {APIResponse & PollOptionResponse} The poll option - */ - async getPollOption(pollId: string, optionId: string, userId?: string) { - return await this.get( - this.baseURL + - `/polls/${encodeURIComponent(pollId)}/options/${encodeURIComponent(optionId)}`, - userId ? { user_id: userId } : {}, - ); - } - - /** - * Updates a poll option - * @param pollId string The poll id - * @param option PollOptionData The poll option that will be updated - * @param userId string The user id (only serverside) - * @returns - */ - async updatePollOption(pollId: string, option: PollOptionData, userId?: string) { - return await this.put( - this.baseURL + `/polls/${encodeURIComponent(pollId)}/options`, - { - ...option, - ...(userId ? { user_id: userId } : {}), - }, - ); - } - - /** - * Delete a poll option - * @param pollId string The poll id - * @param optionId string The poll option id - * @param userId string The user id (only serverside) - * @returns {APIResponse} The poll option - */ - async deletePollOption(pollId: string, optionId: string, userId?: string) { - return await this.delete( - this.baseURL + - `/polls/${encodeURIComponent(pollId)}/options/${encodeURIComponent(optionId)}`, - userId ? { user_id: userId } : {}, - ); - } - - /** - * Cast vote on a poll - * @param messageId string The message id - * @param pollId string The poll id - * @param vote PollVoteData The vote that will be casted - * @param userId string The user id (only serverside) - * @returns {APIResponse & CastVoteAPIResponse} The poll vote - */ - async castPollVote( - messageId: string, - pollId: string, - vote: PollVoteData, - userId?: string, - ) { - return await this.post( - this.baseURL + - `/messages/${encodeURIComponent(messageId)}/polls/${encodeURIComponent(pollId)}/vote`, - { - vote, - ...(userId ? { user_id: userId } : {}), - }, - ); - } - - /** - * Add a poll answer - * @param messageId string The message id - * @param pollId string The poll id - * @param answerText string The answer text - * @param userId string The user id (only serverside) - */ - addPollAnswer(messageId: string, pollId: string, answerText: string, userId?: string) { - return this.castPollVote( - messageId, - pollId, - { - answer_text: answerText, - }, - userId, - ); - } - - async removePollVote( - messageId: string, - pollId: string, - voteId: string, - userId?: string, - ) { - return await this.delete( - this.baseURL + - `/messages/${encodeURIComponent(messageId)}/polls/${encodeURIComponent(pollId)}/vote/${encodeURIComponent( - voteId, - )}`, - { - ...(userId ? { user_id: userId } : {}), - }, - ); - } - - /** - * Queries polls - * @param filter - * @param sort - * @param options Option object, {limit: 10, offset:0} - * @param userId string The user id (only serverside) - * @returns {APIResponse & QueryPollsResponse} The polls - */ - async queryPolls( - filter: QueryPollsFilters = {}, - sort: PollSort = [], - options: QueryPollsOptions = {}, - userId?: string, - ): Promise { - const q = userId ? `?user_id=${userId}` : ''; - return await this.post( - this.baseURL + `/polls/query${q}`, - { - filter, - sort: normalizeQuerySort(sort), - ...options, - }, - ); - } - - /** - * Queries poll votes - * @param pollId - * @param filter - * @param sort - * @param options Option object, {limit: 10, offset:0} - * @param userId string The user id (only serverside) - * @returns {APIResponse & PollVotesAPIResponse} The poll votes - */ - async queryPollVotes( - pollId: string, - filter: QueryVotesFilters = {}, - sort: VoteSort = [], - options: QueryVotesOptions = {}, - userId?: string, - ): Promise { - const q = userId ? `?user_id=${userId}` : ''; - return await this.post( - this.baseURL + `/polls/${encodeURIComponent(pollId)}/votes${q}`, - { - filter, - sort: normalizeQuerySort(sort), - ...options, - }, - ); - } - - /** - * Queries poll answers - * @param pollId - * @param filter - * @param sort - * @param options Option object, {limit: 10, offset:0} - * @param userId string The user id (only serverside) - * @returns {APIResponse & PollAnswersAPIResponse} The poll votes - */ - async queryPollAnswers( - pollId: string, - filter: QueryVotesFilters = {}, - sort: VoteSort = [], - options: QueryVotesOptions = {}, - userId?: string, - ): Promise { - const q = userId ? `?user_id=${userId}` : ''; - return await this.post( - this.baseURL + `/polls/${encodeURIComponent(pollId)}/votes${q}`, - { - filter: { ...filter, is_answer: true }, - sort: normalizeQuerySort(sort), - ...options, - }, - ); - } - - /** - * Query message history - * @param filter - * @param sort - * @param options Option object, {limit: 10} - * @returns {APIResponse & QueryMessageHistoryResponse} The message histories - */ - async queryMessageHistory( - filter: QueryMessageHistoryFilters = {}, - sort: QueryMessageHistorySort = [], - options: QueryMessageHistoryOptions = {}, - ): Promise { - return await this.post( - this.baseURL + '/messages/history', - { - filter, - sort: normalizeQuerySort(sort), - ...options, - }, - ); - } - - /** - * updateFlags - reviews/unflags flagged message - * - * @param {string[]} message_ids list of message IDs - * @param {string} options Option object in case user ID is set to review all the flagged messages by the user - * @param {string} reviewed_by user ID who reviewed the flagged message - * @returns {APIResponse} - */ - async updateFlags( - message_ids: string[], - reviewed_by: string, - options: { user_id?: string } = {}, - ) { - return await this.post( - this.baseURL + '/automod/v1/moderation/update_flags', - { - message_ids, - reviewed_by, - ...options, - }, - ); - } - - /** - * queryDrafts - Queries drafts for the current user - * - * @param {object} [options] Query options - * @param {object} [options.filter] Filters for the query - * @param {number} [options.sort] Sort parameters - * @param {number} [options.limit] Limit the number of results - * @param {string} [options.next] Pagination parameter - * @param {string} [options.prev] Pagination parameter - * @param {string} [options.user_id] Has to be provided when called server-side - * - * @return {Promise} Response containing the drafts - */ - async queryDrafts( - options: Pager & { - filter?: DraftFilters; - sort?: DraftSort; - user_id?: string; - } = {}, - ) { - const payload = { - ...options, - sort: options.sort ? normalizeQuerySort(options.sort) : undefined, - }; - - return await this.post(this.baseURL + '/drafts/query', payload); - } - - /** - * createReminder - Creates a reminder for a message - * - * @param {CreateReminderOptions} options The options for creating the reminder - * @returns {Promise} - */ - async createReminder({ messageId, ...options }: CreateReminderOptions) { - return await this.post( - `${this.baseURL}/messages/${messageId}/reminders`, - options, - ); - } - - /** - * updateReminder - Updates an existing reminder for a message - * - * @param {UpdateReminderOptions} options The options for updating the reminder - * @returns {Promise} - */ - async updateReminder({ messageId, ...options }: UpdateReminderOptions) { - return await this.patch( - `${this.baseURL}/messages/${messageId}/reminders`, - options, - ); - } - - /** - * deleteReminder - Deletes a reminder for a message - * - * @param {string} messageId The ID of the message whose reminder to delete - * @param {string} [userId] Optional user ID, required for server-side operations - * @returns {Promise} - */ - async deleteReminder(messageId: string, userId?: string): Promise { - return await this.delete( - `${this.baseURL}/messages/${messageId}/reminders`, - userId ? { user_id: userId } : {}, - ); - } - - /** - * queryReminders - Queries reminders based on given filters - * - * @param {QueryRemindersOptions} options The options for querying reminders - * @returns {Promise} - */ - async queryReminders({ filter, sort, ...rest }: QueryRemindersOptions = {}) { - return await this.post(`${this.baseURL}/reminders/query`, { - filter, - sort: sort && normalizeQuerySort(sort), - ...rest, - }); - } - - /** - * queryTeamUsageStats - Queries team-level usage statistics from the warehouse database - * - * Returns all 16 metrics grouped by team with cursor-based pagination. - * - * Date Range Options (mutually exclusive): - * - Use 'month' parameter (YYYY-MM format) for monthly aggregated values - * - Use 'start_date'/'end_date' parameters (YYYY-MM-DD format) for daily breakdown - * - If neither provided, defaults to current month (monthly mode) - * - * This endpoint is server-side only. - * - * @param {QueryTeamUsageStatsOptions} options The options for querying team usage stats - * @returns {Promise} - */ - async queryTeamUsageStats(options: QueryTeamUsageStatsOptions = {}) { - return await this.post( - `${this.baseURL}/stats/team_usage`, - options, - ); - } - - /** - * updateLocation - Updates a location - * - * @param location SharedLocationRequest the location data to update - * - * @returns {Promise} The server response - */ - async updateLocation(location: UpdateLocationPayload) { - return await this.put( - this.baseURL + `/users/live_locations`, - location, - ); - } - - /** - * uploadFile - Uploads a file to the configured storage (defaults to Stream CDN) - * - * @param {string|NodeJS.ReadableStream|Buffer|File} uri The file to upload - * @param {string} [name] The name of the file - * @param {string} [contentType] The content type of the file - * @param {UserResponse} [user] Optional user information - * @param {AxiosRequestConfig} [axiosRequestConfig] Optional axios config (e.g. onUploadProgress for progress tracking) - * - * @return {Promise} Response containing the file URL - */ - uploadFile( - uri: string | NodeJS.ReadableStream | Buffer | File, - name?: string, - contentType?: string, - user?: UserResponse, - axiosRequestConfig?: AxiosRequestConfig, - ) { - return this.sendFile( - `${this.baseURL}/uploads/file`, - uri, - name, - contentType, - user, - axiosRequestConfig, - ); - } - - /** - * uploadImage - Uploads an image to the configured storage (defaults to Stream CDN) - * - * @param {string|NodeJS.ReadableStream|File} uri The image to upload - * @param {string} [name] The name of the image - * @param {string} [contentType] The content type of the image - * @param {UserResponse} [user] Optional user information - * @param {AxiosRequestConfig} [axiosRequestConfig] Optional axios config (e.g. onUploadProgress for progress tracking) - * - * @return {Promise} Response containing the image URL - */ - uploadImage( - uri: string | NodeJS.ReadableStream | File, - name?: string, - contentType?: string, - user?: UserResponse, - axiosRequestConfig?: AxiosRequestConfig, - ) { - return this.sendFile( - `${this.baseURL}/uploads/image`, - uri, - name, - contentType, - user, - axiosRequestConfig, - ); - } - - /** - * deleteFile - Deletes a file from the configured storage - * - * @param {string} url The URL of the file to delete - * - * @return {Promise} The server response - */ - deleteFile(url: string) { - return this.delete(`${this.baseURL}/uploads/file`, { url }); - } - - /** - * deleteImage - Deletes an image from the configured storage - * - * @param {string} url The URL of the image to delete - * - * @return {Promise} The server response - */ - deleteImage(url: string) { - return this.delete(`${this.baseURL}/uploads/image`, { url }); - } - - /** - * Mark the channels delivered for the given messages and the user - * - * @param {MarkDeliveredOptions} data - * @return {Promise} Description - */ - async markChannelsDelivered(data: MarkDeliveredOptions) { - if (!data?.latest_delivered_messages?.length) return; - return await this.post(this.baseURL + '/channels/delivered', data); + async markChannelsDelivered(request?: Gen_MarkDeliveredRequest) { + if (!request?.latest_delivered_messages?.length) return; + return await this.markDelivered(request); } syncDeliveredCandidates(collections: Channel[]) { this.messageDeliveryReporter.syncDeliveredCandidates(collections); } - - /** - * Update Channels Batch - * - * @param {UpdateChannelsBatchOptions} payload for updating channels in batch - * @return {Promise} The server response - */ - async updateChannelsBatch(payload: UpdateChannelsBatchOptions) { - return await this.put( - this.baseURL + `/channels/batch`, - payload, - ); - } - - /** - * createPredefinedFilter - Creates a new predefined filter (server-side only) - * - * @param {CreatePredefinedFilterOptions} options Predefined filter options - * - * @return {Promise} The created predefined filter - */ - async createPredefinedFilter< - F extends Record = Record, - >(options: CreatePredefinedFilterOptions) { - this.validateServerSideAuth(); - return await this.post>( - `${this.baseURL}/predefined_filters`, - options, - ); - } - - /** - * getPredefinedFilter - Gets a predefined filter by name (server-side only) - * - * @param {string} name Predefined filter name - * - * @return {Promise} The predefined filter - */ - async getPredefinedFilter = Record>( - name: string, - ) { - this.validateServerSideAuth(); - return await this.get>( - `${this.baseURL}/predefined_filters/${encodeURIComponent(name)}`, - ); - } - - /** - * updatePredefinedFilter - Updates a predefined filter (server-side only) - * - * @param {string} name Predefined filter name - * @param {UpdatePredefinedFilterOptions} options Predefined filter options - * - * @return {Promise} The updated predefined filter - */ - async updatePredefinedFilter< - F extends Record = Record, - >(name: string, options: UpdatePredefinedFilterOptions) { - this.validateServerSideAuth(); - return await this.put>( - `${this.baseURL}/predefined_filters/${encodeURIComponent(name)}`, - options, - ); - } - - /** - * deletePredefinedFilter - Deletes a predefined filter (server-side only) - * - * @param {string} name Predefined filter name - * - * @return {Promise} The server response - */ - async deletePredefinedFilter(name: string) { - this.validateServerSideAuth(); - return await this.delete( - `${this.baseURL}/predefined_filters/${encodeURIComponent(name)}`, - ); - } - - /** - * listPredefinedFilters - Lists all predefined filters (server-side only) - * - * @param {ListPredefinedFiltersOptions} options Query options - * - * @return {Promise} The list of predefined filters - */ - async listPredefinedFilters< - F extends Record = Record, - >(options: ListPredefinedFiltersOptions = {}) { - this.validateServerSideAuth(); - const { sort, ...paginationOptions } = options; - return await this.get>( - `${this.baseURL}/predefined_filters`, - { - ...paginationOptions, - ...(sort ? { sort: JSON.stringify(sort) } : {}), - }, - ); - } - - /** - * setRetentionPolicy - Creates or updates a retention policy for the app. - * Server-side only. - * - * @param {string} policy The policy type ('old-messages' or 'inactive-channels') - * @param {number} maxAgeHours Max age in hours (24-43800) - * @returns {Promise} - */ - async setRetentionPolicy(policy: string, maxAgeHours: number) { - this.validateServerSideAuth(); - return await this.post( - this.baseURL + '/retention_policy', - { policy, max_age_hours: maxAgeHours }, - ); - } - - /** - * deleteRetentionPolicy - Deletes a retention policy for the app. - * Server-side only. - * - * @param {string} policy The policy type ('old-messages' or 'inactive-channels') - * @returns {Promise} - */ - async deleteRetentionPolicy(policy: string) { - this.validateServerSideAuth(); - return await this.post( - this.baseURL + '/retention_policy/delete', - { policy }, - ); - } - - /** - * getRetentionPolicy - Returns all retention policies configured for the app. - * Server-side only. - * - * @returns {Promise} - */ - async getRetentionPolicy() { - this.validateServerSideAuth(); - return await this.get(this.baseURL + '/retention_policy'); - } - - /** - * getRetentionPolicyRuns - Returns filtered and sorted retention cleanup run history. - * Supports filter_conditions on 'policy' and 'date' fields. - * Server-side only. - * - * @param {GetRetentionPolicyRunsOptions} options Filter, sort, and pagination options - * @returns {Promise} - */ - async getRetentionPolicyRuns(options: GetRetentionPolicyRunsOptions = {}) { - this.validateServerSideAuth(); - return await this.post( - this.baseURL + '/retention_policy/runs', - options, - ); - } } diff --git a/src/client_state.ts b/src/client_state.ts index 2bcf1a3cbd..76c412caa0 100644 --- a/src/client_state.ts +++ b/src/client_state.ts @@ -1,13 +1,13 @@ -import type { UserResponse } from './types'; +import type { OwnUserResponse, UserResponse } from './types'; import type { StreamChat } from './client'; /** - * ClientState - A container class for the client state. + * Container class for the client state. */ export class ClientState { private client: StreamChat; users: { - [key: string]: UserResponse; + [key: string]: UserResponse | OwnUserResponse; }; userChannelReferences: { [key: string]: { [key: string]: boolean } }; constructor({ client }: { client: StreamChat }) { @@ -25,7 +25,7 @@ export class ClientState { } } - updateUser(user?: UserResponse) { + updateUser(user?: UserResponse | OwnUserResponse) { if (user != null && this.client._cacheEnabled()) { this.users[user.id] = user; } diff --git a/src/connection.ts b/src/connection.ts index 567ad0f787..6ad1de09c0 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -13,9 +13,14 @@ import { buildWsSuccessAfterFailureInsight, postInsights, } from './insights'; -import type { ConnectAPIResponse, ConnectionOpen, LogLevel, UR } from './types'; +import { chatLoggerSystem } from './logger'; +import type { ConnectAPIResponse, ConnectionOpen, EventPayload } from './types'; import type { StreamChat } from './client'; import type { APIError } from './errors'; +import { decodeWSEvent } from './gen/model-decoders/event-decoder-mapping'; +import type { WSEvent } from './gen/models'; + +const logger = chatLoggerSystem.getLogger('connection'); // Type guards to check WebSocket error type const isCloseEvent = ( @@ -27,15 +32,16 @@ const isErrorEvent = ( ): res is WebSocket.ErrorEvent => (res as WebSocket.ErrorEvent).error !== undefined; /** - * StableWSConnection - A WS connection that reconnects upon failure. + * A WS connection that reconnects upon failure. + * * - the browser will sometimes report that you're online or offline * - the WS connection can break and fail (there is a 30s health check) * - sometimes your WS connection will seem to work while the user is in fact offline - * - to speed up online/offline detection you can use the window.addEventListener('offline'); + * - to speed up online/offline detection you can use the `window.addEventListener('offline')` * * There are 4 ways in which a connection can become unhealthy: - * - websocket.onerror is called - * - websocket.onclose is called + * - WebSocket.onerror is called + * - WebSocket.onclose is called * - the health check fails and no event is received for ~40 seconds * - the browser indicates the connection is now offline * @@ -99,18 +105,15 @@ export class StableWSConnection { addConnectionEventListeners(this.onlineStatusChanged); } - _log(msg: string, extra: UR = {}, level: LogLevel = 'info') { - this.client.logger(level, 'connection:' + msg, { tags: ['connection'], ...extra }); - } - setClient(client: StreamChat) { this.client = client; } /** - * connect - Connect to the WS URL - * the default 15s timeout allows between 2~3 tries - * @return {ConnectAPIResponse} Promise that completes once the first health check message is received + * Connects to the WS URL. The default 15s timeout allows between 2 and 3 tries. + * + * @param timeout - Connect timeout in milliseconds (optional, defaults to `15000`). + * @returns A promise that resolves once the first health check message is received. */ async connect(timeout = 15000) { if (this.isConnecting) { @@ -125,8 +128,9 @@ export class StableWSConnection { const healthCheck = await this._connect(); this.consecutiveFailures = 0; - this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any + logger + .withExtraTags('connect') + .info(`Established a WebSocket connection. Health check: ${healthCheck}.`); } catch (error: any) { this.isHealthy = false; this.consecutiveFailures += 1; @@ -134,9 +138,11 @@ export class StableWSConnection { const e = error as APIError; if (e.code === chatCodes.TOKEN_EXPIRED && !this.client.tokenManager.isStatic()) { - this._log( - 'connect() - WS failure due to expired token, so going to try to reload token and reconnect', - ); + logger + .withExtraTags('connect') + .warn( + 'WebSocket connection failed due to an expired token. Reloading the token and reconnecting.', + ); this._reconnect({ refreshToken: true }); } else if (!e.isWSFailure) { // API rejected the connection and we should not retry @@ -157,7 +163,8 @@ export class StableWSConnection { /** * _waitForHealthy polls the promise connection to see if its resolved until it times out * the default 15s timeout allows between 2~3 tries - * @param timeout duration(ms) + * + * @param timeout - duration (ms) */ _waitForHealthy(timeout = 15000) { return Promise.race([ @@ -166,7 +173,6 @@ export class StableWSConnection { for (let i = 0; i <= timeout; i += interval) { try { return await this.connectionOpen; - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { if (i === timeout) { throw new Error( @@ -198,7 +204,8 @@ export class StableWSConnection { } /** - * Builds and returns the url for websocket. + * Builds and returns the URL for the WebSocket connection. + * * @private * @returns url string */ @@ -220,11 +227,14 @@ export class StableWSConnection { }; /** - * disconnect - Disconnect the connection and doesn't recover... + * Disconnects the connection without attempting to recover. * + * @param timeout - Optional timeout in milliseconds to wait for the close frame from the server. */ disconnect(timeout?: number) { - this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`); + logger + .withExtraTags('disconnect') + .info(`Closing the WebSocket connection for wsID ${this.wsID}.`); this.wsID += 1; this.isConnecting = false; @@ -255,29 +265,33 @@ export class StableWSConnection { if (ws && ws.close && ws.readyState === ws.OPEN) { isClosedPromise = new Promise((resolve) => { const onclose = (event: WebSocket.CloseEvent) => { - this._log( - `disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, - { event }, - ); + logger + .withExtraTags('disconnect') + .debug( + `Resolving the close promise ${event ? 'with' : 'without'} a close frame.`, + { event }, + ); resolve(); }; ws.onclose = onclose; - // In case we don't receive close frame websocket server in time, + // In case we don't receive a close frame from the WebSocket server in time, // lets not wait for more than 1 seconds. setTimeout(onclose, timeout != null ? timeout : 1000); }); - this._log( - `disconnect() - Manually closed connection by calling client.disconnect()`, - ); + logger + .withExtraTags('disconnect') + .debug('Manually closing the connection via client.disconnect().'); ws.close( chatCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()', ); } else { - this._log(`disconnect() - ws connection doesn't exist or it is already closed.`); + logger + .withExtraTags('disconnect') + .debug('The WebSocket connection does not exist or is already closed.'); isClosedPromise = Promise.resolve(); } @@ -287,9 +301,9 @@ export class StableWSConnection { } /** - * _connect - Connect to the WS endpoint + * Connects to the WS endpoint. * - * @return {ConnectAPIResponse} Promise that completes once the first health check message is received + * @returns A promise that resolves once the first health check message is received. */ async _connect() { if ( @@ -302,7 +316,7 @@ export class StableWSConnection { this.client.insightMetrics.connectionStartTimestamp = new Date().getTime(); let isTokenReady = false; try { - this._log(`_connect() - waiting for token`); + logger.withExtraTags('_connect').debug('Waiting for the auth token.'); await this.client.tokenManager.tokenReady(); isTokenReady = true; } catch (e) { @@ -311,13 +325,15 @@ export class StableWSConnection { try { if (!isTokenReady) { - this._log(`_connect() - tokenProvider failed before, so going to retry`); + logger + .withExtraTags('_connect') + .warn('The token provider failed previously. Retrying.'); await this.client.tokenManager.loadToken(); } this._setupConnectionPromise(); const wsURL = this._buildUrl(); - this._log(`_connect() - Connecting to ${wsURL}`, { + logger.withExtraTags('_connect').info(`Connecting to ${wsURL}.`, { wsURL, requestID: this.requestID, }); @@ -343,10 +359,11 @@ export class StableWSConnection { } return response; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.isConnecting = false; - this._log(`_connect() - Error - `, error); + logger + .withExtraTags('_connect') + .warn('An error occurred while connecting.', { error }); if (this.client.options.enableInsights) { this.client.insightMetrics.wsConsecutiveFailures++; this.client.insightMetrics.wsTotalFailures++; @@ -362,21 +379,22 @@ export class StableWSConnection { } /** - * _reconnect - Retry the connection to WS endpoint - * - * @param {{ interval?: number; refreshToken?: boolean }} options Following options are available + * Retries the connection to the WS endpoint. * - * - `interval` {int} number of ms that function should wait before reconnecting - * - `refreshToken` {boolean} reload/refresh user token be refreshed before attempting reconnection. + * @param options - Reconnect options. + * @param options.interval - Number of milliseconds to wait before reconnecting. + * @param options.refreshToken - Reload/refresh the user token before attempting to reconnect. */ async _reconnect( options: { interval?: number; refreshToken?: boolean } = {}, ): Promise { - this._log('_reconnect() - Initiating the reconnect'); + logger.withExtraTags('_reconnect').info('Initiating a reconnect.'); // only allow 1 connection at the time if (this.isConnecting || this.isHealthy) { - this._log('_reconnect() - Abort (1) since already connecting or healthy'); + logger + .withExtraTags('_reconnect') + .debug('Aborting reconnect: already connecting or healthy (check 1).'); return; } @@ -392,16 +410,22 @@ export class StableWSConnection { // Check once again if by some other call to _reconnect is active or connection is // already restored, then no need to proceed. if (this.isConnecting || this.isHealthy) { - this._log('_reconnect() - Abort (2) since already connecting or healthy'); + logger + .withExtraTags('_reconnect') + .debug('Aborting reconnect: already connecting or healthy (check 2).'); return; } if (this.isDisconnected && this.client.options.enableWSFallback) { - this._log('_reconnect() - Abort (3) since disconnect() is called'); + logger + .withExtraTags('_reconnect') + .debug('Aborting reconnect: disconnect() was called.'); return; } - this._log('_reconnect() - Destroying current WS connection'); + logger + .withExtraTags('_reconnect') + .info('Destroying the current WebSocket connection.'); // cleanup the old connection this._destroyCurrentWSConnection(); @@ -412,12 +436,11 @@ export class StableWSConnection { try { await this._connect(); - this._log('_reconnect() - Waiting for recoverCallBack'); + logger.withExtraTags('_reconnect').debug('Waiting for the recover callback.'); await this.client.recoverState(); - this._log('_reconnect() - Finished recoverCallBack'); + logger.withExtraTags('_reconnect').debug('Finished the recover callback.'); this.consecutiveFailures = 0; - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.isHealthy = false; this.consecutiveFailures += 1; @@ -425,60 +448,71 @@ export class StableWSConnection { error.code === chatCodes.TOKEN_EXPIRED && !this.client.tokenManager.isStatic() ) { - this._log( - '_reconnect() - WS failure due to expired token, so going to try to reload token and reconnect', - ); + logger + .withExtraTags('_reconnect') + .warn( + 'WebSocket connection failed due to an expired token. Reloading the token and reconnecting.', + ); return this._reconnect({ refreshToken: true }); } // reconnect on WS failures, don't reconnect if there is a code bug if (error.isWSFailure) { - this._log('_reconnect() - WS failure, so going to try to reconnect'); + logger + .withExtraTags('_reconnect') + .warn('WebSocket connection failed. Retrying the reconnect.'); this._reconnect(); } } - this._log('_reconnect() - == END =='); + logger.withExtraTags('_reconnect').debug('Reconnect attempt finished.'); } /** - * onlineStatusChanged - this function is called when the browser connects or disconnects from the internet. - * - * @param {Event} event Event with type online or offline + * Called when the browser connects or disconnects from the internet. * + * @param event - The DOM event whose `type` is `'online'` or `'offline'`. */ onlineStatusChanged = (event: Event) => { if (event.type === 'offline') { // mark the connection as down - this._log('onlineStatusChanged() - Status changing to offline'); + logger + .withExtraTags('onlineStatusChanged') + .info('Network status changed to offline.'); this._setHealth(false); } else if (event.type === 'online') { // retry right now... // We check this.isHealthy, not sure if it's always // smart to create a new WS connection if the old one is still up and running. // it's possible we didn't miss any messages, so this process is just expensive and not needed. - this._log( - `onlineStatusChanged() - Status changing to online. isHealthy: ${this.isHealthy}`, - ); + logger + .withExtraTags('onlineStatusChanged') + .info(`Network status changed to online. isHealthy: ${this.isHealthy}.`); if (!this.isHealthy) { this._reconnect({ interval: 10 }); } } }; - onopen = (wsID: number) => { - if (this.wsID !== wsID) return; + onopen = (wsId: number) => { + if (this.wsID !== wsId) return; - this._log('onopen() - onopen callback', { wsID }); + logger.withExtraTags('onopen').debug('WebSocket onopen callback fired.', { + wsID: wsId, + }); }; - onmessage = (wsID: number, event: WebSocket.MessageEvent) => { - if (this.wsID !== wsID) return; + onmessage = (wsId: number, event: WebSocket.MessageEvent) => { + if (this.wsID !== wsId) return; - this._log('onmessage() - onmessage callback', { event, wsID }); + logger.withExtraTags('onmessage').trace('WebSocket onmessage callback fired.', { + event, + wsID: wsId, + }); if (typeof event.data !== 'string') return; const data = JSON.parse(event.data); + const decodedData = decodeWSEvent(data) as WSEvent; // we wait till the first message before we consider the connection open.. // the reason for this is that auth errors and similar errors trigger a ws.onopen and immediately @@ -490,7 +524,7 @@ export class StableWSConnection { return; } - this.resolvePromise?.(data); + this.resolvePromise?.(decodedData as EventPayload<'health.check'>); this._setHealth(true); } @@ -501,14 +535,19 @@ export class StableWSConnection { this.scheduleNextPing(); } - this.client.dispatchEvent(data); + this.client.dispatchEvent(decodedData); this.scheduleConnectionCheck(); }; - onclose = (wsID: number, event: WebSocket.CloseEvent) => { - if (this.wsID !== wsID) return; + onclose = (wsId: number, event: WebSocket.CloseEvent) => { + if (this.wsID !== wsId) return; - this._log('onclose() - onclose callback - ' + event.code, { event, wsID }); + logger + .withExtraTags('onclose') + .debug(`WebSocket onclose callback fired with code ${event.code}.`, { + event, + wsID: wsId, + }); if (event.code === chatCodes.WS_CLOSED_SUCCESS) { // this is a permanent error raised by stream.. @@ -523,7 +562,9 @@ export class StableWSConnection { error.target = event.target; this.rejectPromise?.(error); - this._log(`onclose() - WS connection reject with error ${event.reason}`, { event }); + logger + .withExtraTags('onclose') + .warn(`The WebSocket connection was rejected: ${event.reason}.`, { event }); } else { this.consecutiveFailures += 1; this.totalFailures += 1; @@ -532,15 +573,19 @@ export class StableWSConnection { this.rejectPromise?.(this._errorFromWSEvent(event)); - this._log(`onclose() - WS connection closed. Calling reconnect ...`, { event }); + logger + .withExtraTags('onclose') + .warn('The WebSocket connection was closed. Attempting to reconnect.', { + event, + }); // reconnect if its an abnormal failure this._reconnect(); } }; - onerror = (wsID: number, event: WebSocket.ErrorEvent) => { - if (this.wsID !== wsID) return; + onerror = (wsId: number, event: WebSocket.ErrorEvent) => { + if (this.wsID !== wsId) return; this.consecutiveFailures += 1; this.totalFailures += 1; @@ -548,17 +593,17 @@ export class StableWSConnection { this.isConnecting = false; this.rejectPromise?.(this._errorFromWSEvent(event)); - this._log(`onerror() - WS connection resulted into error`, { event }); + logger + .withExtraTags('onerror') + .warn('The WebSocket connection raised an error.', { event }); this._reconnect(); }; /** - * _setHealth - Sets the connection to healthy or unhealthy. - * Broadcasts an event in case the connection status changed. - * - * @param {boolean} healthy boolean indicating if the connection is healthy or not + * Sets the connection to healthy or unhealthy. Broadcasts an event if the connection status changed. * + * @param healthy - Whether the connection is healthy. */ _setHealth = (healthy: boolean) => { if (healthy === this.isHealthy) return; @@ -578,8 +623,11 @@ export class StableWSConnection { }; /** - * _errorFromWSEvent - Creates an error object for the WS event + * Creates an error object for the WS event. * + * @param event - The raw WebSocket close / data / error event. + * @param isWSFailure - Whether the underlying cause is a WebSocket failure (optional, defaults to `true`). + * @returns A normalized error describing the WS failure. */ _errorFromWSEvent = ( event: WebSocket.CloseEvent | WebSocket.Data | WebSocket.ErrorEvent, @@ -601,7 +649,9 @@ export class StableWSConnection { } // Keeping this `warn` level log, to avoid cluttering of error logs from ws failures. - this._log(`_errorFromWSEvent() - WS failed with code ${code}`, { event }, 'warn'); + logger + .withExtraTags('_errorFromWSEvent') + .warn(`The WebSocket failed with code ${code}.`, { event }); const error = new Error( `WS failed with code ${code} and reason - ${message}`, @@ -621,8 +671,7 @@ export class StableWSConnection { }; /** - * _destroyCurrentWSConnection - Removes the current WS connection - * + * Removes the current WS connection. */ _destroyCurrentWSConnection() { // increment the ID, meaning we will ignore all messages from the old @@ -638,7 +687,7 @@ export class StableWSConnection { } /** - * _setupPromise - sets up the this.connectOpen promise + * Sets up the `this.connectionOpen` promise. */ _setupConnectionPromise = () => { this.isResolved = false; @@ -650,7 +699,7 @@ export class StableWSConnection { }; /** - * Schedules a next health check ping for websocket. + * Schedules the next health check ping for the WebSocket connection. */ scheduleNextPing = () => { if (this.healthCheckTimeoutRef) { @@ -660,7 +709,7 @@ export class StableWSConnection { // 30 seconds is the recommended interval (messenger uses this) this.healthCheckTimeoutRef = setTimeout(() => { // send the healthcheck.., server replies with a health check event - const data = [{ type: 'health.check', client_id: this.client.clientID }]; + const data = [{ type: 'health.check', client_id: this.client.clientId }]; // try to send on the connection try { this.ws?.send(JSON.stringify(data)); @@ -671,9 +720,9 @@ export class StableWSConnection { }; /** - * scheduleConnectionCheck - schedules a check for time difference between last received event and now. - * If the difference is more than 35 seconds, it means our health check logic has failed and websocket needs - * to be reconnected. + * Schedules a check for the time difference between the last received event and now. If the + * difference is more than 35 seconds, it means our health check logic has failed and the + * WebSocket needs to be reconnected. */ scheduleConnectionCheck = () => { if (this.connectionCheckTimeoutRef) { @@ -686,7 +735,9 @@ export class StableWSConnection { this.lastEvent && now.getTime() - this.lastEvent.getTime() > this.connectionCheckTimeout ) { - this._log('scheduleConnectionCheck - going to reconnect'); + logger + .withExtraTags('scheduleConnectionCheck') + .warn('No events received within the health-check window. Reconnecting.'); this._setHealth(false); this._reconnect(); } diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index c0552b1a18..98c8864aa3 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -8,7 +8,11 @@ import { sleep, } from './utils'; import { isAPIError, isConnectionIDError, isErrorRetryable } from './errors'; -import type { ConnectionOpen, Event, LogLevel, UR } from './types'; +import { chatLoggerSystem } from './logger'; +import type { ConnectionOpen, UR } from './types'; +import type { WSEvent } from './gen/models'; + +const logger = chatLoggerSystem.getLogger('connection-fallback'); export enum ConnectionState { Closed = 'CLOSED', @@ -33,15 +37,8 @@ export class WSConnectionFallback { addConnectionEventListeners(this._onlineStatusChanged); } - _log(msg: string, extra: UR = {}, level: LogLevel = 'info') { - this.client.logger(level, 'WSConnectionFallback:' + msg, { - tags: ['connection_fallback', 'connection'], - ...extra, - }); - } - _setState(state: ConnectionState) { - this._log(`_setState() - ${state}`); + logger.withExtraTags('_setState').debug(`Transitioning to state: ${state}.`); // transition from connecting => connected if ( @@ -60,7 +57,9 @@ export class WSConnectionFallback { /** @private */ _onlineStatusChanged = (event: { type: string }) => { - this._log(`_onlineStatusChanged() - ${event.type}`); + logger + .withExtraTags('_onlineStatusChanged') + .info(`Network status changed to ${event.type}.`); if (event.type === 'offline') { this._setState(ConnectionState.Closed); @@ -85,24 +84,22 @@ export class WSConnectionFallback { } try { - const res = await this.client.doAxiosRequest( + const res = await this.client.api.doAxiosRequest( 'get', (this.client.baseURL as string).replace(':3030', ':8900') + '/longpoll', // replace port if present for testing with local API undefined, - { - config: { ...config, cancelToken: this.cancelToken?.token }, - params, - }, + { ...config, cancelToken: this.cancelToken?.token, params }, ); this.consecutiveFailures = 0; // always reset in case of no error return res; - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.consecutiveFailures += 1; if (retry && isErrorRetryable(error)) { - this._log(`_req() - Retryable error, retrying request`); + logger + .withExtraTags('_req') + .debug('Encountered a retryable error. Retrying the request.'); await sleep(retryInterval(this.consecutiveFailures)); return this._req(params, config, retry); } @@ -116,7 +113,7 @@ export class WSConnectionFallback { while (this.state === ConnectionState.Connected) { try { const data = await this._req<{ - events: Event[]; + events: WSEvent[]; }>({}, { timeout: 30000 }, true); // 30s => API responds in 20s if there is no event if (data.events?.length) { @@ -124,17 +121,18 @@ export class WSConnectionFallback { this.client.dispatchEvent(data.events[i]); } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { if (axios.isCancel(error)) { - this._log(`_poll() - axios canceled request`); + logger.withExtraTags('_poll').debug('Axios canceled the request.'); return; } /** client.doAxiosRequest will take care of TOKEN_EXPIRED error */ if (isConnectionIDError(error)) { - this._log(`_poll() - ConnectionID error, connecting without ID...`); + logger + .withExtraTags('_poll') + .warn('Received a connection ID error. Reconnecting without an ID.'); this._setState(ConnectionState.Disconnected); this.connect(true); return; @@ -152,15 +150,20 @@ export class WSConnectionFallback { /** * connect try to open a longpoll request - * @param reconnect should be false for first call and true for subsequent calls to keep the connection alive and call recoverState + * + * @param reconnect - should be false for first call and true for subsequent calls to keep the connection alive and call recoverState */ connect = async (reconnect = false) => { if (this.state === ConnectionState.Connecting) { - this._log('connect() - connecting already in progress', { reconnect }, 'warn'); + logger + .withExtraTags('connect') + .warn('A connection attempt is already in progress.', { reconnect }); return; } if (this.state === ConnectionState.Connected) { - this._log('connect() - already connected and polling', { reconnect }, 'warn'); + logger + .withExtraTags('connect') + .warn('Already connected and polling.', { reconnect }); return; } @@ -175,7 +178,6 @@ export class WSConnectionFallback { this._setState(ConnectionState.Connected); this.connectionID = event.connection_id; - // @ts-expect-error type mismatch this.client.dispatchEvent(event); this._poll(); if (reconnect) { @@ -205,9 +207,9 @@ export class WSConnectionFallback { try { await this._req({ close: true, connection_id }, { timeout }, false); - this._log(`disconnect() - Closed connectionID`); + logger.withExtraTags('disconnect').info('Closed the connection ID.'); } catch (err) { - this._log(`disconnect() - Failed`, { err }, 'error'); + logger.withExtraTags('disconnect').error('Disconnect failed.', { error: err }); } }; } diff --git a/src/constants.ts b/src/constants.ts index a503997efb..15515100cc 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -19,7 +19,7 @@ export const RESERVED_UPDATED_MESSAGE_FIELDS = Object.freeze({ own_reactions: true, reaction_counts: true, reply_count: true, - // Message text related fields that shouldn't be in update + // MessageRequest text related fields that shouldn't be in update i18n: true, type: true, html: true, diff --git a/src/custom_types.ts b/src/custom_types.ts index b787867de9..713bff1fca 100644 --- a/src/custom_types.ts +++ b/src/custom_types.ts @@ -1,4 +1,7 @@ -export interface CustomAttachmentData {} +export interface CustomAttachmentData { + mime_type?: string; + file_size?: number; +} export interface CustomChannelData {} export interface CustomCommandData {} export interface CustomEventData {} diff --git a/src/errors.ts b/src/errors.ts index 3688a656c1..432a1c2e7b 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,5 +1,5 @@ import type { AxiosResponse } from 'axios'; -import type { APIErrorResponse } from './types'; +import type { APIError as Gen_APIError } from './types'; export const APIErrorCodes: Record = { '-1': { name: 'InternalSystemError', retryable: true }, @@ -66,6 +66,6 @@ export function isWSFailure(err: APIError): boolean { export function isErrorResponse( res: AxiosResponse, -): res is AxiosResponse { +): res is AxiosResponse { return !res.status || res.status < 200 || 300 <= res.status; } diff --git a/src/events.ts b/src/events.ts deleted file mode 100644 index 7f8805cd8d..0000000000 --- a/src/events.ts +++ /dev/null @@ -1,75 +0,0 @@ -export const EVENT_MAP = { - 'channel.created': true, - 'channel.deleted': true, - 'channel.hidden': true, - 'channel.kicked': true, - 'channel.muted': true, - 'channel.truncated': true, - 'channel.unmuted': true, - 'channel.updated': true, - 'channel.visible': true, - 'draft.deleted': true, - 'draft.updated': true, - 'health.check': true, - 'member.added': true, - 'member.removed': true, - 'member.updated': true, - 'message.deleted': true, - 'message.new': true, - 'message.read': true, - 'message.updated': true, - 'message.undeleted': true, - 'notification.added_to_channel': true, - 'notification.channel_deleted': true, - 'message.delivered': true, - 'notification.channel_mutes_updated': true, - 'notification.channel_truncated': true, - 'notification.invite_accepted': true, - 'notification.invite_rejected': true, - 'notification.invited': true, - 'notification.mark_read': true, - 'notification.mark_unread': true, - 'notification.message_new': true, - 'notification.mutes_updated': true, - 'notification.reminder_due': true, - 'notification.removed_from_channel': true, - 'notification.thread_message_new': true, - 'poll.closed': true, - 'poll.updated': true, - 'poll.vote_casted': true, - 'poll.vote_changed': true, - 'poll.vote_removed': true, - 'reaction.deleted': true, - 'reaction.new': true, - 'reaction.updated': true, - 'reminder.created': true, - 'reminder.deleted': true, - 'reminder.updated': true, - 'thread.updated': true, - 'typing.start': true, - 'typing.stop': true, - 'user.banned': true, - 'user.deleted': true, - 'user.messages.deleted': true, - 'user.presence.changed': true, - 'user.unbanned': true, - 'user.unread_message_reminder': true, - 'user.updated': true, - 'user.watching.start': true, - 'user.watching.stop': true, - // AI events - 'ai_indicator.update': true, - 'ai_indicator.stop': true, - 'ai_indicator.clear': true, - - // local events - 'message.read_locally': true, - 'channels.queried': true, - 'offline_reactions.queried': true, - 'connection.changed': true, - 'connection.recovered': true, - 'transport.changed': true, - 'capabilities.changed': true, - 'live_location_sharing.started': true, - 'live_location_sharing.stopped': true, -}; diff --git a/src/gen-imports.ts b/src/gen-imports.ts new file mode 100644 index 0000000000..47107fae60 --- /dev/null +++ b/src/gen-imports.ts @@ -0,0 +1,3 @@ +export { ChatApi } from './gen/chat/ChatApi'; +export type { StreamResponse } from './types'; +export { ApiClient } from './api-client'; diff --git a/src/gen/chat/ChannelApi.ts b/src/gen/chat/ChannelApi.ts new file mode 100644 index 0000000000..836c04163f --- /dev/null +++ b/src/gen/chat/ChannelApi.ts @@ -0,0 +1,275 @@ +import type { ChatApi, StreamResponse } from '../../gen-imports'; +import type { + ChannelGetOrCreateRequest, + ChannelStateResponse, + ChannelStopWatchingRequest, + CreateDraftRequest, + CreateDraftResponse, + DeleteChannelResponse, + EventResponse, + GetDraftResponse, + GetManyMessagesResponse, + HideChannelRequest, + HideChannelResponse, + MarkReadRequest, + MarkReadResponse, + MarkUnreadRequest, + Response, + SendEventRequest, + SendMessageRequest, + SendMessageResponse, + ShowChannelRequest, + ShowChannelResponse, + TruncateChannelRequest, + TruncateChannelResponse, + UpdateChannelPartialRequest, + UpdateChannelPartialResponse, + UpdateChannelRequest, + UpdateChannelResponse, + UpdateMemberPartialRequest, + UpdateMemberPartialResponse, + UploadChannelFileRequest, + UploadChannelFileResponse, + UploadChannelRequest, + UploadChannelResponse, +} from '../models'; + +export class ChannelApi { + constructor( + protected chatApi: ChatApi, + public readonly type: string, + public id: string | undefined, + ) {} + + delete(request?: { + hard_delete?: boolean; + }): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.deleteChannel({ id: this.id, type: this.type, ...request }); + } + + updateChannelPartial( + request?: UpdateChannelPartialRequest, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.updateChannelPartial({ + id: this.id, + type: this.type, + ...request, + }); + } + + update(request?: UpdateChannelRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.updateChannel({ id: this.id, type: this.type, ...request }); + } + + deleteDraft(request?: { parent_id?: string }): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.deleteDraft({ id: this.id, type: this.type, ...request }); + } + + getDraft(request?: { parent_id?: string }): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.getDraft({ id: this.id, type: this.type, ...request }); + } + + createDraft(request: CreateDraftRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.createDraft({ id: this.id, type: this.type, ...request }); + } + + sendEvent(request: SendEventRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.sendEvent({ id: this.id, type: this.type, ...request }); + } + + deleteChannelFile(request?: { url?: string }): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.deleteChannelFile({ id: this.id, type: this.type, ...request }); + } + + uploadChannelFile( + request?: UploadChannelFileRequest, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.uploadChannelFile({ id: this.id, type: this.type, ...request }); + } + + hide(request?: HideChannelRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.hideChannel({ id: this.id, type: this.type, ...request }); + } + + deleteChannelImage(request?: { url?: string }): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.deleteChannelImage({ id: this.id, type: this.type, ...request }); + } + + uploadChannelImage( + request?: UploadChannelRequest, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.uploadChannelImage({ id: this.id, type: this.type, ...request }); + } + + updateMemberPartial( + request?: UpdateMemberPartialRequest, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.updateMemberPartial({ id: this.id, type: this.type, ...request }); + } + + sendMessage(request: SendMessageRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.sendMessage({ id: this.id, type: this.type, ...request }); + } + + getManyMessages(request: { + ids: Array; + }): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.getManyMessages({ id: this.id, type: this.type, ...request }); + } + + getOrCreate( + request?: ChannelGetOrCreateRequest & { connection_id?: string }, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.getOrCreateChannel({ id: this.id, type: this.type, ...request }); + } + + markRead(request?: MarkReadRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.markRead({ id: this.id, type: this.type, ...request }); + } + + show(request?: ShowChannelRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.showChannel({ id: this.id, type: this.type, ...request }); + } + + stopWatching( + request?: ChannelStopWatchingRequest & { connection_id?: string }, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.stopWatchingChannel({ id: this.id, type: this.type, ...request }); + } + + truncate( + request?: TruncateChannelRequest, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.truncateChannel({ id: this.id, type: this.type, ...request }); + } + + markUnread(request?: MarkUnreadRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.markUnread({ id: this.id, type: this.type, ...request }); + } +} diff --git a/src/gen/chat/ChatApi.ts b/src/gen/chat/ChatApi.ts new file mode 100644 index 0000000000..30b0fb0021 --- /dev/null +++ b/src/gen/chat/ChatApi.ts @@ -0,0 +1,2554 @@ +import type { ApiClient, StreamResponse } from '../../gen-imports'; +import type { + AddUserGroupMembersRequest, + AddUserGroupMembersResponse, + BlockUsersRequest, + BlockUsersResponse, + CastPollVoteRequest, + ChannelGetOrCreateRequest, + ChannelStateResponse, + ChannelStopWatchingRequest, + CreateBlockListRequest, + CreateBlockListResponse, + CreateDeviceRequest, + CreateDraftRequest, + CreateDraftResponse, + CreateGuestRequest, + CreateGuestResponse, + CreatePollOptionRequest, + CreatePollRequest, + CreateReminderRequest, + CreateUserGroupRequest, + CreateUserGroupResponse, + DeleteChannelResponse, + DeleteChannelsRequest, + DeleteChannelsResponse, + DeleteMessageResponse, + DeleteReactionResponse, + DeleteReminderResponse, + EventResponse, + FileUploadRequest, + FileUploadResponse, + GetApplicationResponse, + GetBlockedUsersResponse, + GetDraftResponse, + GetManyMessagesResponse, + GetMessageResponse, + GetOGResponse, + GetReactionsResponse, + GetRepliesResponse, + GetThreadResponse, + GetUserGroupResponse, + GroupedQueryChannelsRequest, + GroupedQueryChannelsResponse, + HideChannelRequest, + HideChannelResponse, + ImageUploadRequest, + ImageUploadResponse, + ListBlockListResponse, + ListDevicesResponse, + ListUserGroupsResponse, + MarkChannelsReadRequest, + MarkDeliveredRequest, + MarkDeliveredResponse, + MarkReadRequest, + MarkReadResponse, + MarkUnreadRequest, + MembersResponse, + MessageActionRequest, + MessageActionResponse, + MuteChannelRequest, + MuteChannelResponse, + PollOptionResponse, + PollResponse, + PollVoteResponse, + PollVotesResponse, + QueryBannedUsersPayload, + QueryBannedUsersResponse, + QueryChannelsRequest, + QueryChannelsResponse, + QueryDraftsRequest, + QueryDraftsResponse, + QueryFutureChannelBansPayload, + QueryFutureChannelBansResponse, + QueryMembersPayload, + QueryMessageFlagsPayload, + QueryMessageFlagsResponse, + QueryPollsRequest, + QueryPollsResponse, + QueryPollVotesRequest, + QueryReactionsRequest, + QueryReactionsResponse, + QueryRemindersRequest, + QueryRemindersResponse, + QueryThreadsRequest, + QueryThreadsResponse, + QueryUsersPayload, + QueryUsersResponse, + ReminderResponseData, + RemoveUserGroupMembersRequest, + RemoveUserGroupMembersResponse, + Response, + SearchPayload, + SearchResponse, + SearchRolesResponse, + SearchUserGroupsResponse, + SendEventRequest, + SendMessageRequest, + SendMessageResponse, + SendReactionRequest, + SendReactionResponse, + SharedLocationResponse, + SharedLocationsResponse, + ShowChannelRequest, + ShowChannelResponse, + SortParamRequest, + SyncRequest, + SyncResponse, + TranslateMessageRequest, + TruncateChannelRequest, + TruncateChannelResponse, + UnblockUsersRequest, + UnblockUsersResponse, + UnmuteChannelRequest, + UnmuteResponse, + UpdateBlockListRequest, + UpdateBlockListResponse, + UpdateChannelPartialRequest, + UpdateChannelPartialResponse, + UpdateChannelRequest, + UpdateChannelResponse, + UpdateLiveLocationRequest, + UpdateMemberPartialRequest, + UpdateMemberPartialResponse, + UpdateMessagePartialRequest, + UpdateMessagePartialResponse, + UpdateMessageRequest, + UpdateMessageResponse, + UpdatePollOptionRequest, + UpdatePollPartialRequest, + UpdatePollRequest, + UpdateReminderRequest, + UpdateReminderResponse, + UpdateThreadPartialRequest, + UpdateThreadPartialResponse, + UpdateUserGroupRequest, + UpdateUserGroupResponse, + UpdateUsersPartialRequest, + UpdateUsersRequest, + UpdateUsersResponse, + UploadChannelFileRequest, + UploadChannelFileResponse, + UploadChannelRequest, + UploadChannelResponse, + UpsertPushPreferencesRequest, + UpsertPushPreferencesResponse, + WrappedUnreadCountsResponse, + WSAuthMessage, +} from '../models'; +import { decoders } from '../model-decoders/decoders'; + +export class ChatApi { + constructor(public readonly apiClient: ApiClient) {} + + async getApp(): Promise> { + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/app', undefined, undefined); + + decoders['GetApplicationResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async listBlockLists(request?: { + team?: string; + }): Promise> { + const queryParams = { + team: request?.team, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/blocklists', undefined, queryParams); + + decoders['ListBlockListResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createBlockList( + request: CreateBlockListRequest, + ): Promise> { + const body = { + name: request?.name, + words: request?.words, + is_confusable_folding_enabled: request?.is_confusable_folding_enabled, + is_leet_check_enabled: request?.is_leet_check_enabled, + is_plural_check_enabled: request?.is_plural_check_enabled, + is_substring_matching_enabled: request?.is_substring_matching_enabled, + team: request?.team, + type: request?.type, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/blocklists', undefined, undefined, body, 'application/json'); + + decoders['CreateBlockListResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteBlockList(request: { + name: string; + team?: string; + }): Promise> { + const queryParams = { + team: request?.team, + }; + const pathParams = { + name: request?.name, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/blocklists/{name}', + pathParams, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateBlockList( + request: UpdateBlockListRequest & { name: string }, + ): Promise> { + const pathParams = { + name: request?.name, + }; + const body = { + is_confusable_folding_enabled: request?.is_confusable_folding_enabled, + is_leet_check_enabled: request?.is_leet_check_enabled, + is_plural_check_enabled: request?.is_plural_check_enabled, + is_substring_matching_enabled: request?.is_substring_matching_enabled, + team: request?.team, + words: request?.words, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'PUT', + '/api/v2/blocklists/{name}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateBlockListResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryChannels( + request?: QueryChannelsRequest & { connection_id?: string }, + ): Promise> { + const queryParams = { + connection_id: request?.connection_id, + }; + const body = { + limit: request?.limit, + member_limit: request?.member_limit, + message_limit: request?.message_limit, + offset: request?.offset, + predefined_filter: request?.predefined_filter, + presence: request?.presence, + state: request?.state, + watch: request?.watch, + sort: request?.sort, + filter_conditions: request?.filter_conditions, + filter_values: request?.filter_values, + sort_values: request?.sort_values, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/chat/channels', undefined, queryParams, body, 'application/json'); + + decoders['QueryChannelsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteChannels( + request: DeleteChannelsRequest, + ): Promise> { + const body = { + cids: request?.cids, + hard_delete: request?.hard_delete, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/delete', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['DeleteChannelsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async markDelivered( + request?: MarkDeliveredRequest, + ): Promise> { + const body = { + latest_delivered_messages: request?.latest_delivered_messages, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/delivered', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['MarkDeliveredResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async groupedQueryChannels( + request?: GroupedQueryChannelsRequest & { connection_id?: string }, + ): Promise> { + const queryParams = { + connection_id: request?.connection_id, + }; + const body = { + limit: request?.limit, + presence: request?.presence, + watch: request?.watch, + groups: request?.groups, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/grouped', + undefined, + queryParams, + body, + 'application/json', + ); + + decoders['GroupedQueryChannelsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async markChannelsRead( + request?: MarkChannelsReadRequest, + ): Promise> { + const body = { + read_by_channel: request?.read_by_channel, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/channels/read', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['MarkReadResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getOrCreateDistinctChannel( + request: ChannelGetOrCreateRequest & { type: string; connection_id?: string }, + ): Promise> { + const queryParams = { + connection_id: request?.connection_id, + }; + const pathParams = { + type: request?.type, + }; + const body = { + hide_for_creator: request?.hide_for_creator, + presence: request?.presence, + state: request?.state, + thread_unread_counts: request?.thread_unread_counts, + watch: request?.watch, + data: request?.data, + members: request?.members, + messages: request?.messages, + watchers: request?.watchers, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/query', + pathParams, + queryParams, + body, + 'application/json', + ); + + decoders['ChannelStateResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteChannel(request: { + type: string; + id: string; + hard_delete?: boolean; + }): Promise> { + const queryParams = { + hard_delete: request?.hard_delete, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('DELETE', '/api/v2/chat/channels/{type}/{id}', pathParams, queryParams); + + decoders['DeleteChannelResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateChannelPartial( + request: UpdateChannelPartialRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + unset: request?.unset, + set: request?.set, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'PATCH', + '/api/v2/chat/channels/{type}/{id}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateChannelPartialResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateChannel( + request: UpdateChannelRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + accept_invite: request?.accept_invite, + cooldown: request?.cooldown, + hide_history: request?.hide_history, + hide_history_before: request?.hide_history_before, + reject_invite: request?.reject_invite, + skip_push: request?.skip_push, + add_filter_tags: request?.add_filter_tags, + add_members: request?.add_members, + add_moderators: request?.add_moderators, + assign_roles: request?.assign_roles, + demote_moderators: request?.demote_moderators, + invites: request?.invites, + remove_filter_tags: request?.remove_filter_tags, + remove_members: request?.remove_members, + data: request?.data, + message: request?.message, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateChannelResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteDraft(request: { + type: string; + id: string; + parent_id?: string; + }): Promise> { + const queryParams = { + parent_id: request?.parent_id, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/chat/channels/{type}/{id}/draft', + pathParams, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getDraft(request: { + type: string; + id: string; + parent_id?: string; + }): Promise> { + const queryParams = { + parent_id: request?.parent_id, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/chat/channels/{type}/{id}/draft', + pathParams, + queryParams, + ); + + decoders['GetDraftResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createDraft( + request: CreateDraftRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + message: request?.message, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/draft', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['CreateDraftResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async sendEvent( + request: SendEventRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + event: request?.event, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/channels/{type}/{id}/event', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['EventResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteChannelFile(request: { + type: string; + id: string; + url?: string; + }): Promise> { + const queryParams = { + url: request?.url, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/chat/channels/{type}/{id}/file', + pathParams, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async uploadChannelFile( + request: UploadChannelFileRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + file: request?.file, + user: request?.user, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/file', + pathParams, + undefined, + body, + 'multipart/form-data', + ); + + decoders['UploadChannelFileResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async hideChannel( + request: HideChannelRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + clear_history: request?.clear_history, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/hide', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['HideChannelResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteChannelImage(request: { + type: string; + id: string; + url?: string; + }): Promise> { + const queryParams = { + url: request?.url, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/chat/channels/{type}/{id}/image', + pathParams, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async uploadChannelImage( + request: UploadChannelRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + file: request?.file, + upload_sizes: request?.upload_sizes, + user: request?.user, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/image', + pathParams, + undefined, + body, + 'multipart/form-data', + ); + + decoders['UploadChannelResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateMemberPartial( + request: UpdateMemberPartialRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + unset: request?.unset, + set: request?.set, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'PATCH', + '/api/v2/chat/channels/{type}/{id}/member', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateMemberPartialResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async sendMessage( + request: SendMessageRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + message: request?.message, + keep_channel_hidden: request?.keep_channel_hidden, + skip_enrich_url: request?.skip_enrich_url, + skip_push: request?.skip_push, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/message', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['SendMessageResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getManyMessages(request: { + type: string; + id: string; + ids: Array; + }): Promise> { + const queryParams = { + ids: request?.ids, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/chat/channels/{type}/{id}/messages', pathParams, queryParams); + + decoders['GetManyMessagesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getOrCreateChannel( + request: ChannelGetOrCreateRequest & { + type: string; + id: string; + connection_id?: string; + }, + ): Promise> { + const queryParams = { + connection_id: request?.connection_id, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + hide_for_creator: request?.hide_for_creator, + presence: request?.presence, + state: request?.state, + thread_unread_counts: request?.thread_unread_counts, + watch: request?.watch, + data: request?.data, + members: request?.members, + messages: request?.messages, + watchers: request?.watchers, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/query', + pathParams, + queryParams, + body, + 'application/json', + ); + + decoders['ChannelStateResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async markRead( + request: MarkReadRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + message_id: request?.message_id, + thread_id: request?.thread_id, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/channels/{type}/{id}/read', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['MarkReadResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async showChannel( + request: ShowChannelRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = {}; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/show', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['ShowChannelResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async stopWatchingChannel( + request: ChannelStopWatchingRequest & { + type: string; + id: string; + connection_id?: string; + }, + ): Promise> { + const queryParams = { + connection_id: request?.connection_id, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = {}; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/channels/{type}/{id}/stop-watching', + pathParams, + queryParams, + body, + 'application/json', + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async truncateChannel( + request: TruncateChannelRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + hard_delete: request?.hard_delete, + skip_push: request?.skip_push, + truncated_at: request?.truncated_at, + member_ids: request?.member_ids, + message: request?.message, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/truncate', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['TruncateChannelResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async markUnread( + request: MarkUnreadRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + message_id: request?.message_id, + message_timestamp: request?.message_timestamp, + thread_id: request?.thread_id, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/channels/{type}/{id}/unread', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryDrafts( + request?: QueryDraftsRequest, + ): Promise> { + const body = { + limit: request?.limit, + next: request?.next, + prev: request?.prev, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/drafts/query', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['QueryDraftsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryMembers(request?: { + payload?: QueryMembersPayload; + }): Promise> { + const queryParams = { + payload: request?.payload, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/chat/members', + undefined, + queryParams, + ); + + decoders['MembersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteMessage(request: { + id: string; + hard?: boolean; + deleted_by?: string; + delete_for_me?: boolean; + }): Promise> { + const queryParams = { + hard: request?.hard, + deleted_by: request?.deleted_by, + delete_for_me: request?.delete_for_me, + }; + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('DELETE', '/api/v2/chat/messages/{id}', pathParams, queryParams); + + decoders['DeleteMessageResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getMessage(request: { id: string }): Promise> { + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/chat/messages/{id}', + pathParams, + undefined, + ); + + decoders['GetMessageResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateMessage( + request: UpdateMessageRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + message: request?.message, + skip_enrich_url: request?.skip_enrich_url, + skip_push: request?.skip_push, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/messages/{id}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateMessageResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateMessagePartial( + request: UpdateMessagePartialRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + skip_enrich_url: request?.skip_enrich_url, + skip_push: request?.skip_push, + unset: request?.unset, + set: request?.set, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'PUT', + '/api/v2/chat/messages/{id}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateMessagePartialResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async runMessageAction( + request: MessageActionRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + form_data: request?.form_data, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/messages/{id}/action', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['MessageActionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async sendReaction( + request: SendReactionRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + reaction: request?.reaction, + enforce_unique: request?.enforce_unique, + skip_push: request?.skip_push, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/messages/{id}/reaction', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['SendReactionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteReaction(request: { + id: string; + type: string; + user_id?: string; + }): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const pathParams = { + id: request?.id, + type: request?.type, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('DELETE', '/api/v2/chat/messages/{id}/reaction/{type}', pathParams, queryParams); + + decoders['DeleteReactionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getReactions(request: { + id: string; + limit?: number; + offset?: number; + }): Promise> { + const queryParams = { + limit: request?.limit, + offset: request?.offset, + }; + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/chat/messages/{id}/reactions', pathParams, queryParams); + + decoders['GetReactionsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryReactions( + request: QueryReactionsRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + limit: request?.limit, + next: request?.next, + prev: request?.prev, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/messages/{id}/reactions', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['QueryReactionsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async translateMessage( + request: TranslateMessageRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + language: request?.language, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/messages/{id}/translate', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['MessageActionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async castPollVote( + request: CastPollVoteRequest & { message_id: string; poll_id: string }, + ): Promise> { + const pathParams = { + message_id: request?.message_id, + poll_id: request?.poll_id, + }; + const body = { + vote: request?.vote, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/messages/{message_id}/polls/{poll_id}/vote', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['PollVoteResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deletePollVote(request: { + message_id: string; + poll_id: string; + vote_id: string; + user_id?: string; + }): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const pathParams = { + message_id: request?.message_id, + poll_id: request?.poll_id, + vote_id: request?.vote_id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/chat/messages/{message_id}/polls/{poll_id}/vote/{vote_id}', + pathParams, + queryParams, + ); + + decoders['PollVoteResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteReminder(request: { + message_id: string; + }): Promise> { + const pathParams = { + message_id: request?.message_id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('DELETE', '/api/v2/chat/messages/{message_id}/reminders', pathParams, undefined); + + decoders['DeleteReminderResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateReminder( + request: UpdateReminderRequest & { message_id: string }, + ): Promise> { + const pathParams = { + message_id: request?.message_id, + }; + const body = { + remind_at: request?.remind_at, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'PATCH', + '/api/v2/chat/messages/{message_id}/reminders', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateReminderResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createReminder( + request: CreateReminderRequest & { message_id: string }, + ): Promise> { + const pathParams = { + message_id: request?.message_id, + }; + const body = { + remind_at: request?.remind_at, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/messages/{message_id}/reminders', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['ReminderResponseData']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getReplies(request: { + parent_id: string; + limit?: number; + id_gte?: string; + id_gt?: string; + id_lte?: string; + id_lt?: string; + id_around?: string; + sort?: Array; + }): Promise> { + const queryParams = { + limit: request?.limit, + id_gte: request?.id_gte, + id_gt: request?.id_gt, + id_lte: request?.id_lte, + id_lt: request?.id_lt, + id_around: request?.id_around, + sort: request?.sort, + }; + const pathParams = { + parent_id: request?.parent_id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/chat/messages/{parent_id}/replies', + pathParams, + queryParams, + ); + + decoders['GetRepliesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryMessageFlags(request?: { + payload?: QueryMessageFlagsPayload; + }): Promise> { + const queryParams = { + payload: request?.payload, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/chat/moderation/flags/message', undefined, queryParams); + + decoders['QueryMessageFlagsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async muteChannel( + request?: MuteChannelRequest, + ): Promise> { + const body = { + expiration: request?.expiration, + channel_cids: request?.channel_cids, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/moderation/mute/channel', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['MuteChannelResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async unmuteChannel( + request?: UnmuteChannelRequest, + ): Promise> { + const body = { + expiration: request?.expiration, + channel_cids: request?.channel_cids, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/moderation/unmute/channel', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['UnmuteResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryBannedUsers(request?: { + payload?: QueryBannedUsersPayload; + }): Promise> { + const queryParams = { + payload: request?.payload, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/chat/query_banned_users', undefined, queryParams); + + decoders['QueryBannedUsersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryFutureChannelBans(request?: { + payload?: QueryFutureChannelBansPayload; + }): Promise> { + const queryParams = { + payload: request?.payload, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/chat/query_future_channel_bans', undefined, queryParams); + + decoders['QueryFutureChannelBansResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryReminders( + request?: QueryRemindersRequest, + ): Promise> { + const body = { + limit: request?.limit, + next: request?.next, + prev: request?.prev, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/reminders/query', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['QueryRemindersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async search(request?: { + payload?: SearchPayload; + }): Promise> { + const queryParams = { + payload: request?.payload, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/chat/search', + undefined, + queryParams, + ); + + decoders['SearchResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async sync( + request: SyncRequest & { + with_inaccessible_cids?: boolean; + watch?: boolean; + connection_id?: string; + }, + ): Promise> { + const queryParams = { + with_inaccessible_cids: request?.with_inaccessible_cids, + watch: request?.watch, + connection_id: request?.connection_id, + }; + const body = { + last_sync_at: request?.last_sync_at, + channel_cids: request?.channel_cids, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/sync', + undefined, + queryParams, + body, + 'application/json', + ); + + decoders['SyncResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryThreads( + request?: QueryThreadsRequest & { connection_id?: string }, + ): Promise> { + const queryParams = { + connection_id: request?.connection_id, + }; + const body = { + limit: request?.limit, + member_limit: request?.member_limit, + next: request?.next, + participant_limit: request?.participant_limit, + prev: request?.prev, + reply_limit: request?.reply_limit, + watch: request?.watch, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/chat/threads', undefined, queryParams, body, 'application/json'); + + decoders['QueryThreadsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getThread(request: { + message_id: string; + watch?: boolean; + connection_id?: string; + reply_limit?: number; + participant_limit?: number; + member_limit?: number; + }): Promise> { + const queryParams = { + watch: request?.watch, + connection_id: request?.connection_id, + reply_limit: request?.reply_limit, + participant_limit: request?.participant_limit, + member_limit: request?.member_limit, + }; + const pathParams = { + message_id: request?.message_id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/chat/threads/{message_id}', + pathParams, + queryParams, + ); + + decoders['GetThreadResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateThreadPartial( + request: UpdateThreadPartialRequest & { message_id: string }, + ): Promise> { + const pathParams = { + message_id: request?.message_id, + }; + const body = { + unset: request?.unset, + set: request?.set, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'PATCH', + '/api/v2/chat/threads/{message_id}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateThreadPartialResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async unreadCounts(): Promise> { + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/chat/unread', undefined, undefined); + + decoders['WrappedUnreadCountsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteDevice(request: { id: string }): Promise> { + const queryParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/devices', + undefined, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async listDevices(): Promise> { + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/devices', undefined, undefined); + + decoders['ListDevicesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createDevice(request: CreateDeviceRequest): Promise> { + const body = { + id: request?.id, + push_provider: request?.push_provider, + hardware_id: request?.hardware_id, + push_provider_name: request?.push_provider_name, + voip_token: request?.voip_token, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/devices', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createGuest( + request: CreateGuestRequest, + ): Promise> { + const body = { + user: request?.user, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/guest', undefined, undefined, body, 'application/json'); + + decoders['CreateGuestResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async longPoll(request?: { + connection_id?: string; + json?: WSAuthMessage; + }): Promise> { + const queryParams = { + connection_id: request?.connection_id, + json: request?.json, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/longpoll', + undefined, + queryParams, + ); + + decoders['{}']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getOG(request: { url: string }): Promise> { + const queryParams = { + url: request?.url, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/og', + undefined, + queryParams, + ); + + decoders['GetOGResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createPoll(request: CreatePollRequest): Promise> { + const body = { + name: request?.name, + allow_answers: request?.allow_answers, + allow_user_suggested_options: request?.allow_user_suggested_options, + description: request?.description, + enforce_unique_vote: request?.enforce_unique_vote, + id: request?.id, + is_closed: request?.is_closed, + max_votes_allowed: request?.max_votes_allowed, + voting_visibility: request?.voting_visibility, + options: request?.options, + custom: request?.custom, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/polls', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['PollResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updatePoll(request: UpdatePollRequest): Promise> { + const body = { + id: request?.id, + name: request?.name, + allow_answers: request?.allow_answers, + allow_user_suggested_options: request?.allow_user_suggested_options, + description: request?.description, + enforce_unique_vote: request?.enforce_unique_vote, + is_closed: request?.is_closed, + max_votes_allowed: request?.max_votes_allowed, + voting_visibility: request?.voting_visibility, + options: request?.options, + custom: request?.custom, + }; + + const response = await this.apiClient.sendRequest>( + 'PUT', + '/api/v2/polls', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['PollResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryPolls( + request?: QueryPollsRequest & { user_id?: string }, + ): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const body = { + limit: request?.limit, + next: request?.next, + prev: request?.prev, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/polls/query', + undefined, + queryParams, + body, + 'application/json', + ); + + decoders['QueryPollsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deletePoll(request: { + poll_id: string; + user_id?: string; + }): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const pathParams = { + poll_id: request?.poll_id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/polls/{poll_id}', + pathParams, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getPoll(request: { + poll_id: string; + user_id?: string; + }): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const pathParams = { + poll_id: request?.poll_id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/polls/{poll_id}', + pathParams, + queryParams, + ); + + decoders['PollResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updatePollPartial( + request: UpdatePollPartialRequest & { poll_id: string }, + ): Promise> { + const pathParams = { + poll_id: request?.poll_id, + }; + const body = { + unset: request?.unset, + set: request?.set, + }; + + const response = await this.apiClient.sendRequest>( + 'PATCH', + '/api/v2/polls/{poll_id}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['PollResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createPollOption( + request: CreatePollOptionRequest & { poll_id: string }, + ): Promise> { + const pathParams = { + poll_id: request?.poll_id, + }; + const body = { + text: request?.text, + custom: request?.custom, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/polls/{poll_id}/options', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['PollOptionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updatePollOption( + request: UpdatePollOptionRequest & { poll_id: string }, + ): Promise> { + const pathParams = { + poll_id: request?.poll_id, + }; + const body = { + id: request?.id, + text: request?.text, + custom: request?.custom, + }; + + const response = await this.apiClient.sendRequest>( + 'PUT', + '/api/v2/polls/{poll_id}/options', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['PollOptionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deletePollOption(request: { + poll_id: string; + option_id: string; + user_id?: string; + }): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const pathParams = { + poll_id: request?.poll_id, + option_id: request?.option_id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/polls/{poll_id}/options/{option_id}', + pathParams, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getPollOption(request: { + poll_id: string; + option_id: string; + user_id?: string; + }): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const pathParams = { + poll_id: request?.poll_id, + option_id: request?.option_id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/polls/{poll_id}/options/{option_id}', + pathParams, + queryParams, + ); + + decoders['PollOptionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryPollVotes( + request: QueryPollVotesRequest & { poll_id: string; user_id?: string }, + ): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const pathParams = { + poll_id: request?.poll_id, + }; + const body = { + limit: request?.limit, + next: request?.next, + prev: request?.prev, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/polls/{poll_id}/votes', + pathParams, + queryParams, + body, + 'application/json', + ); + + decoders['PollVotesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updatePushNotificationPreferences( + request: UpsertPushPreferencesRequest, + ): Promise> { + const body = { + preferences: request?.preferences, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/push_preferences', undefined, undefined, body, 'application/json'); + + decoders['UpsertPushPreferencesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async searchRoles(request: { + query: string; + limit?: number; + name_gt?: string; + role_type?: string; + include_global_roles?: boolean; + }): Promise> { + const queryParams = { + query: request?.query, + limit: request?.limit, + name_gt: request?.name_gt, + role_type: request?.role_type, + include_global_roles: request?.include_global_roles, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/roles/search', undefined, queryParams); + + decoders['SearchRolesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteFile(request?: { url?: string }): Promise> { + const queryParams = { + url: request?.url, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/uploads/file', + undefined, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async uploadFile( + request?: FileUploadRequest, + ): Promise> { + const body = { + file: request?.file, + user: request?.user, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/uploads/file', + undefined, + undefined, + body, + 'multipart/form-data', + ); + + decoders['FileUploadResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteImage(request?: { url?: string }): Promise> { + const queryParams = { + url: request?.url, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/uploads/image', + undefined, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async uploadImage( + request?: ImageUploadRequest, + ): Promise> { + const body = { + file: request?.file, + upload_sizes: request?.upload_sizes, + user: request?.user, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/uploads/image', undefined, undefined, body, 'multipart/form-data'); + + decoders['ImageUploadResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async listUserGroups(request?: { + limit?: number; + id_gt?: string; + created_at_gt?: string; + team_id?: string; + }): Promise> { + const queryParams = { + limit: request?.limit, + id_gt: request?.id_gt, + created_at_gt: request?.created_at_gt, + team_id: request?.team_id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/usergroups', undefined, queryParams); + + decoders['ListUserGroupsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createUserGroup( + request: CreateUserGroupRequest, + ): Promise> { + const body = { + name: request?.name, + description: request?.description, + id: request?.id, + team_id: request?.team_id, + member_ids: request?.member_ids, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/usergroups', undefined, undefined, body, 'application/json'); + + decoders['CreateUserGroupResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async searchUserGroups(request: { + query: string; + limit?: number; + name_gt?: string; + id_gt?: string; + team_id?: string; + }): Promise> { + const queryParams = { + query: request?.query, + limit: request?.limit, + name_gt: request?.name_gt, + id_gt: request?.id_gt, + team_id: request?.team_id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/usergroups/search', undefined, queryParams); + + decoders['SearchUserGroupsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteUserGroup(request: { + id: string; + team_id?: string; + }): Promise> { + const queryParams = { + team_id: request?.team_id, + }; + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/usergroups/{id}', + pathParams, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getUserGroup(request: { + id: string; + team_id?: string; + }): Promise> { + const queryParams = { + team_id: request?.team_id, + }; + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/usergroups/{id}', pathParams, queryParams); + + decoders['GetUserGroupResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateUserGroup( + request: UpdateUserGroupRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + description: request?.description, + name: request?.name, + team_id: request?.team_id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('PUT', '/api/v2/usergroups/{id}', pathParams, undefined, body, 'application/json'); + + decoders['UpdateUserGroupResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async addUserGroupMembers( + request: AddUserGroupMembersRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + member_ids: request?.member_ids, + as_admin: request?.as_admin, + team_id: request?.team_id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/usergroups/{id}/members', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['AddUserGroupMembersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async removeUserGroupMembers( + request: RemoveUserGroupMembersRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + member_ids: request?.member_ids, + team_id: request?.team_id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/usergroups/{id}/members/delete', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['RemoveUserGroupMembersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryUsers(request?: { + payload?: QueryUsersPayload; + }): Promise> { + const queryParams = { + payload: request?.payload, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/users', + undefined, + queryParams, + ); + + decoders['QueryUsersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateUsersPartial( + request: UpdateUsersPartialRequest, + ): Promise> { + const body = { + users: request?.users, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('PATCH', '/api/v2/users', undefined, undefined, body, 'application/json'); + + decoders['UpdateUsersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateUsers( + request: UpdateUsersRequest, + ): Promise> { + const body = { + users: request?.users, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/users', undefined, undefined, body, 'application/json'); + + decoders['UpdateUsersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getBlockedUsers(): Promise> { + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/users/block', undefined, undefined); + + decoders['GetBlockedUsersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async blockUsers( + request: BlockUsersRequest, + ): Promise> { + const body = { + blocked_user_id: request?.blocked_user_id, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/users/block', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['BlockUsersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getUserLiveLocations(): Promise> { + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/users/live_locations', undefined, undefined); + + decoders['SharedLocationsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateLiveLocation( + request: UpdateLiveLocationRequest, + ): Promise> { + const body = { + message_id: request?.message_id, + end_at: request?.end_at, + latitude: request?.latitude, + longitude: request?.longitude, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'PUT', + '/api/v2/users/live_locations', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['SharedLocationResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async unblockUsers( + request: UnblockUsersRequest, + ): Promise> { + const body = { + blocked_user_id: request?.blocked_user_id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/users/unblock', undefined, undefined, body, 'application/json'); + + decoders['UnblockUsersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } +} diff --git a/src/gen/model-decoders/decoders.ts b/src/gen/model-decoders/decoders.ts new file mode 100644 index 0000000000..b58e1c8cec --- /dev/null +++ b/src/gen/model-decoders/decoders.ts @@ -0,0 +1,2680 @@ +type Decoder = (i: any) => any; + +type TypeMapping = Record; + +export const decoders: Record = {}; + +const decodeDatetimeType = (input: number | string) => + typeof input === 'number' ? new Date(Math.floor(input / 1000000)) : new Date(input); + +decoders.DatetimeType = decodeDatetimeType; + +const decode = (typeMappings: TypeMapping, input?: Record) => { + if (!input || Object.keys(typeMappings).length === 0) return input; + + Object.keys(typeMappings).forEach((key) => { + if (input[key] != null) { + if (typeMappings[key]) { + const decoder = decoders[typeMappings[key].type]; + if (decoder) { + if (typeMappings[key].isSingle) { + input[key] = decoder(input[key]); + } else { + Object.keys(input[key]).forEach((k) => { + input[key][k] = decoder(input[key][k]); + }); + } + } + } + } + }); + + return input; +}; + +decoders['AIIndicatorClearEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['AIIndicatorStopEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['AIIndicatorUpdateEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ActionLogResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + review_queue_item: { type: 'ReviewQueueItemResponse', isSingle: true }, + + target_user: { type: 'UserResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['AddUserGroupMembersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_group: { type: 'UserGroupResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['AppUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['AppealItemResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + actions: { type: 'ActionLogResponse', isSingle: false }, + + flags: { type: 'ModerationFlagResponse', isSingle: false }, + + moderation_action: { type: 'ActionLogResponse', isSingle: true }, + + original_moderation_action: { type: 'ActionLogResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['AutomodDetailsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + result: { type: 'MessageModerationResult', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['BanInfoResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + expires: { type: 'DatetimeType', isSingle: true }, + + created_by: { type: 'UserResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['BanResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + expires: { type: 'DatetimeType', isSingle: true }, + + banned_by: { type: 'UserResponse', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['BlockListResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['BlockUsersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['BlockedUserResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + blocked_user: { type: 'UserResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['BulkActionAppealsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + results: { type: 'BulkAppealResult', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['BulkAppealResult'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + appeal_item: { type: 'AppealItemResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['CallResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + ended_at: { type: 'DatetimeType', isSingle: true }, + + starts_at: { type: 'DatetimeType', isSingle: true }, + + created_by: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelConfigWithInfo'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + commands: { type: 'Command', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelCreatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelFrozenEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelHiddenEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelKickedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelMemberResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + archived_at: { type: 'DatetimeType', isSingle: true }, + + ban_expires: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + invite_accepted_at: { type: 'DatetimeType', isSingle: true }, + + invite_rejected_at: { type: 'DatetimeType', isSingle: true }, + + pinned_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelMute'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + expires: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelPushPreferencesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + disabled_until: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + hide_messages_before: { type: 'DatetimeType', isSingle: true }, + + last_message_at: { type: 'DatetimeType', isSingle: true }, + + mute_expires_at: { type: 'DatetimeType', isSingle: true }, + + truncated_at: { type: 'DatetimeType', isSingle: true }, + + members: { type: 'ChannelMemberResponse', isSingle: false }, + + config: { type: 'ChannelConfigWithInfo', isSingle: true }, + + created_by: { type: 'UserResponse', isSingle: true }, + + truncated_by: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelStateResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + members: { type: 'ChannelMemberResponse', isSingle: false }, + + messages: { type: 'MessageResponse', isSingle: false }, + + pinned_messages: { type: 'MessageResponse', isSingle: false }, + + threads: { type: 'ThreadStateResponse', isSingle: false }, + + hide_messages_before: { type: 'DatetimeType', isSingle: true }, + + active_live_locations: { type: 'SharedLocationResponseData', isSingle: false }, + + pending_messages: { type: 'PendingMessageResponse', isSingle: false }, + + read: { type: 'ReadStateResponse', isSingle: false }, + + watchers: { type: 'UserResponse', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + draft: { type: 'DraftResponse', isSingle: true }, + + membership: { type: 'ChannelMemberResponse', isSingle: true }, + + push_preferences: { type: 'ChannelPushPreferencesResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelStateResponseFields'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + members: { type: 'ChannelMemberResponse', isSingle: false }, + + messages: { type: 'MessageResponse', isSingle: false }, + + pinned_messages: { type: 'MessageResponse', isSingle: false }, + + threads: { type: 'ThreadStateResponse', isSingle: false }, + + hide_messages_before: { type: 'DatetimeType', isSingle: true }, + + active_live_locations: { type: 'SharedLocationResponseData', isSingle: false }, + + pending_messages: { type: 'PendingMessageResponse', isSingle: false }, + + read: { type: 'ReadStateResponse', isSingle: false }, + + watchers: { type: 'UserResponse', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + draft: { type: 'DraftResponse', isSingle: true }, + + membership: { type: 'ChannelMemberResponse', isSingle: true }, + + push_preferences: { type: 'ChannelPushPreferencesResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelTruncatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelUnFrozenEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelVisibleEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatDraftPayloadResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + mentioned_users: { type: 'UserResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatDraftResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'ChatDraftPayloadResponse', isSingle: true }, + + parent_message: { type: 'ChatMessageResponse', isSingle: true }, + + quoted_message: { type: 'ChatMessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatMessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + latest_reactions: { type: 'ChatReactionResponse', isSingle: false }, + + mentioned_users: { type: 'UserResponse', isSingle: false }, + + own_reactions: { type: 'ChatReactionResponse', isSingle: false }, + + user: { type: 'UserResponse', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + message_text_updated_at: { type: 'DatetimeType', isSingle: true }, + + pin_expires: { type: 'DatetimeType', isSingle: true }, + + pinned_at: { type: 'DatetimeType', isSingle: true }, + + mentioned_groups: { type: 'UserGroupResponse', isSingle: false }, + + thread_participants: { type: 'UserResponse', isSingle: false }, + + draft: { type: 'ChatDraftResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + pinned_by: { type: 'UserResponse', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + quoted_message: { type: 'ChatMessageResponse', isSingle: true }, + + reaction_groups: { type: 'ChatReactionGroupResponse', isSingle: false }, + + reminder: { type: 'ChatReminderResponseData', isSingle: true }, + + shared_location: { type: 'ChatSharedLocationResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatReactionGroupResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + first_reaction_at: { type: 'DatetimeType', isSingle: true }, + + last_reaction_at: { type: 'DatetimeType', isSingle: true }, + + latest_reactions_by: { type: 'ChatReactionGroupUserResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatReactionGroupUserResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatReactionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatReminderResponseData'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + remind_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'ChatMessageResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatSharedLocationResponseData'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + end_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'ChatMessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['Command'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ConfigResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['CreateBlockListResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + blocklist: { type: 'BlockListResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['CreateDraftResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + draft: { type: 'DraftResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['CreateGuestResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['CreateUserGroupResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_group: { type: 'UserGroupResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['CustomEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['DeleteChannelResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channel: { type: 'ChannelResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['DeleteMessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['DeleteReactionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageResponse', isSingle: true }, + + reaction: { type: 'ReactionResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['DeviceResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['DraftDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + draft: { type: 'DraftResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['DraftPayloadResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + mentioned_users: { type: 'UserResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['DraftResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'DraftPayloadResponse', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + parent_message: { type: 'MessageResponse', isSingle: true }, + + quoted_message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['DraftUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + draft: { type: 'DraftResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['EntityCreatorResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + deactivated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + last_active: { type: 'DatetimeType', isSingle: true }, + + revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FeedsBookmarkResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FeedsEnrichedCollectionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FeedsFeedResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + created_by: { type: 'UserResponse', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FeedsReactionGroupResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + first_reaction_at: { type: 'DatetimeType', isSingle: true }, + + last_reaction_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FeedsReactionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FeedsV3ActivityResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + comments: { type: 'FeedsV3CommentResponse', isSingle: false }, + + latest_reactions: { type: 'FeedsReactionResponse', isSingle: false }, + + mentioned_users: { type: 'UserResponse', isSingle: false }, + + own_bookmarks: { type: 'FeedsBookmarkResponse', isSingle: false }, + + own_reactions: { type: 'FeedsReactionResponse', isSingle: false }, + + collections: { type: 'FeedsEnrichedCollectionResponse', isSingle: false }, + + reaction_groups: { type: 'FeedsReactionGroupResponse', isSingle: false }, + + user: { type: 'UserResponse', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + edited_at: { type: 'DatetimeType', isSingle: true }, + + expires_at: { type: 'DatetimeType', isSingle: true }, + + friend_reactions: { type: 'FeedsReactionResponse', isSingle: false }, + + current_feed: { type: 'FeedsFeedResponse', isSingle: true }, + + parent: { type: 'FeedsV3ActivityResponse', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FeedsV3CommentResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + mentioned_users: { type: 'UserResponse', isSingle: false }, + + own_reactions: { type: 'FeedsReactionResponse', isSingle: false }, + + user: { type: 'UserResponse', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + edited_at: { type: 'DatetimeType', isSingle: true }, + + latest_reactions: { type: 'FeedsReactionResponse', isSingle: false }, + + reaction_groups: { type: 'FeedsReactionGroupResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['FlagDetailsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + automod: { type: 'AutomodDetailsResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FlagFeedbackResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FullUserResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + channel_mutes: { type: 'ChannelMute', isSingle: false }, + + devices: { type: 'DeviceResponse', isSingle: false }, + + mutes: { type: 'UserMuteResponse', isSingle: false }, + + ban_expires: { type: 'DatetimeType', isSingle: true }, + + deactivated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + last_active: { type: 'DatetimeType', isSingle: true }, + + revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FutureChannelBanResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + expires: { type: 'DatetimeType', isSingle: true }, + + banned_by: { type: 'UserResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['GetAppealResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + item: { type: 'AppealItemResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['GetBlockedUsersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + blocks: { type: 'BlockedUserResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['GetConfigResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + config: { type: 'ConfigResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['GetDraftResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + draft: { type: 'DraftResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['GetManyMessagesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + messages: { type: 'MessageResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['GetMessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageWithChannelResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['GetReactionsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + reactions: { type: 'ReactionResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['GetRepliesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + messages: { type: 'MessageResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['GetThreadResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + thread: { type: 'ThreadStateResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['GetUserGroupResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_group: { type: 'UserGroupResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['GroupedChannelsBucket'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channels: { type: 'ChannelStateResponseFields', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['GroupedQueryChannelsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + groups: { type: 'GroupedChannelsBucket', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['HealthCheckEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + me: { type: 'OwnUserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ListBlockListResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + blocklists: { type: 'BlockListResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['ListDevicesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + devices: { type: 'DeviceResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['ListQueuesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + queues: { type: 'ModerationQueueResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['ListUserGroupsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_groups: { type: 'UserGroupResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['MarkReadResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + event: { type: 'MarkReadResponseEvent', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MarkReadResponseEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel_last_message_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + thread: { type: 'ThreadResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MaxStreakChangedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MemberAddedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MemberRemovedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MemberUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MembersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + members: { type: 'ChannelMemberResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageActionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageDeliveredEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageFlagResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + approved_at: { type: 'DatetimeType', isSingle: true }, + + rejected_at: { type: 'DatetimeType', isSingle: true }, + + reviewed_at: { type: 'DatetimeType', isSingle: true }, + + details: { type: 'FlagDetailsResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + moderation_feedback: { type: 'FlagFeedbackResponse', isSingle: true }, + + moderation_result: { type: 'MessageModerationResult', isSingle: true }, + + reviewed_by: { type: 'UserResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageModerationResult'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageNewEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageReadEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + thread: { type: 'ThreadResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + latest_reactions: { type: 'ReactionResponse', isSingle: false }, + + mentioned_users: { type: 'UserResponse', isSingle: false }, + + own_reactions: { type: 'ReactionResponse', isSingle: false }, + + user: { type: 'UserResponse', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + message_text_updated_at: { type: 'DatetimeType', isSingle: true }, + + pin_expires: { type: 'DatetimeType', isSingle: true }, + + pinned_at: { type: 'DatetimeType', isSingle: true }, + + mentioned_groups: { type: 'UserGroupResponse', isSingle: false }, + + thread_participants: { type: 'UserResponse', isSingle: false }, + + draft: { type: 'DraftResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + pinned_by: { type: 'UserResponse', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + quoted_message: { type: 'MessageResponse', isSingle: true }, + + reaction_groups: { type: 'ReactionGroupResponse', isSingle: false }, + + reminder: { type: 'ReminderResponseData', isSingle: true }, + + shared_location: { type: 'SharedLocationResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageUndeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageWithChannelResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + latest_reactions: { type: 'ReactionResponse', isSingle: false }, + + mentioned_users: { type: 'UserResponse', isSingle: false }, + + own_reactions: { type: 'ReactionResponse', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + message_text_updated_at: { type: 'DatetimeType', isSingle: true }, + + pin_expires: { type: 'DatetimeType', isSingle: true }, + + pinned_at: { type: 'DatetimeType', isSingle: true }, + + mentioned_groups: { type: 'UserGroupResponse', isSingle: false }, + + thread_participants: { type: 'UserResponse', isSingle: false }, + + draft: { type: 'DraftResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + pinned_by: { type: 'UserResponse', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + quoted_message: { type: 'MessageResponse', isSingle: true }, + + reaction_groups: { type: 'ReactionGroupResponse', isSingle: false }, + + reminder: { type: 'ReminderResponseData', isSingle: true }, + + shared_location: { type: 'SharedLocationResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ModerationCustomActionEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + review_queue_item: { type: 'ReviewQueueItemResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ModerationFlagResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + review_queue_item: { type: 'ReviewQueueItemResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ModerationFlaggedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ModerationMarkReviewedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + item: { type: 'ReviewQueueItemResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ModerationQueueResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MuteChannelResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channel_mutes: { type: 'ChannelMute', isSingle: false }, + + channel_mute: { type: 'ChannelMute', isSingle: true }, + + own_user: { type: 'OwnUserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MuteResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + mutes: { type: 'UserMuteResponse', isSingle: false }, + + own_user: { type: 'OwnUserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationAddedToChannelEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationChannelDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationChannelMutesUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + me: { type: 'OwnUserResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationChannelTruncatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationInviteAcceptedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationInviteRejectedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationInvitedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationMarkReadEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + thread: { type: 'ThreadResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationMarkUnreadEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + last_read_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationMutesUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + me: { type: 'OwnUserResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationNewMessageEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationRemovedFromChannelEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationThreadMessageNewEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['OwnUserResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + channel_mutes: { type: 'ChannelMute', isSingle: false }, + + devices: { type: 'DeviceResponse', isSingle: false }, + + mutes: { type: 'UserMuteResponse', isSingle: false }, + + deactivated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + last_active: { type: 'DatetimeType', isSingle: true }, + + revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, + + push_preferences: { type: 'PushPreferencesResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PendingMessageEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PendingMessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollClosedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + poll: { type: 'PollResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollResponseData'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + latest_answers: { type: 'PollVoteResponseData', isSingle: false }, + + own_votes: { type: 'PollVoteResponseData', isSingle: false }, + + created_by: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVoteCastedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + poll_vote: { type: 'PollVoteResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVoteChangedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + poll_vote: { type: 'PollVoteResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVoteRemovedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + poll_vote: { type: 'PollVoteResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVoteResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + poll: { type: 'PollResponseData', isSingle: true }, + + vote: { type: 'PollVoteResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVoteResponseData'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVotesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + votes: { type: 'PollVoteResponseData', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['PushPreferencesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + disabled_until: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryAppealsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + items: { type: 'AppealItemResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryBannedUsersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + bans: { type: 'BanResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryChannelsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channels: { type: 'ChannelStateResponseFields', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryDraftsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + drafts: { type: 'DraftResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryFutureChannelBansResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + bans: { type: 'FutureChannelBanResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryMessageFlagsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + flags: { type: 'MessageFlagResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryModerationConfigsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + configs: { type: 'ConfigResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryPollsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + polls: { type: 'PollResponseData', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryReactionsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + reactions: { type: 'ReactionResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryRemindersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + reminders: { type: 'ReminderResponseData', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryReviewQueueResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + items: { type: 'ReviewQueueItemResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryThreadsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + threads: { type: 'ThreadStateResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryUsersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + users: { type: 'FullUserResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueueResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + queue: { type: 'ModerationQueueResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['Reaction'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReactionDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, + + message: { type: 'MessageResponse', isSingle: true }, + + reaction: { type: 'ReactionResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReactionGroupResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + first_reaction_at: { type: 'DatetimeType', isSingle: true }, + + last_reaction_at: { type: 'DatetimeType', isSingle: true }, + + latest_reactions_by: { type: 'ReactionGroupUserResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['ReactionGroupUserResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReactionNewEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, + + message: { type: 'MessageResponse', isSingle: true }, + + reaction: { type: 'ReactionResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReactionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReactionUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + reaction: { type: 'ReactionResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReadStateResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + last_read: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + + last_delivered_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReminderCreatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + reminder: { type: 'ReminderResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReminderDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + reminder: { type: 'ReminderResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReminderNotificationEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + reminder: { type: 'ReminderResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReminderResponseData'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + remind_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReminderUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + reminder: { type: 'ReminderResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['RemoveUserGroupMembersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_group: { type: 'UserGroupResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReviewQueueItemResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + actions: { type: 'ActionLogResponse', isSingle: false }, + + bans: { type: 'BanInfoResponse', isSingle: false }, + + flags: { type: 'ModerationFlagResponse', isSingle: false }, + + completed_at: { type: 'DatetimeType', isSingle: true }, + + escalated_at: { type: 'DatetimeType', isSingle: true }, + + reviewed_at: { type: 'DatetimeType', isSingle: true }, + + appeal: { type: 'AppealItemResponse', isSingle: true }, + + assigned_to: { type: 'UserResponse', isSingle: true }, + + call: { type: 'CallResponse', isSingle: true }, + + entity_creator: { type: 'EntityCreatorResponse', isSingle: true }, + + feeds_v2_reaction: { type: 'Reaction', isSingle: true }, + + feeds_v3_activity: { type: 'FeedsV3ActivityResponse', isSingle: true }, + + feeds_v3_comment: { type: 'FeedsV3CommentResponse', isSingle: true }, + + message: { type: 'ChatMessageResponse', isSingle: true }, + + reaction: { type: 'Reaction', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['Role'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['SearchResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + results: { type: 'SearchResult', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['SearchResult'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'SearchResultMessage', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['SearchResultMessage'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + latest_reactions: { type: 'ReactionResponse', isSingle: false }, + + mentioned_users: { type: 'UserResponse', isSingle: false }, + + own_reactions: { type: 'ReactionResponse', isSingle: false }, + + user: { type: 'UserResponse', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + message_text_updated_at: { type: 'DatetimeType', isSingle: true }, + + pin_expires: { type: 'DatetimeType', isSingle: true }, + + pinned_at: { type: 'DatetimeType', isSingle: true }, + + mentioned_groups: { type: 'UserGroupResponse', isSingle: false }, + + thread_participants: { type: 'UserResponse', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + draft: { type: 'DraftResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + pinned_by: { type: 'UserResponse', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + quoted_message: { type: 'MessageResponse', isSingle: true }, + + reaction_groups: { type: 'ReactionGroupResponse', isSingle: false }, + + reminder: { type: 'ReminderResponseData', isSingle: true }, + + shared_location: { type: 'SharedLocationResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['SearchRolesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + roles: { type: 'Role', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['SearchUserGroupsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_groups: { type: 'UserGroupResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['SendMessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['SendReactionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageResponse', isSingle: true }, + + reaction: { type: 'ReactionResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['SharedLocationResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + end_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['SharedLocationResponseData'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + end_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['SharedLocationsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + active_live_locations: { type: 'SharedLocationResponseData', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['SubmitActionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + appeal_item: { type: 'AppealItemResponse', isSingle: true }, + + item: { type: 'ReviewQueueItemResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ThreadParticipant'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + last_read_at: { type: 'DatetimeType', isSingle: true }, + + last_thread_message_at: { type: 'DatetimeType', isSingle: true }, + + left_thread_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ThreadResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + last_message_at: { type: 'DatetimeType', isSingle: true }, + + thread_participants: { type: 'ThreadParticipant', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + created_by: { type: 'UserResponse', isSingle: true }, + + parent_message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ThreadStateResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + latest_replies: { type: 'MessageResponse', isSingle: false }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + last_message_at: { type: 'DatetimeType', isSingle: true }, + + read: { type: 'ReadStateResponse', isSingle: false }, + + thread_participants: { type: 'ThreadParticipant', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + created_by: { type: 'UserResponse', isSingle: true }, + + draft: { type: 'DraftResponse', isSingle: true }, + + parent_message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ThreadUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + thread: { type: 'ThreadResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['TruncateChannelResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['TypingStartEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['TypingStopEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UnreadCountsChannel'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + last_read: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UnreadCountsThread'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + last_read: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateBlockListResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + blocklist: { type: 'BlockListResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateChannelPartialResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + members: { type: 'ChannelMemberResponse', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateChannelResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + members: { type: 'ChannelMemberResponse', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateMemberPartialResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channel_member: { type: 'ChannelMemberResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateMessagePartialResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateMessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateReminderResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + reminder: { type: 'ReminderResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateThreadPartialResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + thread: { type: 'ThreadResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateUserGroupResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_group: { type: 'UserGroupResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateUsersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + users: { type: 'FullUserResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['UpsertConfigResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + config: { type: 'ConfigResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpsertPushPreferencesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_preferences: { type: 'PushPreferencesResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['UserBannedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + expiration: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + created_by: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserDeactivatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + created_by: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserGroupCreatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserGroupDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserGroupMember'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserGroupMemberAddedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserGroupMemberRemovedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserGroupResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + members: { type: 'UserGroupMember', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['UserGroupUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserMessagesDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserMuteResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + expires: { type: 'DatetimeType', isSingle: true }, + + target: { type: 'UserResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserMutedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + target_users: { type: 'UserResponseCommonFields', isSingle: false }, + + target_user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserPresenceChangedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserReactivatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + created_by: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + deactivated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + last_active: { type: 'DatetimeType', isSingle: true }, + + revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserResponseCommonFields'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + deactivated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + last_active: { type: 'DatetimeType', isSingle: true }, + + revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserUnbannedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + created_by: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserWatchingStartEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserWatchingStopEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['WrappedUnreadCountsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channels: { type: 'UnreadCountsChannel', isSingle: false }, + + threads: { type: 'UnreadCountsThread', isSingle: false }, + }; + return decode(typeMappings, input); +}; diff --git a/src/gen/model-decoders/event-decoder-mapping.ts b/src/gen/model-decoders/event-decoder-mapping.ts new file mode 100644 index 0000000000..c0dc4826a4 --- /dev/null +++ b/src/gen/model-decoders/event-decoder-mapping.ts @@ -0,0 +1,198 @@ +import type { WSEvent } from '../models'; +import { decoders } from '../model-decoders/decoders'; + +const eventDecoderMapping: { + [key in WSEvent['type']]: (data: Record) => WSEvent; +} = { + '*': (data: Record) => decoders.CustomEvent(data), + + 'ai_indicator.clear': (data: Record) => + decoders.AIIndicatorClearEvent(data), + + 'ai_indicator.stop': (data: Record) => decoders.AIIndicatorStopEvent(data), + + 'ai_indicator.update': (data: Record) => + decoders.AIIndicatorUpdateEvent(data), + + 'app.updated': (data: Record) => decoders.AppUpdatedEvent(data), + + 'channel.created': (data: Record) => decoders.ChannelCreatedEvent(data), + + 'channel.deleted': (data: Record) => decoders.ChannelDeletedEvent(data), + + 'channel.frozen': (data: Record) => decoders.ChannelFrozenEvent(data), + + 'channel.hidden': (data: Record) => decoders.ChannelHiddenEvent(data), + + 'channel.kicked': (data: Record) => decoders.ChannelKickedEvent(data), + + 'channel.max_streak_changed': (data: Record) => + decoders.MaxStreakChangedEvent(data), + + 'channel.truncated': (data: Record) => + decoders.ChannelTruncatedEvent(data), + + 'channel.unfrozen': (data: Record) => decoders.ChannelUnFrozenEvent(data), + + 'channel.updated': (data: Record) => decoders.ChannelUpdatedEvent(data), + + 'channel.visible': (data: Record) => decoders.ChannelVisibleEvent(data), + + 'draft.deleted': (data: Record) => decoders.DraftDeletedEvent(data), + + 'draft.updated': (data: Record) => decoders.DraftUpdatedEvent(data), + + 'health.check': (data: Record) => decoders.HealthCheckEvent(data), + + 'member.added': (data: Record) => decoders.MemberAddedEvent(data), + + 'member.removed': (data: Record) => decoders.MemberRemovedEvent(data), + + 'member.updated': (data: Record) => decoders.MemberUpdatedEvent(data), + + 'message.deleted': (data: Record) => decoders.MessageDeletedEvent(data), + + 'message.delivered': (data: Record) => + decoders.MessageDeliveredEvent(data), + + 'message.new': (data: Record) => decoders.MessageNewEvent(data), + + 'message.pending': (data: Record) => decoders.PendingMessageEvent(data), + + 'message.read': (data: Record) => decoders.MessageReadEvent(data), + + 'message.undeleted': (data: Record) => + decoders.MessageUndeletedEvent(data), + + 'message.updated': (data: Record) => decoders.MessageUpdatedEvent(data), + + 'moderation.custom_action': (data: Record) => + decoders.ModerationCustomActionEvent(data), + + 'moderation.flagged': (data: Record) => + decoders.ModerationFlaggedEvent(data), + + 'moderation.mark_reviewed': (data: Record) => + decoders.ModerationMarkReviewedEvent(data), + + 'notification.added_to_channel': (data: Record) => + decoders.NotificationAddedToChannelEvent(data), + + 'notification.channel_deleted': (data: Record) => + decoders.NotificationChannelDeletedEvent(data), + + 'notification.channel_mutes_updated': (data: Record) => + decoders.NotificationChannelMutesUpdatedEvent(data), + + 'notification.channel_truncated': (data: Record) => + decoders.NotificationChannelTruncatedEvent(data), + + 'notification.invite_accepted': (data: Record) => + decoders.NotificationInviteAcceptedEvent(data), + + 'notification.invite_rejected': (data: Record) => + decoders.NotificationInviteRejectedEvent(data), + + 'notification.invited': (data: Record) => + decoders.NotificationInvitedEvent(data), + + 'notification.mark_read': (data: Record) => + decoders.NotificationMarkReadEvent(data), + + 'notification.mark_unread': (data: Record) => + decoders.NotificationMarkUnreadEvent(data), + + 'notification.message_new': (data: Record) => + decoders.NotificationNewMessageEvent(data), + + 'notification.mutes_updated': (data: Record) => + decoders.NotificationMutesUpdatedEvent(data), + + 'notification.reminder_due': (data: Record) => + decoders.ReminderNotificationEvent(data), + + 'notification.removed_from_channel': (data: Record) => + decoders.NotificationRemovedFromChannelEvent(data), + + 'notification.thread_message_new': (data: Record) => + decoders.NotificationThreadMessageNewEvent(data), + + 'poll.closed': (data: Record) => decoders.PollClosedEvent(data), + + 'poll.deleted': (data: Record) => decoders.PollDeletedEvent(data), + + 'poll.updated': (data: Record) => decoders.PollUpdatedEvent(data), + + 'poll.vote_casted': (data: Record) => decoders.PollVoteCastedEvent(data), + + 'poll.vote_changed': (data: Record) => decoders.PollVoteChangedEvent(data), + + 'poll.vote_removed': (data: Record) => decoders.PollVoteRemovedEvent(data), + + 'reaction.deleted': (data: Record) => decoders.ReactionDeletedEvent(data), + + 'reaction.new': (data: Record) => decoders.ReactionNewEvent(data), + + 'reaction.updated': (data: Record) => decoders.ReactionUpdatedEvent(data), + + 'reminder.created': (data: Record) => decoders.ReminderCreatedEvent(data), + + 'reminder.deleted': (data: Record) => decoders.ReminderDeletedEvent(data), + + 'reminder.updated': (data: Record) => decoders.ReminderUpdatedEvent(data), + + 'thread.updated': (data: Record) => decoders.ThreadUpdatedEvent(data), + + 'typing.start': (data: Record) => decoders.TypingStartEvent(data), + + 'typing.stop': (data: Record) => decoders.TypingStopEvent(data), + + 'user.banned': (data: Record) => decoders.UserBannedEvent(data), + + 'user.deactivated': (data: Record) => decoders.UserDeactivatedEvent(data), + + 'user.deleted': (data: Record) => decoders.UserDeletedEvent(data), + + 'user.messages.deleted': (data: Record) => + decoders.UserMessagesDeletedEvent(data), + + 'user.muted': (data: Record) => decoders.UserMutedEvent(data), + + 'user.presence.changed': (data: Record) => + decoders.UserPresenceChangedEvent(data), + + 'user.reactivated': (data: Record) => decoders.UserReactivatedEvent(data), + + 'user.unbanned': (data: Record) => decoders.UserUnbannedEvent(data), + + 'user.updated': (data: Record) => decoders.UserUpdatedEvent(data), + + 'user.watching.start': (data: Record) => + decoders.UserWatchingStartEvent(data), + + 'user.watching.stop': (data: Record) => + decoders.UserWatchingStopEvent(data), + + 'user_group.created': (data: Record) => + decoders.UserGroupCreatedEvent(data), + + 'user_group.deleted': (data: Record) => + decoders.UserGroupDeletedEvent(data), + + 'user_group.member_added': (data: Record) => + decoders.UserGroupMemberAddedEvent(data), + + 'user_group.member_removed': (data: Record) => + decoders.UserGroupMemberRemovedEvent(data), + + 'user_group.updated': (data: Record) => + decoders.UserGroupUpdatedEvent(data), +}; + +export const decodeWSEvent = (data: { type: string } & Record) => { + if (Object.hasOwn(eventDecoderMapping, data.type)) { + return eventDecoderMapping[data.type as WSEvent['type']](data); + } else { + return data; + } +}; diff --git a/src/gen/models/index.ts b/src/gen/models/index.ts new file mode 100644 index 0000000000..c493ed7851 --- /dev/null +++ b/src/gen/models/index.ts @@ -0,0 +1,12673 @@ +import type { + CustomAttachmentData, + CustomChannelData, + CustomEventData, + CustomMemberData, + CustomMessageData, + CustomPollData, + CustomPollOptionData, + CustomReactionData, + CustomThreadData, + CustomUserData, +} from '../../custom_types'; + +type Filters> = + QueryFilters<{ + [Property in keyof FilterConditions]: FilterConditions[Property]['operators'] extends string + ? + | RequireAtLeastOne<{ + [Operator in FilterConditions[Property]['operators']]: + | (Operator extends '$in' | '$nin' + ? Array + : Operator extends '$exists' + ? boolean + : FilterConditions[Property]['type']) + | null; + }> + | FilterConditions[Property]['type'] + | null + : undefined; + }>; + +export type QueryFilters = { + [Key in keyof Operators]?: Operators[Key]; +} & QueryLogicalOperators; + +export type QueryLogicalOperators = { + $and?: ArrayOneOrMore>; + $nor?: ArrayOneOrMore>; + $or?: ArrayTwoOrMore>; +}; + +export type ArrayOneOrMore = { + 0: T; +} & Array; + +export type ArrayTwoOrMore = { + 0: T; + 1: T; +} & Array; + +export type RequireAtLeastOne = { + [K in keyof T]-?: Required> & Partial>; +}[keyof T]; + +export interface AIImageConfig { + enabled: boolean; + + ocr_rules: Array; + + rules: Array; + + async?: boolean; +} + +export interface AIImageLabelDefinition { + description: string; + + group: string; + + key: string; + + label: string; +} + +export interface AIIndicatorClearEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "ai_indicator.clear" in this case + */ + type: string; + + /** + * The ID of the channel + */ + channel_id?: string; + + /** + * The type of the channel + */ + channel_type?: string; + + /** + * The CID of the channel + */ + cid?: string; + + received_at?: Date; +} + +export interface AIIndicatorStopEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "ai_indicator.stop" in this case + */ + type: string; + + /** + * The ID of the channel + */ + channel_id?: string; + + /** + * The type of the channel + */ + channel_type?: string; + + /** + * The CID of the channel + */ + cid?: string; + + received_at?: Date; +} + +export interface AIIndicatorUpdateEvent { + /** + * The state of the AI indicator + */ + ai_state: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The ID of the message + */ + message_id: string; + + custom: CustomEventData; + + /** + * The type of event: "ai_indicator.update" in this case + */ + type: string; + + /** + * Optional message from the AI + */ + ai_message?: string; + + /** + * The ID of the channel + */ + channel_id?: string; + + /** + * The type of the channel + */ + channel_type?: string; + + /** + * The CID of the channel + */ + cid?: string; + + received_at?: Date; +} + +export interface AITextConfig { + enabled: boolean; + + profile: string; + + rules: Array; + + severity_rules: Array; + + async?: boolean; +} + +export interface AIVideoConfig { + enabled: boolean; + + rules: Array; + + async?: boolean; +} + +export interface APIError { + /** + * API error code + */ + code: number; + + /** + * Request duration + */ + duration: string; + + /** + * Message describing an error + */ + message: string; + + /** + * URL with additional information + */ + more_info: string; + + /** + * Response HTTP status code + */ + status_code: number; + + /** + * Additional error-specific information + */ + details: Array; + + /** + * Flag that indicates if the error is unrecoverable, requests that return unrecoverable errors should not be retried, this error only applies to the request that caused it + */ + unrecoverable?: boolean; + + /** + * Additional error info + */ + exception_fields?: Record; +} + +export interface AWSRekognitionRule { + action: 'flag' | 'shadow' | 'remove' | 'bounce' | 'bounce_flag' | 'bounce_remove'; + + label: string; + + min_confidence: number; + + subclassifications?: Record; +} + +export interface Action { + name: string; + + text: string; + + type: string; + + style?: string; + + value?: string; +} + +export interface ActionLogResponse { + /** + * Timestamp when the action was taken + */ + created_at: Date; + + /** + * Unique identifier of the action log + */ + id: string; + + /** + * Reason for the moderation action + */ + reason: string; + + /** + * Classification of who triggered the action (e.g. user, moderator, automod, api_integration) + */ + reporter_type: string; + + /** + * ID of the user who was the target of the action + */ + target_user_id: string; + + /** + * Type of moderation action + */ + type: string; + + /** + * ID of the user who performed the action + */ + user_id: string; + + ai_providers: Array; + + /** + * Additional metadata about the action + */ + custom: Record; + + review_queue_item?: ReviewQueueItemResponse; + + target_user?: UserResponse; + + user?: UserResponse; +} + +export interface ActionSequence { + action: string; + + blur: boolean; + + cooldown_period: number; + + threshold: number; + + time_window: number; + + warning: boolean; + + warning_text: string; +} + +export interface AddUserGroupMembersRequest { + /** + * List of user IDs to add as members + */ + member_ids: Array; + + /** + * Whether to add the members as group admins. Defaults to false + */ + as_admin?: boolean; + + team_id?: string; +} + +export interface AddUserGroupMembersResponse { + duration: string; + + user_group?: UserGroupResponse; +} + +export interface AppEventResponse { + /** + * boolean + */ + auto_translation_enabled: boolean; + + /** + * string + */ + name: string; + + /** + * boolean + */ + async_url_enrich_enabled?: boolean; + + file_upload_config?: FileUploadConfig; + + image_upload_config?: FileUploadConfig; +} + +export interface AppResponseFields { + async_url_enrich_enabled: boolean; + + auto_translation_enabled: boolean; + + id: number; + + name: string; + + placement: string; + + file_upload_config: FileUploadConfig; + + image_upload_config: FileUploadConfig; +} + +export interface AppUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + app: AppEventResponse; + + custom: CustomEventData; + + /** + * The type of event: "app.updated" in this case + */ + type: string; + + received_at?: Date; +} + +export interface AppealItemResponse { + /** + * Reason Text of the Appeal Item + */ + appeal_reason: string; + + /** + * When the flag was created + */ + created_at: Date; + + /** + * ID of the entity + */ + entity_id: string; + + /** + * Type of entity + */ + entity_type: string; + + id: string; + + /** + * Status of the Appeal Item + */ + status: string; + + /** + * When the flag was last updated + */ + updated_at: Date; + + /** + * Text severity level assigned by the AI provider + */ + ai_text_severity?: string; + + /** + * CID of the channel the entity belongs to, if applicable + */ + channel_cid?: string; + + /** + * Moderation policy key that was applied + */ + config_key?: string; + + /** + * Decision Reason of the Appeal Item + */ + decision_reason?: string; + + /** + * Action recommended by the automated moderation system (e.g. flag, remove, shadow) + */ + recommended_action?: string; + + /** + * ID of the review queue item linked to this appeal, if the appeal was submitted with one + */ + review_queue_item_id?: string; + + /** + * Overall content severity score (1–100) + */ + severity?: number; + + /** + * Full chronological history of all moderation actions on the review queue item + */ + actions?: Array; + + /** + * Attachments(e.g. Images) of the Appeal Item + */ + attachments?: Array; + + /** + * Classification labels from automated and manual review + */ + flag_labels?: Array; + + /** + * Types of flags applied to the entity (e.g. user_report, bodyguard) + */ + flag_types?: Array; + + /** + * Per-provider flag records explaining why the action was taken + */ + flags?: Array; + + entity_content?: ModerationPayload; + + moderation_action?: ActionLogResponse; + + original_moderation_action?: ActionLogResponse; + + user?: UserResponse; +} + +export interface AppealRequest { + /** + * Explanation for why the content is being appealed + */ + appeal_reason: string; + + /** + * Unique identifier of the entity being appealed + */ + entity_id: string; + + /** + * Type of entity being appealed (e.g., message, user) + */ + entity_type: string; + + /** + * ID of the review queue item (flagged message) that triggered the ban. Applicable only for user ban appeals. + */ + review_queue_item_id?: string; + + /** + * Array of Attachment URLs(e.g., images) + */ + attachments?: Array; +} + +export interface AppealResponse { + /** + * Unique identifier of the created Appeal item + */ + appeal_id: string; + + duration: string; +} + +export interface Attachment { + custom: CustomAttachmentData; + + asset_url?: string; + + author_icon?: string; + + author_link?: string; + + author_name?: string; + + color?: string; + + fallback?: string; + + footer?: string; + + footer_icon?: string; + + image_url?: string; + + og_scrape_url?: string; + + original_height?: number; + + original_width?: number; + + pretext?: string; + + text?: string; + + thumb_url?: string; + + title?: string; + + title_link?: string; + + /** + * Attachment type (e.g. image, video, url) + */ + type?: string; + + actions?: Array; + + fields?: Array; + + giphy?: Images; +} + +export interface AutomodDetailsResponse { + action?: string; + + original_message_type?: string; + + image_labels?: Array; + + message_details?: FlagMessageDetailsResponse; + + result?: MessageModerationResult; +} + +export interface AutomodPlatformCircumventionConfig { + enabled: boolean; + + rules: Array; + + async?: boolean; +} + +export interface AutomodRule { + action: 'flag' | 'shadow' | 'remove' | 'bounce' | 'bounce_flag' | 'bounce_remove'; + + label: string; + + threshold: number; +} + +export interface AutomodSemanticFiltersConfig { + enabled: boolean; + + rules: Array; + + async?: boolean; +} + +export interface AutomodSemanticFiltersRule { + action: 'flag' | 'shadow' | 'remove' | 'bounce' | 'bounce_flag' | 'bounce_remove'; + + name: string; + + threshold: number; +} + +export interface AutomodToxicityConfig { + enabled: boolean; + + rules: Array; + + async?: boolean; +} + +export interface BanActionRequestPayload { + /** + * Also ban user from all channels this moderator creates in the future + */ + ban_from_future_channels?: boolean; + + /** + * Ban only from specific channel + */ + channel_ban_only?: boolean; + + channel_cid?: string; + + /** + * Message deletion mode: soft, pruning, or hard + */ + + delete_messages?: 'soft' | 'pruning' | 'hard'; + + /** + * Whether to ban by IP address + */ + ip_ban?: boolean; + + /** + * Reason for the ban + */ + reason?: string; + + /** + * Whether this is a shadow ban + */ + shadow?: boolean; + + /** + * Optional: ban user directly without review item + */ + target_user_id?: string; + + /** + * Duration of ban in minutes + */ + timeout?: number; +} + +export interface BanInfoResponse { + /** + * When the ban was created + */ + created_at: Date; + + /** + * When the ban expires + */ + expires?: Date; + + /** + * Reason for the ban + */ + reason?: string; + + /** + * Whether this is a shadow ban + */ + shadow?: boolean; + + created_by?: UserResponse; + + user?: UserResponse; +} + +export interface BanOptions { + delete_messages?: 'soft' | 'pruning' | 'hard'; + + duration?: number; + + ip_ban?: boolean; + + reason?: string; + + shadow_ban?: boolean; +} + +export interface BanRequest { + /** + * ID of the user to ban + */ + target_user_id: string; + + /** + * ID of the user performing the ban + */ + banned_by_id?: string; + + /** + * Channel where the ban applies + */ + channel_cid?: string; + + delete_messages?: 'soft' | 'pruning' | 'hard'; + + /** + * Whether to ban the user's IP address + */ + ip_ban?: boolean; + + /** + * Optional explanation for the ban + */ + reason?: string; + + /** + * Whether this is a shadow ban + */ + shadow?: boolean; + + /** + * Duration of the ban in minutes + */ + timeout?: number; + + banned_by?: UserRequest; +} + +export interface BanResponse { + created_at: Date; + + expires?: Date; + + reason?: string; + + shadow?: boolean; + + banned_by?: UserResponse; + + channel?: ChannelResponse; + + user?: UserResponse; +} + +export interface BlockActionRequestPayload { + /** + * Reason for blocking + */ + reason?: string; +} + +export interface BlockListConfig { + enabled: boolean; + + rules: Array; + + async?: boolean; + + match_substring?: boolean; +} + +export interface BlockListOptions { + /** + * Blocklist behavior. One of: flag, block, shadow_block + */ + + behavior: 'flag' | 'block' | 'shadow_block'; + + /** + * Blocklist name + */ + blocklist: string; +} + +export interface BlockListResponse { + is_confusable_folding_enabled: boolean; + + is_leet_check_enabled: boolean; + + is_plural_check_enabled: boolean; + + is_substring_matching_enabled: boolean; + + /** + * Block list name + */ + name: string; + + /** + * Block list type. One of: regex, domain, domain_allowlist, email, email_allowlist, word + */ + type: string; + + /** + * List of words to block + */ + words: Array; + + /** + * Date/time of creation + */ + created_at?: Date; + + id?: string; + + team?: string; + + /** + * Date/time of the last update + */ + updated_at?: Date; +} + +export interface BlockListRule { + action: + | 'flag' + | 'mask' + | 'mask_flag' + | 'shadow' + | 'remove' + | 'bounce' + | 'bounce_flag' + | 'bounce_remove'; + + name: string; + + team: string; +} + +export interface BlockUsersRequest { + /** + * User id to block + */ + blocked_user_id: string; +} + +export interface BlockUsersResponse { + /** + * User id who blocked another user + */ + blocked_by_user_id: string; + + /** + * User id who got blocked + */ + blocked_user_id: string; + + /** + * Timestamp when the user was blocked + */ + created_at: Date; + + /** + * Duration of the request in milliseconds + */ + duration: string; +} + +export interface BlockedUserResponse { + /** + * ID of the user who got blocked + */ + blocked_user_id: string; + + created_at: Date; + + /** + * ID of the user who blocked another user + */ + user_id: string; + + blocked_user: UserResponse; + + user: UserResponse; +} + +export interface BodyguardProfileSummary { + name: string; + + display_name?: string; + + text_type?: string; +} + +export interface BodyguardRule { + action: + | 'keep' + | 'flag' + | 'mask' + | 'mask_flag' + | 'shadow' + | 'remove' + | 'bounce' + | 'bounce_flag' + | 'bounce_remove'; + + label: string; + + severity_rules: Array; +} + +export interface BodyguardSeverityRule { + action: + | 'keep' + | 'flag' + | 'mask' + | 'shadow' + | 'remove' + | 'bounce' + | 'bounce_flag' + | 'bounce_remove'; + + severity: 'low' | 'medium' | 'high' | 'critical'; +} + +export interface BulkActionAppealsRequest { + /** + * Action to apply: unban, restore, unblock, mark_reviewed, or reject_appeal + */ + + action_type: 'unban' | 'restore' | 'unblock' | 'mark_reviewed' | 'reject_appeal'; + + /** + * List of appeal UUIDs to process + */ + appeal_ids: Array; + + mark_reviewed?: MarkReviewedRequestPayload; + + reject_appeal?: RejectAppealRequestPayload; + + restore?: RestoreActionRequestPayload; + + unban?: UnbanActionRequestPayload; + + unblock?: UnblockActionRequestPayload; +} + +export interface BulkActionAppealsResponse { + duration: string; + + /** + * Appeals that could not be processed, with per-item error messages + */ + errors: Array; + + /** + * Successfully processed appeals + */ + results: Array; +} + +export interface BulkAppealError { + appeal_id: string; + + error: string; +} + +export interface BulkAppealResult { + appeal_id: string; + + appeal_item?: AppealItemResponse; +} + +export interface BulkDeleteActionConfigRequest { + /** + * UUIDs of the action configs to delete + */ + ids: Array; +} + +export interface BulkDeleteActionConfigResponse { + /** + * Number of action configs deleted + */ + deleted: number; + + duration: string; +} + +export interface BulkUpsertActionConfigRequest { + /** + * List of action configs to create or update + */ + action_configs: Array; +} + +export interface BulkUpsertActionConfigResponse { + duration: string; + + /** + * The created or updated action configs in the same order as the request + */ + action_configs: Array; +} + +export interface BypassActionRequest { + enabled?: boolean; +} + +export interface CallActionOptions { + duration?: number; + + flag_reason?: string; + + kick_reason?: string; + + mute_audio?: boolean; + + mute_video?: boolean; + + reason?: string; + + warning_text?: string; +} + +export interface CallCustomPropertyParameters { + operator?: string; + + property_key?: string; +} + +export interface CallResponse { + backstage: boolean; + + captioning: boolean; + + cid: string; + + created_at: Date; + + current_session_id: string; + + id: string; + + recording: boolean; + + transcribing: boolean; + + translating: boolean; + + type: string; + + updated_at: Date; + + blocked_user_ids: Array; + + custom: Record; + + channel_cid?: string; + + ended_at?: Date; + + join_ahead_time_seconds?: number; + + routing_number?: string; + + starts_at?: Date; + + team?: string; + + created_by?: UserResponse; +} + +export interface CallRuleActionSequence { + violation_number?: number; + + actions?: Array; + + call_options?: CallActionOptions; +} + +export interface CallTypeRuleParameters { + call_type?: string; +} + +export interface CallViolationCountParameters { + threshold?: number; + + time_window?: string; +} + +export interface CastPollVoteRequest { + vote?: VoteData; +} + +export interface ChannelConfigOverrides { + blocklist?: string; + + blocklist_behavior?: 'flag' | 'block'; + + /** + * Enable/disable message counting + */ + count_messages?: boolean; + + /** + * Overrides max message length + */ + max_message_length?: number; + + /** + * Overrides the push notification level for this channel + */ + + push_level?: 'all' | 'all_mentions' | 'mentions' | 'direct_mentions' | 'none'; + + /** + * Enables message quotes + */ + quotes?: boolean; + + /** + * Enables or disables reactions + */ + reactions?: boolean; + + /** + * Enables message replies (threads) + */ + replies?: boolean; + + /** + * Enable/disable shared locations + */ + shared_locations?: boolean; + + /** + * Enables or disables typing events + */ + typing_events?: boolean; + + /** + * Enables or disables file uploads + */ + uploads?: boolean; + + /** + * Enables or disables URL enrichment + */ + url_enrichment?: boolean; + + /** + * Enable/disable user message reminders + */ + user_message_reminders?: boolean; + + /** + * List of commands that channel supports + */ + commands?: Array; + + chat_preferences?: ChatPreferences; + + grants?: Record>; +} + +export interface ChannelConfigWithInfo { + automod: 'disabled' | 'simple' | 'AI'; + + automod_behavior: 'flag' | 'block' | 'shadow_block'; + + connect_events: boolean; + + count_messages: boolean; + + created_at: Date; + + custom_events: boolean; + + delivery_events: boolean; + + mark_messages_pending: boolean; + + max_message_length: number; + + mutes: boolean; + + name: string; + + polls: boolean; + + push_notifications: boolean; + + quotes: boolean; + + reactions: boolean; + + read_events: boolean; + + reminders: boolean; + + replies: boolean; + + search: boolean; + + shared_locations: boolean; + + skip_last_msg_update_for_system_msgs: boolean; + + typing_events: boolean; + + updated_at: Date; + + uploads: boolean; + + url_enrichment: boolean; + + user_message_reminders: boolean; + + commands: Array; + + blocklist?: string; + + blocklist_behavior?: 'flag' | 'block' | 'shadow_block'; + + partition_size?: number; + + partition_ttl?: string; + + push_level?: 'all' | 'all_mentions' | 'mentions' | 'direct_mentions' | 'none'; + + allowed_flag_reasons?: Array; + + blocklists?: Array; + + automod_thresholds?: Thresholds; + + chat_preferences?: ChatPreferences; + + grants?: Record>; +} + +export interface ChannelCreatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "channel.created" in this case + */ + type: string; + + /** + * The ID of the channel which was created + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was created + */ + channel_type?: string; + + /** + * The CID of the channel which was created + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface ChannelDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "channel.deleted" in this case + */ + type: string; + + /** + * The ID of the channel which was deleted + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was deleted + */ + channel_type?: string; + + /** + * The CID of the channel which was deleted + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface ChannelFrozenEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "channel.frozen" in this case + */ + type: string; + + /** + * The ID of the channel which was frozen + */ + channel_id?: string; + + /** + * The type of the channel which was frozen + */ + channel_type?: string; + + /** + * The CID of the channel which was frozen + */ + cid?: string; + + received_at?: Date; +} + +export interface ChannelGetOrCreateRequest { + /** + * Whether this channel will be hidden for the user who created the channel or not + */ + hide_for_creator?: boolean; + + /** + * Fetch user presence info + */ + presence?: boolean; + + /** + * Refresh channel state + */ + state?: boolean; + + thread_unread_counts?: boolean; + + /** + * Start watching the channel + */ + watch?: boolean; + + data?: ChannelInput; + + members?: PaginationParams; + + messages?: MessagePaginationParams; + + watchers?: PaginationParams; +} + +export interface ChannelHiddenEvent { + /** + * Whether the history was cleared + */ + clear_history: boolean; + + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "channel.hidden" in this case + */ + type: string; + + /** + * The ID of the channel which was hidden + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was hidden + */ + channel_type?: string; + + /** + * The CID of the channel which was hidden + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface ChannelInput { + /** + * Enable or disable auto translation + */ + auto_translation_enabled?: boolean; + + /** + * Switch auto translation language + */ + auto_translation_language?: string; + + created_by_id?: string; + + disabled?: boolean; + + /** + * Freeze or unfreeze the channel + */ + frozen?: boolean; + + /** + * Team the channel belongs to (if multi-tenant mode is enabled) + */ + team?: string; + + truncated_by_id?: string; + + filter_tags?: Array; + + invites?: Array; + + members?: Array; + + config_overrides?: ChannelConfigOverrides; + + created_by?: UserRequest; + + custom?: CustomChannelData; +} + +export interface ChannelInputRequest { + auto_translation_enabled?: boolean; + + auto_translation_language?: string; + + disabled?: boolean; + + frozen?: boolean; + + team?: string; + + invites?: Array; + + members?: Array; + + config_overrides?: ConfigOverridesRequest; + + created_by?: UserRequest; + + custom?: CustomChannelData; +} + +export interface ChannelKickedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "channel.kicked" in this case + */ + type: string; + + /** + * The ID of the channel which was kicked + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was kicked + */ + channel_type?: string; + + /** + * The CID of the channel which was kicked + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; +} + +export interface ChannelMemberRequest { + user_id: string; + + /** + * Role of the member in the channel + */ + channel_role?: string; + + custom?: CustomMemberData; + + user?: UserResponse; +} + +export interface ChannelMemberResponse { + /** + * Whether member is banned this channel or not + */ + banned: boolean; + + /** + * Role of the member in the channel + */ + channel_role: string; + + /** + * Date/time of creation + */ + created_at: Date; + + notifications_muted: boolean; + + /** + * Whether member is shadow banned in this channel or not + */ + shadow_banned: boolean; + + /** + * Date/time of the last update + */ + updated_at: Date; + + custom: CustomMemberData; + + archived_at?: Date; + + /** + * Expiration date of the ban + */ + ban_expires?: Date; + + deleted_at?: Date; + + /** + * Date when invite was accepted + */ + invite_accepted_at?: Date; + + /** + * Date when invite was rejected + */ + invite_rejected_at?: Date; + + /** + * Whether member was invited or not + */ + invited?: boolean; + + /** + * Whether member is channel moderator or not + */ + is_moderator?: boolean; + + pinned_at?: Date; + + /** + * Permission level of the member in the channel (DEPRECATED: use channel_role instead). One of: member, moderator, admin, owner + */ + role?: string; + + status?: string; + + user_id?: string; + + deleted_messages?: Array; + + user?: UserResponse; +} + +export interface ChannelMessageCountRuleParameters { + operator?: string; + + threshold?: number; +} + +export interface ChannelMute { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * Date/time of mute expiration + */ + expires?: Date; + + channel?: ChannelResponse; + + user?: UserResponse; +} + +export const ChannelOwnCapability = { + BAN_CHANNEL_MEMBERS: 'ban-channel-members', + CAST_POLL_VOTE: 'cast-poll-vote', + CONNECT_EVENTS: 'connect-events', + CREATE_ATTACHMENT: 'create-attachment', + CREATE_MENTION: 'create-mention', + DELETE_ANY_MESSAGE: 'delete-any-message', + DELETE_CHANNEL: 'delete-channel', + DELETE_OWN_MESSAGE: 'delete-own-message', + DELIVERY_EVENTS: 'delivery-events', + FLAG_MESSAGE: 'flag-message', + FREEZE_CHANNEL: 'freeze-channel', + JOIN_CHANNEL: 'join-channel', + LEAVE_CHANNEL: 'leave-channel', + MUTE_CHANNEL: 'mute-channel', + NOTIFY_CHANNEL: 'notify-channel', + NOTIFY_GROUP: 'notify-group', + NOTIFY_HERE: 'notify-here', + NOTIFY_ROLE: 'notify-role', + PIN_MESSAGE: 'pin-message', + QUERY_POLL_VOTES: 'query-poll-votes', + QUOTE_MESSAGE: 'quote-message', + READ_EVENTS: 'read-events', + SEARCH_MESSAGES: 'search-messages', + SEND_CUSTOM_EVENTS: 'send-custom-events', + SEND_LINKS: 'send-links', + SEND_MESSAGE: 'send-message', + SEND_POLL: 'send-poll', + SEND_REACTION: 'send-reaction', + SEND_REPLY: 'send-reply', + SEND_RESTRICTED_VISIBILITY_MESSAGE: 'send-restricted-visibility-message', + SEND_TYPING_EVENTS: 'send-typing-events', + SET_CHANNEL_COOLDOWN: 'set-channel-cooldown', + SHARE_LOCATION: 'share-location', + SKIP_SLOW_MODE: 'skip-slow-mode', + SLOW_MODE: 'slow-mode', + TYPING_EVENTS: 'typing-events', + UPDATE_ANY_MESSAGE: 'update-any-message', + UPDATE_CHANNEL: 'update-channel', + UPDATE_CHANNEL_MEMBERS: 'update-channel-members', + UPDATE_OWN_MESSAGE: 'update-own-message', + UPDATE_THREAD: 'update-thread', + UPLOAD_FILE: 'upload-file', +} as const; + +export type ChannelOwnCapability = + (typeof ChannelOwnCapability)[keyof typeof ChannelOwnCapability]; + +export interface ChannelPushPreferencesResponse { + chat_level?: string; + + disabled_until?: Date; + + chat_preferences?: ChatPreferencesResponse; +} + +export interface ChannelResponse { + /** + * Channel CID (:) + */ + cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + disabled: boolean; + + /** + * Whether channel is frozen or not + */ + frozen: boolean; + + /** + * Channel unique ID + */ + id: string; + + /** + * Type of the channel + */ + type: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * Custom data for this object + */ + custom: CustomChannelData; + + /** + * Whether auto translation is enabled or not + */ + auto_translation_enabled?: boolean; + + /** + * Language to translate to when auto translation is active + */ + auto_translation_language?: string; + + /** + * Whether this channel is blocked by current user or not + */ + blocked?: boolean; + + /** + * Cooldown period after sending each message + */ + cooldown?: number; + + /** + * Date/time of deletion + */ + deleted_at?: Date; + + /** + * Whether this channel is hidden by current user or not + */ + hidden?: boolean; + + /** + * Date since when the message history is accessible + */ + hide_messages_before?: Date; + + /** + * Date of the last message sent + */ + last_message_at?: Date; + + /** + * Number of members in the channel + */ + member_count?: number; + + /** + * Number of messages in the channel + */ + message_count?: number; + + /** + * Date of mute expiration + */ + mute_expires_at?: Date; + + /** + * Whether this channel is muted or not + */ + muted?: boolean; + + /** + * Team the channel belongs to (multi-tenant only) + */ + team?: string; + + /** + * Date of the latest truncation of the channel + */ + truncated_at?: Date; + + /** + * List of filter tags associated with the channel + */ + filter_tags?: Array; + + /** + * List of channel members (max 100) + */ + members?: Array; + + /** + * List of channel capabilities of authenticated user + */ + own_capabilities?: Array; + + config?: ChannelConfigWithInfo; + + created_by?: UserResponse; + + truncated_by?: UserResponse; +} + +export interface ChannelStateResponse { + duration: string; + + members: Array; + + messages: Array; + + pinned_messages: Array; + + threads: Array; + + hidden?: boolean; + + hide_messages_before?: Date; + + watcher_count?: number; + + active_live_locations?: Array; + + pending_messages?: Array; + + read?: Array; + + watchers?: Array; + + channel?: ChannelResponse; + + draft?: DraftResponse; + + membership?: ChannelMemberResponse; + + push_preferences?: ChannelPushPreferencesResponse; +} + +export interface ChannelStateResponseFields { + /** + * List of channel members + */ + members: Array; + + /** + * List of channel messages + */ + messages: Array; + + /** + * List of pinned messages in the channel + */ + pinned_messages: Array; + + threads: Array; + + /** + * Whether this channel is hidden or not + */ + hidden?: boolean; + + /** + * Messages before this date are hidden from the user + */ + hide_messages_before?: Date; + + /** + * Number of channel watchers + */ + watcher_count?: number; + + /** + * Active live locations in the channel + */ + active_live_locations?: Array; + + /** + * Pending messages that this user has sent + */ + pending_messages?: Array; + + /** + * List of read states + */ + read?: Array; + + /** + * List of user who is watching the channel + */ + watchers?: Array; + + channel?: ChannelResponse; + + draft?: DraftResponse; + + membership?: ChannelMemberResponse; + + push_preferences?: ChannelPushPreferencesResponse; +} + +export interface ChannelStopWatchingRequest {} + +export interface ChannelTruncatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "channel.truncated" in this case + */ + type: string; + + /** + * The ID of the channel which was truncated + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was truncated + */ + channel_type?: string; + + /** + * The CID of the channel which was truncated + */ + cid?: string; + + message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + message?: MessageResponse; + + user?: UserResponseCommonFields; +} + +export interface ChannelUnFrozenEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "channel.unfrozen" in this case + */ + type: string; + + /** + * The ID of the channel which was unfrozen + */ + channel_id?: string; + + /** + * The type of the channel which was unfrozen + */ + channel_type?: string; + + /** + * The CID of the channel which was unfrozen + */ + cid?: string; + + received_at?: Date; +} + +export interface ChannelUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "channel.updated" in this case + */ + type: string; + + /** + * The ID of the channel which was updated + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was updated + */ + channel_type?: string; + + /** + * The CID of the channel which was updated + */ + cid?: string; + + message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + message?: MessageResponse; + + user?: UserResponseCommonFields; +} + +export interface ChannelVisibleEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "channel.visible" in this case + */ + type: string; + + /** + * The ID of the channel which was shown + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was shown + */ + channel_type?: string; + + /** + * The CID of the channel which was shown + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface ChatDraftPayloadResponse { + id: string; + + text: string; + + custom: CustomMessageData; + + html?: string; + + mml?: string; + + parent_id?: string; + + poll_id?: string; + + quoted_message_id?: string; + + show_in_channel?: boolean; + + silent?: boolean; + + type?: string; + + attachments?: Array; + + mentioned_users?: Array; +} + +export interface ChatDraftResponse { + channel_cid: string; + + created_at: Date; + + message: ChatDraftPayloadResponse; + + parent_id?: string; + + parent_message?: ChatMessageResponse; + + quoted_message?: ChatMessageResponse; +} + +export interface ChatMessageResponse { + cid: string; + + created_at: Date; + + deleted_reply_count: number; + + html: string; + + id: string; + + mentioned_channel: boolean; + + mentioned_here: boolean; + + pinned: boolean; + + reply_count: number; + + shadowed: boolean; + + silent: boolean; + + text: string; + + type: string; + + updated_at: Date; + + attachments: Array; + + latest_reactions: Array; + + mentioned_users: Array; + + own_reactions: Array; + + restricted_visibility: Array; + + custom: CustomMessageData; + + reaction_counts: Record; + + reaction_scores: Record; + + user: UserResponse; + + command?: string; + + deleted_at?: Date; + + deleted_for_me?: boolean; + + message_text_updated_at?: Date; + + mml?: string; + + parent_id?: string; + + pin_expires?: Date; + + pinned_at?: Date; + + poll_id?: string; + + quoted_message_id?: string; + + show_in_channel?: boolean; + + mentioned_group_ids?: Array; + + mentioned_groups?: Array; + + mentioned_roles?: Array; + + thread_participants?: Array; + + draft?: ChatDraftResponse; + + i18n?: Record; + + image_labels?: Record>; + + member?: ChannelMemberResponse; + + moderation?: ChatModerationV2Response; + + pinned_by?: UserResponse; + + poll?: PollResponseData; + + quoted_message?: ChatMessageResponse; + + reaction_groups?: Record; + + reminder?: ChatReminderResponseData; + + shared_location?: ChatSharedLocationResponseData; +} + +export interface ChatModerationV2Response { + action: string; + + original_text: string; + + blocklist_matched?: string; + + platform_circumvented?: boolean; + + semantic_filter_matched?: string; + + blocklists_matched?: Array; + + image_harms?: Array; + + text_harms?: Array; +} + +export interface ChatPreferences { + channel_mentions?: string; + + default_preference?: string; + + direct_mentions?: string; + + distinct_channel_messages?: string; + + group_mentions?: string; + + here_mentions?: string; + + role_mentions?: string; + + thread_replies?: string; +} + +export interface ChatPreferencesInput { + channel_mentions?: 'all' | 'none'; + + default_preference?: 'all' | 'none'; + + direct_mentions?: 'all' | 'none'; + + group_mentions?: 'all' | 'none'; + + here_mentions?: 'all' | 'none'; + + role_mentions?: 'all' | 'none'; + + thread_replies?: 'all' | 'none'; +} + +export interface ChatPreferencesResponse { + channel_mentions?: string; + + default_preference?: string; + + direct_mentions?: string; + + group_mentions?: string; + + here_mentions?: string; + + role_mentions?: string; + + thread_replies?: string; +} + +export interface ChatReactionGroupResponse { + count: number; + + first_reaction_at: Date; + + last_reaction_at: Date; + + sum_scores: number; + + latest_reactions_by: Array; +} + +export interface ChatReactionGroupUserResponse { + created_at: Date; + + user_id: string; + + user?: UserResponse; +} + +export interface ChatReactionResponse { + created_at: Date; + + message_id: string; + + score: number; + + type: string; + + updated_at: Date; + + user_id: string; + + custom: CustomReactionData; + + user: UserResponse; +} + +export interface ChatReminderResponseData { + channel_cid: string; + + created_at: Date; + + message_id: string; + + updated_at: Date; + + user_id: string; + + remind_at?: Date; + + message?: ChatMessageResponse; + + user?: UserResponse; +} + +export interface ChatSharedLocationResponseData { + channel_cid: string; + + created_at: Date; + + created_by_device_id: string; + + latitude: number; + + longitude: number; + + message_id: string; + + updated_at: Date; + + user_id: string; + + end_at?: Date; + + message?: ChatMessageResponse; +} + +export interface ClosedCaptionRuleParameters { + threshold?: number; + + time_window?: string; + + harm_labels?: Array; + + llm_harm_labels?: Record; +} + +export interface Command { + /** + * Arguments help text, shown in commands auto-completion + */ + args: string; + + /** + * Description, shown in commands auto-completion + */ + description: string; + + /** + * Unique command name + */ + name: string; + + /** + * Set name used for grouping commands + */ + set: string; + + /** + * Date/time of creation + */ + created_at?: Date; + + /** + * Date/time of the last update + */ + updated_at?: Date; +} + +export interface ConfigOverridesRequest { + /** + * Blocklist name + */ + blocklist?: string; + + /** + * Blocklist behavior. One of: flag, block + */ + + blocklist_behavior?: 'flag' | 'block'; + + /** + * Enable/disable message counting + */ + count_messages?: boolean; + + /** + * Maximum message length + */ + max_message_length?: number; + + push_level?: 'all' | 'all_mentions' | 'mentions' | 'direct_mentions' | 'none'; + + /** + * Enable/disable quotes + */ + quotes?: boolean; + + /** + * Enable/disable reactions + */ + reactions?: boolean; + + /** + * Enable/disable replies + */ + replies?: boolean; + + /** + * Enable/disable shared locations + */ + shared_locations?: boolean; + + /** + * Enable/disable typing events + */ + typing_events?: boolean; + + /** + * Enable/disable uploads + */ + uploads?: boolean; + + /** + * Enable/disable URL enrichment + */ + url_enrichment?: boolean; + + /** + * Enable/disable user message reminders + */ + user_message_reminders?: boolean; + + /** + * List of available commands + */ + commands?: Array; + + chat_preferences?: ChatPreferences; + + /** + * Permission grants modifiers + */ + grants?: Record>; +} + +export interface ConfigResponse { + /** + * Whether moderation should be performed asynchronously + */ + async: boolean; + + /** + * When the configuration was created + */ + created_at: Date; + + /** + * Unique identifier for the moderation configuration + */ + key: string; + + /** + * Team associated with the configuration + */ + team: string; + + /** + * When the configuration was last updated + */ + updated_at: Date; + + supported_video_call_harm_types: Array; + + /** + * Configurable image moderation label definitions for dashboard rendering + */ + ai_image_label_definitions?: Array; + + /** + * Names of Bodyguard credential profiles registered on this app. The dashboard uses this list to render the profile picker on the AI Text section. + */ + available_bodyguard_profiles?: Array; + + ai_image_config?: AIImageConfig; + + /** + * Available L2 subclassifications per L1 image moderation label, based on the active provider + */ + ai_image_subclassifications?: Record>; + + ai_text_config?: AITextConfig; + + ai_video_config?: AIVideoConfig; + + automod_platform_circumvention_config?: AutomodPlatformCircumventionConfig; + + automod_semantic_filters_config?: AutomodSemanticFiltersConfig; + + automod_toxicity_config?: AutomodToxicityConfig; + + block_list_config?: BlockListConfig; + + flood_config?: FloodConfig; + + llm_config?: LLMConfig; + + velocity_filter_config?: VelocityFilterConfig; + + video_call_rule_config?: VideoCallRuleConfig; +} + +export interface ConnectUserDetailsRequest { + id: string; + + image?: string; + + invisible?: boolean; + + language?: string; + + name?: string; + + custom?: CustomUserData; + + privacy_settings?: PrivacySettingsResponse; +} + +export interface ContentCountRuleParameters { + threshold?: number; + + time_window?: string; +} + +export interface ContentCustomPropertyCountParameters { + operator?: string; + + property_key?: string; + + threshold?: number; + + time_window?: string; +} + +export interface ContentCustomPropertyParameters { + operator?: string; + + property_key?: string; +} + +export interface CreateBlockListRequest { + /** + * Block list name + */ + name: string; + + /** + * List of words to block + */ + words: Array; + + is_confusable_folding_enabled?: boolean; + + is_leet_check_enabled?: boolean; + + is_plural_check_enabled?: boolean; + + is_substring_matching_enabled?: boolean; + + team?: string; + + /** + * Block list type. One of: regex, domain, domain_allowlist, email, email_allowlist, word + */ + + type?: 'regex' | 'domain' | 'domain_allowlist' | 'email' | 'email_allowlist' | 'word'; +} + +export interface CreateBlockListResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + blocklist?: BlockListResponse; +} + +export interface CreateDeviceRequest { + /** + * Device ID + */ + id: string; + + /** + * Push provider + */ + + push_provider: 'firebase' | 'apn' | 'huawei' | 'xiaomi'; + + /** + * Stable physical device identifier used to deduplicate pushes across push providers (e.g. APNs VoIP and Firebase on the same iOS device). Distinct from 'id', which is the push token. + */ + hardware_id?: string; + + /** + * Push provider name + */ + push_provider_name?: string; + + /** + * When true the token is for Apple VoIP push notifications + */ + voip_token?: boolean; +} + +export interface CreateDraftRequest { + message: MessageRequest; +} + +export interface CreateDraftResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + draft: DraftResponse; +} + +export interface CreateGuestRequest { + user: UserRequest; +} + +export interface CreateGuestResponse { + /** + * the access token to authenticate the user + */ + access_token: string; + + /** + * Duration of the request in milliseconds + */ + duration: string; + + user: UserResponse; +} + +export interface CreatePollOptionRequest { + /** + * Option text + */ + text: string; + + custom?: CustomPollOptionData; +} + +export interface CreatePollRequest { + /** + * The name of the poll + */ + name: string; + + /** + * Indicates whether users can suggest user defined answers + */ + allow_answers?: boolean; + + allow_user_suggested_options?: boolean; + + /** + * A description of the poll + */ + description?: string; + + /** + * Indicates whether users can cast multiple votes + */ + enforce_unique_vote?: boolean; + + id?: string; + + /** + * Indicates whether the poll is open for voting + */ + is_closed?: boolean; + + /** + * Indicates the maximum amount of votes a user can cast + */ + max_votes_allowed?: number; + + voting_visibility?: 'anonymous' | 'public'; + + options?: Array; + + custom?: CustomPollData; +} + +export interface CreateQueueRequest { + name: string; + + type: 'personal_view' | 'operational_queue'; + + description?: string; + + sort?: Array>; + + filters?: Record; +} + +export interface CreateReminderRequest { + remind_at?: Date; +} + +export interface CreateUserGroupRequest { + /** + * The user friendly name of the user group + */ + name: string; + + /** + * An optional description for the group + */ + description?: string; + + /** + * Optional user group ID. If not provided, a UUID v7 will be generated + */ + id?: string; + + /** + * Optional team ID to scope the group to a team + */ + team_id?: string; + + /** + * Optional initial list of user IDs to add as members + */ + member_ids?: Array; +} + +export interface CreateUserGroupResponse { + duration: string; + + user_group?: UserGroupResponse; +} + +export interface CustomActionRequestPayload { + /** + * Custom action identifier + */ + id?: string; + + /** + * Custom action options + */ + options?: Record; +} + +export interface CustomEvent { + created_at: Date; + + custom: CustomEventData; + + type: string; + + received_at?: Date; +} + +export interface Data { + id: string; +} + +export interface DeleteActionConfigResponse { + /** + * Number of action configs deleted (0 or 1) + */ + deleted: number; + + duration: string; +} + +export interface DeleteActivityRequestPayload { + /** + * ID of the activity to delete (alternative to item_id) + */ + entity_id?: string; + + /** + * Type of the entity (required for delete_activity to distinguish v2 vs v3) + */ + entity_type?: string; + + /** + * Whether to permanently delete the activity + */ + hard_delete?: boolean; + + /** + * Reason for deletion + */ + reason?: string; +} + +export interface DeleteChannelResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + channel?: ChannelResponse; +} + +export interface DeleteChannelsRequest { + /** + * All channels that should be deleted + */ + cids: Array; + + /** + * Specify if channels and all ressources should be hard deleted + */ + hard_delete?: boolean; +} + +export interface DeleteChannelsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + task_id?: string; + + /** + * Map of channel IDs and their deletion results + */ + result?: Record; +} + +export interface DeleteChannelsResultResponse { + status: string; + + error?: string; +} + +export interface DeleteCommentRequestPayload { + /** + * ID of the comment to delete (alternative to item_id) + */ + entity_id?: string; + + /** + * Type of the entity + */ + entity_type?: string; + + /** + * Whether to permanently delete the comment + */ + hard_delete?: boolean; + + /** + * Reason for deletion + */ + reason?: string; +} + +export interface DeleteMessageRequestPayload { + /** + * ID of the message to delete (alternative to item_id) + */ + entity_id?: string; + + /** + * Type of the entity + */ + entity_type?: string; + + /** + * Whether to permanently delete the message + */ + hard_delete?: boolean; + + /** + * Reason for deletion + */ + reason?: string; +} + +export interface DeleteMessageResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message: MessageResponse; +} + +export interface DeleteModerationConfigResponse { + duration: string; +} + +export interface DeleteQueueRequest {} + +export interface DeleteReactionRequestPayload { + /** + * ID of the reaction to delete (alternative to item_id) + */ + entity_id?: string; + + /** + * Type of the entity + */ + entity_type?: string; + + /** + * Whether to permanently delete the reaction + */ + hard_delete?: boolean; + + /** + * Reason for deletion + */ + reason?: string; +} + +export interface DeleteReactionResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message: MessageResponse; + + reaction: ReactionResponse; +} + +export interface DeleteReminderResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; +} + +export interface DeleteUserRequestPayload { + /** + * Also delete all user conversations + */ + delete_conversation_channels?: boolean; + + /** + * Delete flagged feeds content + */ + delete_feeds_content?: boolean; + + /** + * ID of the user to delete (alternative to item_id) + */ + entity_id?: string; + + /** + * Type of the entity + */ + entity_type?: string; + + /** + * Whether to permanently delete the user + */ + hard_delete?: boolean; + + /** + * Also delete all user messages + */ + mark_messages_deleted?: boolean; + + /** + * Reason for deletion + */ + reason?: string; +} + +export interface DeliveredMessagePayload { + cid?: string; + + id?: string; +} + +export interface DeliveryReceiptsResponse { + enabled: boolean; +} + +export interface DeviceResponse { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Device ID + */ + id: string; + + /** + * Push provider + */ + push_provider: string; + + /** + * User ID + */ + user_id: string; + + /** + * Whether device is disabled or not + */ + disabled?: boolean; + + /** + * Reason explaining why device had been disabled + */ + disabled_reason?: string; + + /** + * Stable physical device identifier used to deduplicate pushes across push providers + */ + hardware_id?: string; + + /** + * Push provider name + */ + push_provider_name?: string; + + /** + * When true the token is for Apple VoIP push notifications + */ + voip?: boolean; +} + +export interface DraftDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "draft.deleted" in this case + */ + type: string; + + /** + * The CID of the channel where the draft was created + */ + cid?: string; + + /** + * The ID of the parent message + */ + parent_id?: string; + + received_at?: Date; + + draft?: DraftResponse; +} + +export interface DraftPayloadResponse { + /** + * Message ID is unique string identifier of the message + */ + id: string; + + /** + * Text of the message + */ + text: string; + + custom: CustomMessageData; + + /** + * Contains HTML markup of the message + */ + html?: string; + + /** + * MML content of the message + */ + mml?: string; + + /** + * ID of parent message (thread) + */ + parent_id?: string; + + /** + * Identifier of the poll to include in the message + */ + poll_id?: string; + + quoted_message_id?: string; + + /** + * Whether thread reply should be shown in the channel as well + */ + show_in_channel?: boolean; + + /** + * Whether message is silent or not + */ + silent?: boolean; + + /** + * Contains type of the message. One of: regular, system + */ + type?: string; + + /** + * Array of message attachments + */ + attachments?: Array; + + /** + * List of mentioned users + */ + mentioned_users?: Array; +} + +export interface DraftResponse { + channel_cid: string; + + created_at: Date; + + message: DraftPayloadResponse; + + parent_id?: string; + + channel?: ChannelResponse; + + parent_message?: MessageResponse; + + quoted_message?: MessageResponse; +} + +export interface DraftUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "draft.updated" in this case + */ + type: string; + + /** + * The CID of the channel where the draft was created/updated + */ + cid?: string; + + /** + * The ID of the parent message + */ + parent_id?: string; + + received_at?: Date; + + draft?: DraftResponse; +} + +export interface EnrichedActivity { + foreign_id?: string; + + id?: string; + + score?: number; + + verb?: string; + + to?: Array; + + actor?: Data; + + latest_reactions?: Record>; + + object?: Data; + + origin?: Data; + + own_reactions?: Record>; + + reaction_counts?: Record; + + target?: Data; +} + +export interface EnrichedReaction { + activity_id: string; + + kind: string; + + user_id: string; + + id?: string; + + parent?: string; + + target_feeds?: Array; + + children_counts?: Record; + + created_at?: Time; + + data?: Record; + + latest_children?: Record>; + + own_children?: Record>; + + updated_at?: Time; + + user?: Data; +} + +export interface EntityCreatorResponse { + /** + * Number of minor actions performed on the user + */ + ban_count: number; + + banned: boolean; + + created_at: Date; + + /** + * Number of major actions performed on the user + */ + deleted_content_count: number; + + /** + * Number of flag actions performed on the user + */ + flagged_count: number; + + id: string; + + language: string; + + online: boolean; + + role: string; + + updated_at: Date; + + blocked_user_ids: Array; + + teams: Array; + + custom: CustomUserData; + + avg_response_time?: number; + + deactivated_at?: Date; + + deleted_at?: Date; + + image?: string; + + last_active?: Date; + + name?: string; + + revoke_tokens_issued_before?: Date; + + teams_role?: Record; +} + +export interface EscalatePayload { + /** + * Additional context for the reviewer + */ + notes?: string; + + /** + * Priority of the escalation (low, medium, high) + */ + priority?: string; + + /** + * Reason for the escalation (from configured escalation_reasons) + */ + reason?: string; +} + +export interface EscalationMetadata { + notes?: string; + + priority?: string; + + reason?: string; +} + +export interface EventRequest { + type: string; + + parent_id?: string; + + custom?: CustomEventData; +} + +export interface EventResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + event: WSEvent; +} + +export interface FeedsActivityLocation { + lat: number; + + lng: number; +} + +export interface FeedsBookmarkResponse { + created_at: Date; + + object_id: string; + + object_type: string; + + updated_at: Date; + + user: UserResponse; + + activity_id?: string; + + custom?: Record; +} + +export interface FeedsEnrichedCollectionResponse { + created_at: Date; + + id: string; + + name: string; + + status: string; + + updated_at: Date; + + user_id: string; + + custom: Record; +} + +export interface FeedsFeedResponse { + activity_count: number; + + created_at: Date; + + description: string; + + feed: string; + + follower_count: number; + + following_count: number; + + group_id: string; + + id: string; + + member_count: number; + + name: string; + + pin_count: number; + + updated_at: Date; + + created_by: UserResponse; + + deleted_at?: Date; + + visibility?: string; + + filter_tags?: Array; + + custom?: Record; + + location?: FeedsActivityLocation; +} + +export interface FeedsNotificationComment { + comment: string; + + id: string; + + user_id: string; + + attachments?: Array; +} + +export interface FeedsNotificationContext { + target?: FeedsNotificationTarget; + + trigger?: FeedsNotificationTrigger; +} + +export interface FeedsNotificationParentActivity { + id: string; + + text?: string; + + type?: string; + + user_id?: string; + + attachments?: Array; +} + +export interface FeedsNotificationTarget { + id: string; + + name?: string; + + text?: string; + + type?: string; + + user_id?: string; + + attachments?: Array; + + comment?: FeedsNotificationComment; + + custom?: Record; + + parent_activity?: FeedsNotificationParentActivity; +} + +export interface FeedsNotificationTrigger { + text: string; + + type: string; + + comment?: FeedsNotificationComment; + + custom?: Record; +} + +export interface FeedsPreferences { + /** + * Push notification preference for comments on user's activities. One of: all, none + */ + + comment?: 'all' | 'none'; + + /** + * Push notification preference for mentions in comments. One of: all, none + */ + + comment_mention?: 'all' | 'none'; + + /** + * Push notification preference for reactions on comments. One of: all, none + */ + + comment_reaction?: 'all' | 'none'; + + /** + * Push notification preference for replies to comments. One of: all, none + */ + + comment_reply?: 'all' | 'none'; + + /** + * Push notification preference for new followers. One of: all, none + */ + + follow?: 'all' | 'none'; + + /** + * Push notification preference for mentions in activities. One of: all, none + */ + + mention?: 'all' | 'none'; + + /** + * Push notification preference for reactions on user's activities or comments. One of: all, none + */ + + reaction?: 'all' | 'none'; + + /** + * Push notification preferences for custom activity types. Map of activity type to preference (all or none) + */ + custom_activity_types?: Record; +} + +export interface FeedsPreferencesResponse { + comment?: string; + + comment_mention?: string; + + comment_reaction?: string; + + comment_reply?: string; + + follow?: string; + + mention?: string; + + reaction?: string; + + custom_activity_types?: Record; +} + +export interface FeedsReactionGroupResponse { + count: number; + + first_reaction_at: Date; + + last_reaction_at: Date; +} + +export interface FeedsReactionResponse { + activity_id: string; + + created_at: Date; + + type: string; + + updated_at: Date; + + user: UserResponse; + + comment_id?: string; + + custom?: Record; +} + +export interface FeedsV3ActivityResponse { + bookmark_count: number; + + comment_count: number; + + created_at: Date; + + hidden: boolean; + + id: string; + + popularity: number; + + preview: boolean; + + reaction_count: number; + + restrict_replies: string; + + score: number; + + share_count: number; + + type: string; + + updated_at: Date; + + visibility: string; + + attachments: Array; + + comments: Array; + + feeds: Array; + + filter_tags: Array; + + interest_tags: Array; + + latest_reactions: Array; + + mentioned_users: Array; + + own_bookmarks: Array; + + own_reactions: Array; + + collections: Record; + + custom: Record; + + reaction_groups: Record; + + search_data: Record; + + user: UserResponse; + + deleted_at?: Date; + + edited_at?: Date; + + expires_at?: Date; + + friend_reaction_count?: number; + + is_read?: boolean; + + is_seen?: boolean; + + is_watched?: boolean; + + moderation_action?: string; + + selector_source?: string; + + text?: string; + + visibility_tag?: string; + + friend_reactions?: Array; + + current_feed?: FeedsFeedResponse; + + location?: FeedsActivityLocation; + + metrics?: Record; + + moderation?: ModerationV2Response; + + notification_context?: FeedsNotificationContext; + + parent?: FeedsV3ActivityResponse; + + poll?: PollResponseData; + + score_vars?: Record; +} + +export interface FeedsV3CommentResponse { + bookmark_count: number; + + confidence_score: number; + + created_at: Date; + + downvote_count: number; + + id: string; + + object_id: string; + + object_type: string; + + reaction_count: number; + + reply_count: number; + + score: number; + + status: string; + + updated_at: Date; + + upvote_count: number; + + mentioned_users: Array; + + own_reactions: Array; + + user: UserResponse; + + controversy_score?: number; + + deleted_at?: Date; + + edited_at?: Date; + + parent_id?: string; + + text?: string; + + attachments?: Array; + + latest_reactions?: Array; + + custom?: Record; + + moderation?: ModerationV2Response; + + reaction_groups?: Record; +} + +export interface Field { + short: boolean; + + title: string; + + value: string; +} + +export interface FileUploadConfig { + size_limit: number; + + allowed_file_extensions: Array; + + allowed_mime_types: Array; + + blocked_file_extensions: Array; + + blocked_mime_types: Array; +} + +export interface FileUploadRequest { + /** + * file field + */ + file?: string; + + user?: OnlyUserID; +} + +export interface FileUploadResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * URL to the uploaded asset. Should be used to put to `asset_url` attachment field + */ + file?: string; + + /** + * URL of the file thumbnail for supported file formats. Should be put to `thumb_url` attachment field + */ + thumb_url?: string; +} + +export interface FilterConfigResponse { + /** + * LLM moderation labels available as filter values + */ + llm_labels: Array; + + /** + * AI text moderation labels available as filter values + */ + ai_text_labels?: Array; + + /** + * Moderation config keys present in the queue, available as filter values + */ + config_keys?: Array; + + /** + * The moderation_payload.custom keys the app has configured as review-queue filter chips (via moderation_dashboard_preferences.filterable_custom_keys). Discovery hint for the dashboard only — the filter accepts any custom key regardless of this list. + */ + filterable_custom_keys?: Array; +} + +export interface FlagCountRuleParameters { + threshold?: number; +} + +export interface FlagDetailsResponse { + original_text: string; + + automod?: AutomodDetailsResponse; + + extra?: Record; +} + +export interface FlagFeedbackResponse { + created_at: Date; + + message_id: string; + + labels: Array; +} + +export interface FlagItemResponse { + duration: string; + + /** + * Unique identifier of the created moderation item + */ + item_id: string; +} + +export interface FlagMessageDetailsResponse { + pin_changed?: boolean; + + should_enrich?: boolean; + + skip_push?: boolean; + + updated_by_id?: string; +} + +export interface FlagRequest { + /** + * Unique identifier of the entity being flagged + */ + entity_id: string; + + /** + * Type of entity being flagged (e.g., message, user) + */ + entity_type: string; + + /** + * ID of the user who created the flagged entity + */ + entity_creator_id?: string; + + /** + * Optional explanation for why the content is being flagged + */ + reason?: string; + + /** + * Additional metadata about the flag + */ + custom?: Record; + + moderation_payload?: ModerationPayload; +} + +export interface FlagUserOptions { + reason?: string; +} + +export interface FloodConfig { + identical?: FloodIdenticalConfig; + + similar?: FloodSimilarConfig; +} + +export interface FloodIdenticalConfig { + action: string; + + enabled: boolean; + + threshold: number; + + time_window: string; +} + +export interface FloodSimilarConfig { + action: string; + + enabled: boolean; + + similarity_distance: number; + + threshold: number; + + time_window: string; +} + +export interface FullUserResponse { + banned: boolean; + + created_at: Date; + + id: string; + + invisible: boolean; + + language: string; + + online: boolean; + + role: string; + + shadow_banned: boolean; + + total_unread_count: number; + + unread_channels: number; + + unread_count: number; + + unread_threads: number; + + updated_at: Date; + + blocked_user_ids: Array; + + channel_mutes: Array; + + devices: Array; + + mutes: Array; + + teams: Array; + + custom: CustomUserData; + + avg_response_time?: number; + + ban_expires?: Date; + + deactivated_at?: Date; + + deleted_at?: Date; + + image?: string; + + last_active?: Date; + + name?: string; + + revoke_tokens_issued_before?: Date; + + latest_hidden_channels?: Array; + + privacy_settings?: PrivacySettingsResponse; + + teams_role?: Record; +} + +export interface FutureChannelBanResponse { + created_at: Date; + + expires?: Date; + + reason?: string; + + shadow?: boolean; + + banned_by?: UserResponse; + + user?: UserResponse; +} + +export interface GetActionConfigResponse { + duration: string; + + /** + * Moderation action configs grouped by entity type, sorted by order ascending + */ + action_config: Record>; +} + +export interface GetAppealResponse { + duration: string; + + item?: AppealItemResponse; +} + +export interface GetApplicationResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + app: AppResponseFields; +} + +export interface GetBlockedUsersResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Array of blocked user object + */ + blocks: Array; +} + +export interface GetConfigResponse { + duration: string; + + config?: ConfigResponse; +} + +export interface GetDraftResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + draft: DraftResponse; +} + +export interface GetManyMessagesResponse { + duration: string; + + /** + * List of messages + */ + messages: Array; +} + +export interface GetMessageResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message: MessageWithChannelResponse; + + pending_message_metadata?: Record; +} + +export interface GetOGResponse { + duration: string; + + custom: CustomAttachmentData; + + /** + * URL of detected video or audio + */ + asset_url?: string; + + author_icon?: string; + + /** + * og:site + */ + author_link?: string; + + /** + * og:site_name + */ + author_name?: string; + + color?: string; + + fallback?: string; + + footer?: string; + + footer_icon?: string; + + /** + * URL of detected image + */ + image_url?: string; + + /** + * extracted url from the text + */ + og_scrape_url?: string; + + original_height?: number; + + original_width?: number; + + pretext?: string; + + /** + * og:description + */ + text?: string; + + /** + * URL of detected thumb image + */ + thumb_url?: string; + + /** + * og:title + */ + title?: string; + + /** + * og:url + */ + title_link?: string; + + /** + * Attachment type, could be empty, image, audio or video + */ + type?: string; + + actions?: Array; + + fields?: Array; + + giphy?: Images; +} + +export interface GetReactionsResponse { + duration: string; + + /** + * List of reactions + */ + reactions: Array; +} + +export interface GetRepliesResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + messages: Array; +} + +export interface GetThreadResponse { + duration: string; + + thread: ThreadStateResponse; +} + +export interface GetUserGroupResponse { + duration: string; + + user_group?: UserGroupResponse; +} + +export interface GoogleVisionConfig { + enabled?: boolean; +} + +export interface GroupedChannelsBucket { + /** + * Channels returned for this bucket + */ + channels: Array; + + /** + * Cursor for the next page of this group + */ + next?: string; + + /** + * Cursor for the previous page of this group + */ + prev?: string; + + /** + * Unread channels currently classified into this bucket + */ + unread_channels?: number; +} + +export interface GroupedChannelsGroupRequest { + limit?: number; + + next?: string; + + prev?: string; +} + +export interface GroupedQueryChannelsRequest { + /** + * Default max channels per group (default 10) + */ + limit?: number; + + /** + * Whether to subscribe to presence events for channel members + */ + presence?: boolean; + + /** + * Whether to start watching found channels or not + */ + watch?: boolean; + + /** + * Groups to return, keyed by group name. Each group can define limit, next, or prev. 'next' and 'prev' cursors are only allowed when the request contains exactly one group; multi-group pagination is rejected. + */ + groups?: Record; +} + +export interface GroupedQueryChannelsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Predefined channel groups keyed by group name + */ + groups: Record; +} + +export interface HarmConfig { + cooldown_period: number; + + severity: number; + + threshold: number; + + action_sequences: Array; + + harm_types: Array; +} + +export interface HealthCheckEvent { + connection_id: string; + + created_at: Date; + + custom: CustomEventData; + + type: string; + + cid?: string; + + received_at?: Date; + + me?: OwnUserResponse; +} + +export interface HideChannelRequest { + /** + * Whether to clear message history of the channel or not + */ + clear_history?: boolean; +} + +export interface HideChannelResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; +} + +export interface ImageContentParameters { + label_operator?: string; + + min_confidence?: number; + + harm_labels?: Array; +} + +export interface ImageData { + frames: string; + + height: string; + + size: string; + + url: string; + + width: string; +} + +export interface ImageRuleParameters { + min_confidence?: number; + + threshold?: number; + + time_window?: string; + + harm_labels?: Array; +} + +export interface ImageSize { + /** + * Crop mode. One of: top, bottom, left, right, center + */ + crop?: string; + + /** + * Target image height + */ + height?: number; + + /** + * Resize method. One of: clip, crop, scale, fill + */ + resize?: string; + + /** + * Target image width + */ + width?: number; +} + +export interface ImageUploadRequest { + file?: string; + + /** + * field with JSON-encoded array of image size configurations + */ + upload_sizes?: Array; + + user?: OnlyUserID; +} + +export interface ImageUploadResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + file?: string; + + thumb_url?: string; + + /** + * Array of image size configurations + */ + upload_sizes?: Array; +} + +export interface Images { + fixed_height: ImageData; + + fixed_height_downsampled: ImageData; + + fixed_height_still: ImageData; + + fixed_width: ImageData; + + fixed_width_downsampled: ImageData; + + fixed_width_still: ImageData; + + original: ImageData; +} + +export interface KeyframeOCRRuleParameters { + threshold?: number; + + time_window?: string; + + harm_labels?: Array; +} + +export interface KeyframeRuleParameters { + min_confidence?: number; + + threshold?: number; + + time_window?: string; + + harm_labels?: Array; +} + +export interface LLMConfig { + enabled: boolean; + + rules: Array; + + app_context?: string; + + async?: boolean; + + severity_descriptions?: Record; +} + +export interface LLMRule { + action: + | 'flag' + | 'shadow' + | 'remove' + | 'bounce' + | 'bounce_flag' + | 'bounce_remove' + | 'keep'; + + description: string; + + label: string; + + severity_rules: Array; +} + +export interface LabelResponse { + name: string; + + harm_labels?: Array; + + phrase_list_ids?: Array; +} + +export interface LabelThresholds { + /** + * Threshold for automatic message block + */ + block?: number; + + /** + * Threshold for automatic message flag + */ + flag?: number; +} + +export interface ListBlockListResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + blocklists: Array; +} + +export interface ListDevicesResponse { + duration: string; + + /** + * List of devices + */ + devices: Array; +} + +export interface ListQueuesResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + queues: Array; +} + +export interface ListUserGroupsResponse { + duration: string; + + /** + * List of user groups + */ + user_groups: Array; +} + +export interface MarkChannelsReadRequest { + /** + * Map of channel ID to last read message ID + */ + read_by_channel?: Record; +} + +export interface MarkDeliveredRequest { + latest_delivered_messages?: Array; +} + +export interface MarkDeliveredResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; +} + +export interface MarkReadRequest { + /** + * ID of the message that is considered last read by client + */ + message_id?: string; + + /** + * Optional Thread ID to specifically mark a given thread as read + */ + thread_id?: string; +} + +export interface MarkReadResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + event?: MarkReadResponseEvent; +} + +export interface MarkReadResponseEvent { + channel_id: string; + + channel_type: string; + + cid: string; + + created_at: Date; + + type: string; + + channel_last_message_at?: Date; + + last_read_message_id?: string; + + team?: string; + + channel?: ChannelResponse; + + thread?: ThreadResponse; + + user?: UserResponseCommonFields; +} + +export interface MarkReviewedRequestPayload { + /** + * Maximum content items to mark as reviewed + */ + content_to_mark_as_reviewed_limit?: number; + + /** + * Reason for the appeal decision + */ + decision_reason?: string; + + /** + * Skip marking content as reviewed + */ + disable_marking_content_as_reviewed?: boolean; +} + +export interface MarkUnreadRequest { + /** + * ID of the message from where the channel is marked unread + */ + message_id?: string; + + /** + * Timestamp of the message from where the channel is marked unread + */ + message_timestamp?: Date; + + /** + * Mark a thread unread, specify one of the thread, message timestamp, or message id + */ + thread_id?: string; +} + +export interface MaxStreakChangedEvent { + created_at: Date; + + custom: CustomEventData; + + type: string; + + received_at?: Date; +} + +export interface MemberAddedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "member.added" in this case + */ + type: string; + + /** + * The ID of the channel to which the member was added + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel to which the member was added + */ + channel_type?: string; + + /** + * The CID of the channel to which the member was added + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface MemberRemovedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "member.removed" in this case + */ + type: string; + + /** + * The ID of the channel from which the member was removed + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel from which the member was removed + */ + channel_type?: string; + + /** + * The CID of the channel from which the member was removed + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface MemberUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "member.updated" in this case + */ + type: string; + + /** + * The ID of the channel in which the member was updated + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel in which the member was updated + */ + channel_type?: string; + + /** + * The CID of the channel in which the member was updated + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface MembersResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of found members + */ + members: Array; +} + +export interface MessageActionRequest { + /** + * ReadOnlyData to execute command with + */ + form_data: Record; +} + +export interface MessageActionResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message?: MessageResponse; +} + +export interface MessageChangeSet { + attachments: boolean; + + custom: boolean; + + html: boolean; + + mentioned_user_ids: boolean; + + mml: boolean; + + pin: boolean; + + quoted_message_id: boolean; + + silent: boolean; + + text: boolean; +} + +export interface MessageDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Whether the message was hard deleted + */ + hard_delete: boolean; + + message_id: string; + + custom: CustomEventData; + + message: MessageResponse; + + /** + * The type of event: "message.deleted" in this case + */ + type: string; + + /** + * The ID of the channel where the message was sent + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel where the message was sent + */ + channel_type?: string; + + /** + * The CID of the channel where the message was sent + */ + cid?: string; + + /** + * Whether the message was deleted only for the current user + */ + deleted_for_me?: boolean; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface MessageDeliveredEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "message.delivered" in this case + */ + type: string; + + /** + * The ID of the channel where the message was read + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel where the message was read + */ + channel_type?: string; + + /** + * The CID of the channel where the message was read + */ + cid?: string; + + /** + * The time when the message was delivered + */ + last_delivered_at?: string; + + /** + * The ID of the last delivered message + */ + last_delivered_message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel?: ChannelResponse; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface MessageFlagResponse { + created_at: Date; + + created_by_automod: boolean; + + updated_at: Date; + + approved_at?: Date; + + reason?: string; + + rejected_at?: Date; + + reviewed_at?: Date; + + custom?: Record; + + details?: FlagDetailsResponse; + + message?: MessageResponse; + + moderation_feedback?: FlagFeedbackResponse; + + moderation_result?: MessageModerationResult; + + reviewed_by?: UserResponse; + + user?: UserResponse; +} + +export interface MessageModerationResult { + /** + * Action taken by automod + */ + action: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * ID of the message + */ + message_id: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * Whether user has bad karma + */ + user_bad_karma: boolean; + + /** + * Karma of the user + */ + user_karma: number; + + /** + * Word that was blocked + */ + blocked_word?: string; + + /** + * Name of the blocklist + */ + blocklist_name?: string; + + /** + * User who moderated the message + */ + moderated_by?: string; + + ai_moderation_response?: ModerationResponse; + + moderation_thresholds?: Thresholds; +} + +export interface MessageNewEvent { + /** + * Date/time of creation + */ + created_at: Date; + + message_id: string; + + /** + * The number of watchers + */ + watcher_count: number; + + custom: CustomEventData; + + message: MessageResponse; + + /** + * The type of event: "message.new" in this case + */ + type: string; + + /** + * The ID of the channel where the message was sent + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel where the message was sent + */ + channel_type?: string; + + /** + * The CID of the channel where the message was sent + */ + cid?: string; + + /** + * The author of the parent message + */ + parent_author?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + total_unread_count?: number; + + unread_channels?: number; + + /** + * The number of unread messages + */ + unread_count?: number; + + /** + * The participants of the thread + */ + thread_participants?: Array; + + channel?: ChannelResponse; + + channel_custom?: CustomChannelData; + + grouped_unread_channels?: Record; + + user?: UserResponseCommonFields; +} + +export interface MessageOptions { + include_thread_participants?: boolean; +} + +export interface MessagePaginationParams { + /** + * The timestamp to get messages with a created_at timestamp greater than + */ + created_at_after?: Date; + + /** + * The timestamp to get messages with a created_at timestamp greater than or equal to + */ + created_at_after_or_equal?: Date; + + /** + * The result will be a set of messages, that are both older and newer than the created_at timestamp provided, distributed evenly around the timestamp + */ + created_at_around?: Date; + + /** + * The timestamp to get messages with a created_at timestamp smaller than + */ + created_at_before?: Date; + + /** + * The timestamp to get messages with a created_at timestamp smaller than or equal to + */ + created_at_before_or_equal?: Date; + + /** + * The result will be a set of messages, that are both older and newer than the message with the provided ID, and the message with the ID provided will be in the middle of the set + */ + id_around?: string; + + /** + * The ID of the message to get messages with a timestamp greater than + */ + id_gt?: string; + + /** + * The ID of the message to get messages with a timestamp greater than or equal to + */ + id_gte?: string; + + /** + * The ID of the message to get messages with a timestamp smaller than + */ + id_lt?: string; + + /** + * The ID of the message to get messages with a timestamp smaller than or equal to + */ + id_lte?: string; + + /** + * The maximum number of messages to return (max limit + */ + limit?: number; +} + +export interface MessageReadEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "message.read" in this case + */ + type: string; + + /** + * The ID of the channel where the message was read + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel where the message was read + */ + channel_type?: string; + + /** + * The CID of the channel where the message was read + */ + cid?: string; + + /** + * The ID of the last read message + */ + last_read_message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel?: ChannelResponse; + + channel_custom?: CustomChannelData; + + thread?: ThreadResponse; + + user?: UserResponseCommonFields; +} + +export interface MessageRequest { + /** + * Message ID is unique string identifier of the message + */ + id?: string; + + mentioned_channel?: boolean; + + mentioned_here?: boolean; + + /** + * Should be empty if `text` is provided. Can only be set when using server-side API + */ + mml?: string; + + /** + * ID of parent message (thread) + */ + parent_id?: string; + + /** + * Date when pinned message expires + */ + pin_expires?: Date; + + /** + * Whether message is pinned or not + */ + pinned?: boolean; + + /** + * Date when message got pinned + */ + pinned_at?: Date; + + /** + * Identifier of the poll to include in the message + */ + poll_id?: string; + + quoted_message_id?: string; + + /** + * Whether thread reply should be shown in the channel as well + */ + show_in_channel?: boolean; + + /** + * Whether message is silent or not + */ + silent?: boolean; + + /** + * Text of the message. Should be empty if `mml` is provided + */ + text?: string; + + /** + * Contains type of the message. One of: regular, system + */ + + type?: "''" | 'regular' | 'system'; + + /** + * Array of message attachments + */ + attachments?: Array; + + /** + * List of user group IDs to mention. Group members who are also channel members will receive push notifications. Max 10 groups + */ + mentioned_group_ids?: Array; + + mentioned_roles?: Array; + + /** + * Array of user IDs to mention + */ + mentioned_users?: Array; + + /** + * A list of user ids that have restricted visibility to the message + */ + restricted_visibility?: Array; + + custom?: CustomMessageData; + + shared_location?: SharedLocation; +} + +export interface MessageResponse { + /** + * Channel unique identifier in : format + */ + cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + deleted_reply_count: number; + + /** + * Contains HTML markup of the message. Can only be set when using server-side API + */ + html: string; + + /** + * Message ID is unique string identifier of the message + */ + id: string; + + /** + * Whether the message mentioned the channel tag + */ + mentioned_channel: boolean; + + /** + * Whether the message mentioned online users with @here tag + */ + mentioned_here: boolean; + + /** + * Whether message is pinned or not + */ + pinned: boolean; + + /** + * Number of replies to this message + */ + reply_count: number; + + /** + * Whether the message was shadowed or not + */ + shadowed: boolean; + + /** + * Whether message is silent or not + */ + silent: boolean; + + /** + * Text of the message. Should be empty if `mml` is provided + */ + text: string; + + /** + * Contains type of the message. One of: regular, ephemeral, error, reply, system, deleted + */ + type: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * Array of message attachments + */ + attachments: Array; + + /** + * List of 10 latest reactions to this message + */ + latest_reactions: Array; + + /** + * List of mentioned users + */ + mentioned_users: Array; + + /** + * List of 10 latest reactions of authenticated user to this message + */ + own_reactions: Array; + + /** + * A list of user ids that have restricted visibility to the message, if the list is not empty, the message is only visible to the users in the list + */ + restricted_visibility: Array; + + custom: CustomMessageData; + + /** + * An object containing number of reactions of each type. Key: reaction type (string), value: number of reactions (int) + */ + reaction_counts: Record; + + /** + * An object containing scores of reactions of each type. Key: reaction type (string), value: total score of reactions (int) + */ + reaction_scores: Record; + + user: UserResponse; + + /** + * Contains provided slash command + */ + command?: string; + + /** + * Date/time of deletion + */ + deleted_at?: Date; + + deleted_for_me?: boolean; + + message_text_updated_at?: Date; + + /** + * Should be empty if `text` is provided. Can only be set when using server-side API + */ + mml?: string; + + /** + * ID of parent message (thread) + */ + parent_id?: string; + + /** + * Date when pinned message expires + */ + pin_expires?: Date; + + /** + * Date when message got pinned + */ + pinned_at?: Date; + + /** + * Identifier of the poll to include in the message + */ + poll_id?: string; + + quoted_message_id?: string; + + /** + * Whether thread reply should be shown in the channel as well + */ + show_in_channel?: boolean; + + /** + * List of user group IDs mentioned in the message. Group members who are also channel members will receive push notifications based on their push preferences. Max 10 groups + */ + mentioned_group_ids?: Array; + + /** + * List of mentioned user group objects. + */ + mentioned_groups?: Array; + + /** + * List of roles mentioned in the message (e.g. admin, channel_moderator, custom roles). Members with matching roles will receive push notifications based on their push preferences. Max 10 roles + */ + mentioned_roles?: Array; + + /** + * List of users who participate in thread + */ + thread_participants?: Array; + + draft?: DraftResponse; + + /** + * Object with translations. Key `language` contains the original language key. Other keys contain translations + */ + i18n?: Record; + + /** + * Contains image moderation information + */ + image_labels?: Record>; + + member?: ChannelMemberResponse; + + moderation?: ModerationV2Response; + + pinned_by?: UserResponse; + + poll?: PollResponseData; + + quoted_message?: MessageResponse; + + reaction_groups?: Record; + + reminder?: ReminderResponseData; + + shared_location?: SharedLocationResponseData; +} + +export interface MessageUndeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + message_id: string; + + custom: CustomEventData; + + message: MessageResponse; + + /** + * The type of event: "message.undeleted" in this case + */ + type: string; + + /** + * The ID of the channel where the message was sent + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel where the message was sent + */ + channel_type?: string; + + /** + * The CID of the channel where the message was sent + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; +} + +export interface MessageUpdate { + old_text?: string; + + change_set?: MessageChangeSet; +} + +export interface MessageUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + message_id: string; + + custom: CustomEventData; + + message: MessageResponse; + + /** + * The type of event: "message.updated" in this case + */ + type: string; + + /** + * The ID of the channel where the message was sent + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel where the message was sent + */ + channel_type?: string; + + /** + * The CID of the channel where the message was sent + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + message_update?: MessageUpdate; + + user?: UserResponseCommonFields; +} + +export interface MessageWithChannelResponse { + /** + * Channel unique identifier in : format + */ + cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + deleted_reply_count: number; + + /** + * Contains HTML markup of the message. Can only be set when using server-side API + */ + html: string; + + /** + * Message ID is unique string identifier of the message + */ + id: string; + + /** + * Whether the message mentioned the channel tag + */ + mentioned_channel: boolean; + + /** + * Whether the message mentioned online users with @here tag + */ + mentioned_here: boolean; + + /** + * Whether message is pinned or not + */ + pinned: boolean; + + /** + * Number of replies to this message + */ + reply_count: number; + + /** + * Whether the message was shadowed or not + */ + shadowed: boolean; + + /** + * Whether message is silent or not + */ + silent: boolean; + + /** + * Text of the message. Should be empty if `mml` is provided + */ + text: string; + + /** + * Contains type of the message. One of: regular, ephemeral, error, reply, system, deleted + */ + type: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * Array of message attachments + */ + attachments: Array; + + /** + * List of 10 latest reactions to this message + */ + latest_reactions: Array; + + /** + * List of mentioned users + */ + mentioned_users: Array; + + /** + * List of 10 latest reactions of authenticated user to this message + */ + own_reactions: Array; + + /** + * A list of user ids that have restricted visibility to the message, if the list is not empty, the message is only visible to the users in the list + */ + restricted_visibility: Array; + + channel: ChannelResponse; + + custom: CustomMessageData; + + /** + * An object containing number of reactions of each type. Key: reaction type (string), value: number of reactions (int) + */ + reaction_counts: Record; + + /** + * An object containing scores of reactions of each type. Key: reaction type (string), value: total score of reactions (int) + */ + reaction_scores: Record; + + user: UserResponse; + + /** + * Contains provided slash command + */ + command?: string; + + /** + * Date/time of deletion + */ + deleted_at?: Date; + + deleted_for_me?: boolean; + + message_text_updated_at?: Date; + + /** + * Should be empty if `text` is provided. Can only be set when using server-side API + */ + mml?: string; + + /** + * ID of parent message (thread) + */ + parent_id?: string; + + /** + * Date when pinned message expires + */ + pin_expires?: Date; + + /** + * Date when message got pinned + */ + pinned_at?: Date; + + /** + * Identifier of the poll to include in the message + */ + poll_id?: string; + + quoted_message_id?: string; + + /** + * Whether thread reply should be shown in the channel as well + */ + show_in_channel?: boolean; + + /** + * List of user group IDs mentioned in the message. Group members who are also channel members will receive push notifications based on their push preferences. Max 10 groups + */ + mentioned_group_ids?: Array; + + /** + * List of mentioned user group objects. + */ + mentioned_groups?: Array; + + /** + * List of roles mentioned in the message (e.g. admin, channel_moderator, custom roles). Members with matching roles will receive push notifications based on their push preferences. Max 10 roles + */ + mentioned_roles?: Array; + + /** + * List of users who participate in thread + */ + thread_participants?: Array; + + draft?: DraftResponse; + + /** + * Object with translations. Key `language` contains the original language key. Other keys contain translations + */ + i18n?: Record; + + /** + * Contains image moderation information + */ + image_labels?: Record>; + + member?: ChannelMemberResponse; + + moderation?: ModerationV2Response; + + pinned_by?: UserResponse; + + poll?: PollResponseData; + + quoted_message?: MessageResponse; + + reaction_groups?: Record; + + reminder?: ReminderResponseData; + + shared_location?: SharedLocationResponseData; +} + +export interface ModerationActionConfigResponse { + /** + * The action to take + */ + action: string; + + /** + * Description of what this action does + */ + description: string; + + /** + * Type of entity this action applies to + */ + entity_type: string; + + /** + * Icon for the dashboard + */ + icon: string; + + /** + * Display order (lower numbers shown first) + */ + order: number; + + id?: string; + + /** + * Queue type this action config belongs to + */ + queue_type?: string; + + /** + * Custom data for the action + */ + custom?: Record; +} + +export interface ModerationBanResponse { + duration: string; +} + +export interface ModerationCustomActionEvent { + /** + * The ID of the custom action that was executed + */ + action_id: string; + + created_at: Date; + + custom: CustomEventData; + + review_queue_item: ReviewQueueItemResponse; + + type: string; + + received_at?: Date; + + /** + * Additional options passed to the custom action + */ + action_options?: Record; + + message?: MessageResponse; +} + +export interface ModerationFlagResponse { + created_at: Date; + + entity_id: string; + + entity_type: string; + + type: string; + + updated_at: Date; + + user_id: string; + + result: Array>; + + entity_creator_id?: string; + + reason?: string; + + review_queue_item_id?: string; + + labels?: Array; + + custom?: Record; + + moderation_payload?: ModerationPayloadResponse; + + review_queue_item?: ReviewQueueItemResponse; + + user?: UserResponse; +} + +export interface ModerationFlaggedEvent { + /** + * The type of content that was flagged + */ + content_type: string; + + created_at: Date; + + /** + * The ID of the flagged content + */ + object_id: string; + + custom: CustomEventData; + + type: string; + + received_at?: Date; +} + +export interface ModerationMarkReviewedEvent { + created_at: Date; + + custom: CustomEventData; + + item: ReviewQueueItemResponse; + + type: string; + + received_at?: Date; + + message?: MessageResponse; +} + +export interface ModerationPayload { + image_ordered_keys?: Array; + + images?: Array; + + text_ordered_keys?: Array; + + texts?: Array; + + videos?: Array; + + custom?: Record; + + image_ids?: Record; + + text_ids?: Record; +} + +export interface ModerationPayloadResponse { + /** + * Caller-supplied keys for images, index-aligned with images[] + */ + image_ordered_keys?: Array; + + /** + * Image URLs to moderate + */ + images?: Array; + + /** + * Caller-supplied keys for texts (e.g. "title", "description"), index-aligned with texts[] + */ + text_ordered_keys?: Array; + + /** + * Text content to moderate + */ + texts?: Array; + + /** + * Video URLs to moderate + */ + videos?: Array; + + /** + * Custom data for moderation + */ + custom?: Record; + + /** + * Caller-supplied content IDs per image key (from content_ids on /analyze) + */ + image_ids?: Record; + + /** + * Caller-supplied content IDs per text key (from content_ids on /analyze) + */ + text_ids?: Record; +} + +export interface ModerationQueueResponse { + created_at: Date; + + created_by: string; + + description: string; + + id: string; + + item_count: number; + + name: string; + + type: string; + + updated_at: Date; + + sort: Array>; + + filters: Record; +} + +export interface ModerationResponse { + action: string; + + explicit: number; + + spam: number; + + toxic: number; +} + +export interface ModerationV2Response { + action: string; + + original_text: string; + + blocklist_matched?: string; + + platform_circumvented?: boolean; + + semantic_filter_matched?: string; + + blocklists_matched?: Array; + + image_harms?: Array; + + text_harms?: Array; +} + +export interface MuteChannelRequest { + /** + * Duration of mute in milliseconds + */ + expiration?: number; + + /** + * Channel CIDs to mute (if multiple channels) + */ + channel_cids?: Array; +} + +export interface MuteChannelResponse { + duration: string; + + /** + * Object with mutes (if multiple channels were muted) + */ + channel_mutes?: Array; + + channel_mute?: ChannelMute; + + own_user?: OwnUserResponse; +} + +export interface MuteRequest { + /** + * User IDs to mute (if multiple users) + */ + target_ids: Array; + + /** + * Duration of mute in minutes + */ + timeout?: number; +} + +export interface MuteResponse { + duration: string; + + /** + * Object with mutes (if multiple users were muted) + */ + mutes?: Array; + + /** + * A list of users that can't be found. Common cause for this is deleted users + */ + non_existing_users?: Array; + + own_user?: OwnUserResponse; +} + +export interface NotificationAddedToChannelEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "notification.added_to_channel" in this case + */ + type: string; + + /** + * The ID of the channel to which the user was added + */ + channel_id?: string; + + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel to which the user was added + */ + channel_type?: string; + + /** + * The CID of the channel to which the user was added + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; +} + +export interface NotificationChannelDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "notification.channel_deleted" in this case + */ + type: string; + + /** + * The ID of the channel which was deleted + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was deleted + */ + channel_type?: string; + + /** + * The CID of the channel which was deleted + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + /** + * The total number of unread messages + */ + total_unread_count?: number; + + /** + * The number of channels with unread messages + */ + unread_channels?: number; + + /** + * The number of unread messages in the channel + */ + unread_count?: number; + + channel_custom?: CustomChannelData; + + grouped_unread_channels?: Record; +} + +export interface NotificationChannelMutesUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + me: OwnUserResponse; + + /** + * The type of event: "notification.channel_mutes_updated" in this case + */ + type: string; + + received_at?: Date; +} + +export interface NotificationChannelTruncatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "notification.channel_truncated" in this case + */ + type: string; + + /** + * The ID of the channel which was truncated + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was truncated + */ + channel_type?: string; + + /** + * The CID of the channel which was truncated + */ + cid?: string; + + message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + /** + * The total number of unread messages + */ + total_unread_count?: number; + + /** + * The number of channels with unread messages + */ + unread_channels?: number; + + /** + * The number of unread messages in the channel + */ + unread_count?: number; + + channel_custom?: CustomChannelData; + + grouped_unread_channels?: Record; + + message?: MessageResponse; +} + +export interface NotificationInviteAcceptedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "notification.invite_accepted" in this case + */ + type: string; + + /** + * The ID of the channel to which the user accepted the invite + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel to which the user accepted the invite + */ + channel_type?: string; + + /** + * The CID of the channel to which the user accepted the invite + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface NotificationInviteRejectedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "notification.invite_rejected" in this case + */ + type: string; + + /** + * The ID of the channel to which the user rejected the invite + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel to which the user rejected the invite + */ + channel_type?: string; + + /** + * The CID of the channel to which the user rejected the invite + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface NotificationInvitedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "notification.invited" in this case + */ + type: string; + + /** + * The ID of the channel to which the user was invited + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel to which the user was invited + */ + channel_type?: string; + + /** + * The CID of the channel to which the user was invited + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface NotificationMarkReadEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The total number of unread messages + */ + total_unread_count: number; + + /** + * The number of channels with unread messages + */ + unread_channels: number; + + /** + * The total number of unread messages + */ + unread_count: number; + + custom: CustomEventData; + + /** + * The type of event: "notification.mark_read" in this case + */ + type: string; + + /** + * The ID of the channel which was marked as read + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was marked as read + */ + channel_type?: string; + + /** + * The CID of the channel which was marked as read + */ + cid?: string; + + /** + * The ID of the last read message + */ + last_read_message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + /** + * The ID of the thread which was marked as read + */ + thread_id?: string; + + /** + * The total number of unread messages in the threads + */ + unread_thread_messages?: number; + + /** + * The number of unread threads + */ + unread_threads?: number; + + channel?: ChannelResponse; + + channel_custom?: CustomChannelData; + + grouped_unread_channels?: Record; + + thread?: ThreadResponse; + + user?: UserResponseCommonFields; +} + +export interface NotificationMarkUnreadEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "notification.mark_unread" in this case + */ + type: string; + + /** + * The ID of the channel which was marked as unread + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was marked as unread + */ + channel_type?: string; + + /** + * The CID of the channel which was marked as unread + */ + cid?: string; + + /** + * The ID of the first unread message + */ + first_unread_message_id?: string; + + /** + * The time when the channel/thread was marked as unread + */ + last_read_at?: Date; + + /** + * The ID of the last read message + */ + last_read_message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + /** + * The ID of the thread which was marked as unread + */ + thread_id?: string; + + /** + * The total number of unread messages + */ + total_unread_count?: number; + + /** + * The number of channels with unread messages + */ + unread_channels?: number; + + /** + * The total number of unread messages + */ + unread_count?: number; + + /** + * The number of unread messages in the channel/thread after first_unread_message_id + */ + unread_messages?: number; + + /** + * The total number of unread messages in the threads + */ + unread_thread_messages?: number; + + /** + * The number of unread threads + */ + unread_threads?: number; + + channel?: ChannelResponse; + + channel_custom?: CustomChannelData; + + grouped_unread_channels?: Record; + + user?: UserResponseCommonFields; +} + +export interface NotificationMutesUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + me: OwnUserResponse; + + /** + * The type of event: "notification.mutes_updated" in this case + */ + type: string; + + received_at?: Date; +} + +export interface NotificationNewMessageEvent { + /** + * Date/time of creation + */ + created_at: Date; + + message_id: string; + + /** + * The number of watchers + */ + watcher_count: number; + + channel: ChannelResponse; + + custom: CustomEventData; + + message: MessageResponse; + + /** + * The type of event: "notification.message_new" in this case + */ + type: string; + + /** + * The ID of the channel where the message was sent + */ + channel_id?: string; + + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel where the message was sent + */ + channel_type?: string; + + /** + * The CID of the channel where the message was sent + */ + cid?: string; + + parent_author?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + total_unread_count?: number; + + unread_channels?: number; + + unread_count?: number; + + /** + * The participants of the thread + */ + thread_participants?: Array; + + channel_custom?: CustomChannelData; + + grouped_unread_channels?: Record; +} + +export interface NotificationRemovedFromChannelEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "notification.removed_from_channel" in this case + */ + type: string; + + /** + * The ID of the channel from which the user was removed + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel from which the user was removed + */ + channel_type?: string; + + /** + * The CID of the channel from which the user was removed + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface NotificationThreadMessageNewEvent { + /** + * Date/time of creation + */ + created_at: Date; + + message_id: string; + + /** + * The ID of the thread + */ + thread_id: string; + + /** + * The number of watchers + */ + watcher_count: number; + + channel: ChannelResponse; + + custom: CustomEventData; + + message: MessageResponse; + + /** + * The type of event: "notification.message_new" in this case + */ + type: string; + + /** + * The ID of the channel where the message was sent + */ + channel_id?: string; + + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel where the message was sent + */ + channel_type?: string; + + /** + * The CID of the channel where the message was sent + */ + cid?: string; + + parent_author?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + unread_thread_messages?: number; + + unread_threads?: number; + + /** + * The participants of the thread + */ + thread_participants?: Array; + + channel_custom?: CustomChannelData; +} + +export interface OCRRule { + action: 'flag' | 'shadow' | 'remove' | 'bounce' | 'bounce_flag' | 'bounce_remove'; + + label: string; +} + +export interface OnlyUserID { + id: string; +} + +export interface OwnUserResponse { + banned: boolean; + + created_at: Date; + + id: string; + + invisible: boolean; + + language: string; + + online: boolean; + + role: string; + + total_unread_count: number; + + unread_channels: number; + + unread_count: number; + + unread_threads: number; + + updated_at: Date; + + channel_mutes: Array; + + devices: Array; + + mutes: Array; + + teams: Array; + + custom: CustomUserData; + + avg_response_time?: number; + + deactivated_at?: Date; + + deleted_at?: Date; + + image?: string; + + last_active?: Date; + + name?: string; + + revoke_tokens_issued_before?: Date; + + blocked_user_ids?: Array; + + latest_hidden_channels?: Array; + + privacy_settings?: PrivacySettingsResponse; + + push_preferences?: PushPreferencesResponse; + + teams_role?: Record; + + total_unread_count_by_team?: Record; +} + +export interface PaginationParams { + limit?: number; + + offset?: number; +} + +export interface ParsedPredefinedFilterResponse { + name: string; + + filter: Record; + + sort?: Array; +} + +export interface PendingMessageEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The method used for the pending message + */ + method: string; + + custom: CustomEventData; + + /** + * The type of event: "message.pending" in this case + */ + type: string; + + received_at?: Date; + + channel?: ChannelResponse; + + message?: MessageResponse; + + /** + * Metadata attached to the pending message + */ + metadata?: Record; + + user?: UserResponse; +} + +export interface PendingMessageResponse { + channel?: ChannelResponse; + + message?: MessageResponse; + + metadata?: Record; + + user?: UserResponse; +} + +export interface PollClosedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + poll: PollResponseData; + + /** + * The type of event: "poll.closed" in this case + */ + type: string; + + activity_id?: string; + + /** + * The CID of the channel containing the poll + */ + cid?: string; + + /** + * The ID of the message containing the poll + */ + message_id?: string; + + received_at?: Date; +} + +export interface PollDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + poll: PollResponseData; + + /** + * The type of event: "poll.deleted" in this case + */ + type: string; + + activity_id?: string; + + /** + * The CID of the channel containing the poll + */ + cid?: string; + + /** + * The ID of the message containing the poll + */ + message_id?: string; + + received_at?: Date; +} + +export interface PollOptionInput { + text?: string; + + custom?: CustomPollOptionData; +} + +export interface PollOptionRequest { + id: string; + + text?: string; + + custom?: CustomPollOptionData; +} + +export interface PollOptionResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + poll_option: PollOptionResponseData; +} + +export interface PollOptionResponseData { + id: string; + + text: string; + + custom: CustomPollOptionData; +} + +export interface PollResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + poll: PollResponseData; +} + +export interface PollResponseData { + allow_answers: boolean; + + allow_user_suggested_options: boolean; + + answers_count: number; + + created_at: Date; + + created_by_id: string; + + description: string; + + enforce_unique_vote: boolean; + + id: string; + + name: string; + + updated_at: Date; + + vote_count: number; + + voting_visibility: string; + + latest_answers: Array; + + options: Array; + + own_votes: Array; + + custom: CustomPollData; + + latest_votes_by_option: Record>; + + vote_counts_by_option: Record; + + is_closed?: boolean; + + max_votes_allowed?: number; + + created_by?: UserResponse; +} + +export interface PollUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + poll: PollResponseData; + + /** + * The type of event: "poll.updated" in this case + */ + type: string; + + activity_id?: string; + + /** + * The CID of the channel containing the poll + */ + cid?: string; + + /** + * The ID of the message containing the poll + */ + message_id?: string; + + received_at?: Date; +} + +export interface PollVoteCastedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + poll: PollResponseData; + + poll_vote: PollVoteResponseData; + + /** + * The type of event: "poll.vote_casted" in this case + */ + type: string; + + activity_id?: string; + + /** + * The CID of the channel containing the poll + */ + cid?: string; + + /** + * The ID of the message containing the poll + */ + message_id?: string; + + received_at?: Date; +} + +export interface PollVoteChangedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + poll: PollResponseData; + + poll_vote: PollVoteResponseData; + + /** + * The type of event: "poll.vote_changed" in this case + */ + type: string; + + activity_id?: string; + + /** + * The CID of the channel containing the poll + */ + cid?: string; + + /** + * The ID of the message containing the poll + */ + message_id?: string; + + received_at?: Date; +} + +export interface PollVoteRemovedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + poll: PollResponseData; + + poll_vote: PollVoteResponseData; + + /** + * The type of event: "poll.vote_removed" in this case + */ + type: string; + + activity_id?: string; + + /** + * The CID of the channel containing the poll + */ + cid?: string; + + /** + * The ID of the message containing the poll + */ + message_id?: string; + + received_at?: Date; +} + +export interface PollVoteResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + poll?: PollResponseData; + + vote?: PollVoteResponseData; +} + +export interface PollVoteResponseData { + created_at: Date; + + id: string; + + option_id: string; + + poll_id: string; + + updated_at: Date; + + answer_text?: string; + + is_answer?: boolean; + + user_id?: string; + + user?: UserResponse; +} + +export interface PollVotesResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Poll votes + */ + votes: Array; + + next?: string; + + prev?: string; +} + +export interface PrivacySettingsResponse { + delivery_receipts?: DeliveryReceiptsResponse; + + read_receipts?: ReadReceiptsResponse; + + typing_indicators?: TypingIndicatorsResponse; +} + +export interface PushPreferenceInput { + /** + * Set the level of call push notifications for the user. One of: all, none, default + */ + + call_level?: 'all' | 'none' | 'default'; + + /** + * Set the push preferences for a specific channel. If empty it sets the default for the user + */ + channel_cid?: string; + + /** + * Set the level of chat push notifications for the user. Note: "mentions" is deprecated in favor of "direct_mentions". One of: all, mentions, direct_mentions, all_mentions, none, default + */ + + chat_level?: + | 'all' + | 'mentions' + | 'direct_mentions' + | 'all_mentions' + | 'none' + | 'default'; + + /** + * Disable push notifications till a certain time + */ + disabled_until?: Date; + + /** + * Set the level of feeds push notifications for the user. One of: all, none, default + */ + + feeds_level?: 'all' | 'none' | 'default'; + + /** + * Remove the disabled until time. (IE stop snoozing notifications) + */ + remove_disable?: boolean; + + /** + * The user id for which to set the push preferences. Required when using server side auths, defaults to current user with client side auth. + */ + user_id?: string; + + chat_preferences?: ChatPreferencesInput; + + feeds_preferences?: FeedsPreferences; +} + +export interface PushPreferencesResponse { + call_level?: string; + + chat_level?: string; + + disabled_until?: Date; + + feeds_level?: string; + + chat_preferences?: ChatPreferencesResponse; + + feeds_preferences?: FeedsPreferencesResponse; +} + +export interface QueryAppealsRequest { + limit?: number; + + next?: string; + + prev?: string; + + /** + * Sorting parameters for appeals + */ + sort?: Array; + + /** + * Filter conditions for appeals + */ + filter?: Record; +} + +export interface QueryAppealsResponse { + duration: string; + + /** + * List of Appeal Items + */ + items: Array; + + next?: string; + + prev?: string; +} + +export interface QueryBannedUsersPayload { + /** + * Filter conditions to apply to the query + */ + filter_conditions: Filters<{ + banned_by_id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + channel_cid: { + type: string; + operators: '$eq' | '$in'; + }; + + created_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + reason: { + type: string; + operators: + | '$autocomplete' + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + user_id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + }>; + + /** + * Whether to exclude expired bans or not + */ + exclude_expired_bans?: boolean; + + /** + * Number of records to return + */ + limit?: number; + + /** + * Number of records to offset + */ + offset?: number; + + /** + * Array of sort parameters + */ + sort?: Array; +} + +export interface QueryBannedUsersResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of found bans + */ + bans: Array; +} + +export interface QueryChannelsRequest { + /** + * Number of channels to limit + */ + limit?: number; + + /** + * Number of members to limit + */ + member_limit?: number; + + /** + * Number of messages to limit + */ + message_limit?: number; + + /** + * Channel pagination offset + */ + offset?: number; + + /** + * ID of a predefined filter to use instead of filter_conditions + */ + predefined_filter?: string; + + presence?: boolean; + + /** + * Whether to update channel state or not + */ + state?: boolean; + + /** + * Whether to start watching found channels or not + */ + watch?: boolean; + + /** + * List of sort parameters + */ + sort?: Array; + + /** + * Filter conditions to apply to the query + */ + filter_conditions?: Filters<{ + app_banned: { + type: string; + operators: '$eq'; + }; + + archived: { + type: boolean; + operators: '$eq'; + }; + + blocked: { + type: boolean; + operators: '$eq'; + }; + + channel_role: { + type: string; + operators: '$eq' | '$in'; + }; + + cid: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + created_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + created_by_id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + disabled: { + type: boolean; + operators: '$eq'; + }; + + distinct: { + type: boolean; + operators: '$eq'; + }; + + filter_tags: { + type: string; + operators: '$eq' | '$in'; + }; + + frozen: { + type: boolean; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + has_unread: { + type: boolean; + operators: '$eq'; + }; + + hidden: { + type: boolean; + operators: '$eq'; + }; + + id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + invite: { + type: string; + operators: '$eq'; + }; + + joined: { + type: boolean; + operators: '$eq'; + }; + + last_message_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + last_updated: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + 'member.user.name': { + type: string; + operators: '$autocomplete' | '$eq' | '$ne'; + }; + + member_count: { + type: number; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + members: { + type: string; + operators: '$eq' | '$in' | '$nin'; + }; + + message_count: { + type: number; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + muted: { + type: boolean; + operators: '$eq'; + }; + + name: { + type: string; + operators: + | '$autocomplete' + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin' + | '$q'; + }; + + pinned: { + type: boolean; + operators: '$eq'; + }; + + team: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + type: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + updated_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + }>; + + /** + * Values to interpolate into the predefined filter template + */ + filter_values?: Record; + + sort_values?: Record; +} + +export interface QueryChannelsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of channels + */ + channels: Array; + + predefined_filter?: ParsedPredefinedFilterResponse; +} + +export interface QueryDraftsRequest { + limit?: number; + + next?: string; + + prev?: string; + + /** + * Array of sort parameters + */ + sort?: Array; + + /** + * Filter to apply to the query + */ + filter?: Record; +} + +export interface QueryDraftsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Drafts + */ + drafts: Array; + + next?: string; + + prev?: string; +} + +export interface QueryFutureChannelBansPayload { + /** + * Whether to exclude expired bans or not + */ + exclude_expired_bans?: boolean; + + /** + * Number of records to return + */ + limit?: number; + + /** + * Number of records to offset + */ + offset?: number; + + /** + * Filter by the target user ID. For server-side requests only. + */ + target_user_id?: string; +} + +export interface QueryFutureChannelBansResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of found future channel bans + */ + bans: Array; +} + +export interface QueryMembersPayload { + type: string; + + /** + * Filter conditions to apply to the query + */ + filter_conditions: Filters<{ + banned: { + type: boolean; + operators: '$eq'; + }; + + channel_role: { + type: string; + operators: '$eq' | '$in'; + }; + + cid: { + type: string; + operators: '$eq'; + }; + + created_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + invite: { + type: string; + operators: '$eq'; + }; + + is_moderator: { + type: boolean; + operators: '$eq' | '$ne'; + }; + + joined: { + type: boolean; + operators: '$eq'; + }; + + last_active: { + type: Date; + operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte' | '$ne'; + }; + + name: { + type: string; + operators: '$autocomplete' | '$eq' | '$in' | '$ne' | '$nin' | '$q'; + }; + + notifications_muted: { + type: boolean; + operators: '$eq'; + }; + + updated_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + 'user.email': { + type: string; + operators: '$autocomplete' | '$eq' | '$in' | '$ne' | '$nin' | '$q'; + }; + + 'user.nd_deactivated': { + type: boolean; + operators: '$eq'; + }; + + user_id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + }>; + + id?: string; + + limit?: number; + + offset?: number; + + members?: Array; + + /** + * Array of sort parameters + */ + sort?: Array; +} + +export interface QueryMessageFlagsPayload { + limit?: number; + + offset?: number; + + /** + * Whether to include deleted messages in the results + */ + show_deleted_messages?: boolean; + + /** + * Array of sort parameters + */ + sort?: Array; + + /** + * Filter conditions to apply to the query + */ + filter_conditions?: Filters<{ + action: { + type: string; + operators: '$eq'; + }; + + blocklist_name: { + type: string; + operators: '$eq'; + }; + + channel_cid: { + type: string; + operators: '$eq' | '$in'; + }; + + date_range: { + type: string; + operators: '$eq'; + }; + + harm_label: { + type: string; + operators: '$eq'; + }; + + harm_type: { + type: string; + operators: '$eq'; + }; + + image_labels: { + type: string; + operators: '$eq'; + }; + + is_reviewed: { + type: boolean; + operators: '$eq'; + }; + + keyword: { + type: string; + operators: '$eq'; + }; + + matched_phrase: { + type: string; + operators: '$eq'; + }; + + message_id: { + type: string; + operators: '$eq' | '$in'; + }; + + phrase_list_ids: { + type: number; + operators: '$eq'; + }; + + reason: { + type: string; + operators: '$eq' | '$in'; + }; + + reporter_id: { + type: string; + operators: '$eq'; + }; + + reporter_type: { + type: string; + operators: '$eq'; + }; + + team: { + type: string; + operators: '$eq' | '$in'; + }; + + user_id: { + type: string; + operators: '$eq' | '$in'; + }; + }>; +} + +export interface QueryMessageFlagsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * The flags that match the query + */ + flags: Array; +} + +export interface QueryModerationConfigsRequest { + limit?: number; + + next?: string; + + prev?: string; + + /** + * Sorting parameters for the results + */ + sort?: Array; + + /** + * Filter conditions for moderation configs + */ + filter?: Record; +} + +export interface QueryModerationConfigsResponse { + duration: string; + + /** + * List of moderation configurations + */ + configs: Array; + + next?: string; + + prev?: string; +} + +export interface QueryPollVotesRequest { + limit?: number; + + next?: string; + + prev?: string; + + /** + * Array of sort parameters + */ + sort?: Array; + + /** + * Filter to apply to the query + */ + filter?: Record; +} + +export interface QueryPollsRequest { + limit?: number; + + next?: string; + + prev?: string; + + /** + * Array of sort parameters + */ + sort?: Array; + + /** + * Filter to apply to the query + */ + filter?: Record; +} + +export interface QueryPollsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Polls data returned by the query + */ + polls: Array; + + next?: string; + + prev?: string; +} + +export interface QueryReactionsRequest { + limit?: number; + + next?: string; + + prev?: string; + + /** + * Array of sort parameters + */ + sort?: Array; + + /** + * Filter to apply to the query + */ + filter?: Filters<{ + created_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + type: { + type: string; + operators: '$eq' | '$in'; + }; + + user_id: { + type: string; + operators: '$eq' | '$in'; + }; + }>; +} + +export interface QueryReactionsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + reactions: Array; + + next?: string; + + prev?: string; +} + +export interface QueryRemindersRequest { + limit?: number; + + next?: string; + + prev?: string; + + /** + * Array of sort parameters + */ + sort?: Array; + + /** + * Filter to apply to the query + */ + filter?: Record; +} + +export interface QueryRemindersResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * MessageReminders data returned by the query + */ + reminders: Array; + + next?: string; + + prev?: string; +} + +export interface QueryReviewQueueRequest { + exclude_default_action_config?: boolean; + + limit?: number; + + /** + * Number of items to lock (1-25) + */ + lock_count?: number; + + /** + * Duration for which items should be locked + */ + lock_duration?: number; + + /** + * Whether to lock items for review (true), unlock items (false), or just fetch (nil) + */ + lock_items?: boolean; + + next?: string; + + prev?: string; + + /** + * Whether to return only statistics + */ + stats_only?: boolean; + + /** + * Sorting parameters for the results + */ + sort?: Array; + + /** + * Filter conditions for review queue items. Accepts built-in fields (e.g. status, channel_cid, severity, recommended_action) and customer-supplied moderation_payload.custom keys: any key that is not a built-in field is matched against the item's custom moderation data (e.g. {"location_id": "loc-42"}). Use filter_config.filterable_custom_keys to discover which custom keys the app exposes as chips. + */ + filter?: Record; +} + +export interface QueryReviewQueueResponse { + duration: string; + + /** + * List of review queue items + */ + items: Array; + + /** + * Configuration for moderation actions + */ + action_config: Record>; + + /** + * Statistics about the review queue + */ + stats: Record; + + next?: string; + + prev?: string; + + default_action_config?: Record>; + + filter_config?: FilterConfigResponse; +} + +export interface QueryThreadsRequest { + limit?: number; + + member_limit?: number; + + next?: string; + + /** + * Limit the number of participants returned per each thread + */ + participant_limit?: number; + + prev?: string; + + /** + * Limit the number of replies returned per each thread + */ + reply_limit?: number; + + /** + * Start watching the channel this thread belongs to + */ + watch?: boolean; + + /** + * Array of sort parameters + */ + sort?: Array; + + /** + * Filter to apply to the query + */ + filter?: Filters<{ + active_participant_count: { + type: number; + operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte'; + }; + + 'channel.disabled': { + type: boolean; + operators: '$eq'; + }; + + 'channel.team': { + type: string; + operators: '$eq' | '$in'; + }; + + channel_cid: { + type: string; + operators: '$eq' | '$in'; + }; + + created_at: { + type: Date; + operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte'; + }; + + created_by_user_id: { + type: string; + operators: '$eq' | '$in'; + }; + + has_unread: { + type: boolean; + operators: '$eq'; + }; + + last_message_at: { + type: Date; + operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte'; + }; + + parent_message_id: { + type: string; + operators: '$eq' | '$in'; + }; + + participant_count: { + type: number; + operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte'; + }; + + reply_count: { + type: number; + operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte'; + }; + + updated_at: { + type: Date; + operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte'; + }; + }>; +} + +export interface QueryThreadsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of enriched thread states + */ + threads: Array; + + next?: string; + + prev?: string; +} + +export interface QueryUsersPayload { + /** + * Filter conditions to apply to the query + */ + filter_conditions: Filters<{ + banned: { + type: boolean; + operators: '$eq' | '$ne'; + }; + + bypass_moderation: { + type: boolean; + operators: '$eq' | '$ne'; + }; + + created_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + email: { + type: string; + operators: '$eq' | '$in'; + }; + + id: { + type: string; + operators: + | '$autocomplete' + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + language: { + type: string; + operators: '$eq' | '$ne'; + }; + + last_active: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + name: { + type: string; + operators: + | '$autocomplete' + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + role: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + shadow_banned: { + type: boolean; + operators: '$eq' | '$ne'; + }; + + teams: { + type: string; + operators: '$_none' | '$contains' | '$eq' | '$in'; + }; + + updated_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + username: { + type: string; + operators: '$autocomplete' | '$eq'; + }; + }>; + + include_deactivated_users?: boolean; + + limit?: number; + + offset?: number; + + presence?: boolean; + + /** + * Array of sort parameters + */ + sort?: Array; +} + +export interface QueryUsersResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Array of users as result of filters applied. + */ + users: Array; +} + +export interface QueueResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + queue?: ModerationQueueResponse; +} + +export interface Reaction { + activity_id: string; + + created_at: Date; + + kind: string; + + updated_at: Date; + + user_id: string; + + deleted_at?: Date; + + id?: string; + + parent?: string; + + score?: number; + + target_feeds?: Array; + + children_counts?: Record; + + data?: Record; + + latest_children?: Record>; + + moderation?: Record; + + own_children?: Record>; + + target_feeds_extra_data?: Record; + + user?: User; +} + +export interface ReactionDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "reaction.deleted" in this case + */ + type: string; + + /** + * The ID of the channel containing the message + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel containing the message + */ + channel_type?: string; + + /** + * The CID of the channel containing the message + */ + cid?: string; + + message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + /** + * The participants of the thread + */ + thread_participants?: Array; + + channel_custom?: CustomChannelData; + + message?: MessageResponse; + + reaction?: ReactionResponse; + + user?: UserResponseCommonFields; +} + +export interface ReactionGroupResponse { + /** + * Count is the number of reactions of this type. + */ + count: number; + + /** + * FirstReactionAt is the time of the first reaction of this type. This is the same also if all reaction of this type are deleted, because if someone will react again with the same type, will be preserved the sorting. + */ + first_reaction_at: Date; + + /** + * LastReactionAt is the time of the last reaction of this type. + */ + last_reaction_at: Date; + + /** + * SumScores is the sum of all scores of reactions of this type. Medium allows you to clap articles more than once and shows the sum of all claps from all users. For example, you can send `clap` x5 using `score: 5`. + */ + sum_scores: number; + + /** + * The most recent users who reacted with this type, ordered by most recent first. + */ + latest_reactions_by: Array; +} + +export interface ReactionGroupUserResponse { + /** + * The time when the user reacted. + */ + created_at: Date; + + /** + * The ID of the user who reacted. + */ + user_id: string; + + user?: UserResponse; +} + +export interface ReactionNewEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "reaction.new" in this case + */ + type: string; + + /** + * The ID of the channel containing the message + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel containing the message + */ + channel_type?: string; + + /** + * The CID of the channel containing the message + */ + cid?: string; + + message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + /** + * The participants of the thread + */ + thread_participants?: Array; + + channel_custom?: CustomChannelData; + + message?: MessageResponse; + + reaction?: ReactionResponse; + + user?: UserResponseCommonFields; +} + +export interface ReactionRequest { + /** + * The type of reaction (e.g. 'like', 'laugh', 'wow') + */ + type: string; + + /** + * Date/time of creation + */ + created_at?: Date; + + /** + * Reaction score. If not specified reaction has score of 1 + */ + score?: number; + + /** + * Date/time of the last update + */ + updated_at?: Date; + + custom?: CustomReactionData; +} + +export interface ReactionResponse { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Message ID + */ + message_id: string; + + /** + * Score of the reaction + */ + score: number; + + /** + * Type of reaction + */ + type: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * User ID + */ + user_id: string; + + /** + * Custom data for this object + */ + custom: CustomReactionData; + + user: UserResponse; +} + +export interface ReactionUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + message_id: string; + + channel: ChannelResponse; + + custom: CustomEventData; + + message: MessageResponse; + + /** + * The type of event: "reaction.updated" in this case + */ + type: string; + + /** + * The ID of the channel containing the message + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel containing the message + */ + channel_type?: string; + + /** + * The CID of the channel containing the message + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + reaction?: ReactionResponse; + + user?: UserResponseCommonFields; +} + +export interface ReadReceiptsResponse { + enabled: boolean; +} + +export interface ReadStateResponse { + last_read: Date; + + unread_messages: number; + + user: UserResponse; + + last_delivered_at?: Date; + + last_delivered_message_id?: string; + + last_read_message_id?: string; +} + +export interface RejectAppealRequestPayload { + /** + * Reason for rejecting the appeal + */ + decision_reason: string; +} + +export interface ReminderCreatedEvent { + /** + * The CID of the Channel for which the reminder was created + */ + cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The ID of the message for which the reminder was created + */ + message_id: string; + + /** + * The ID of the user for whom the reminder was created + */ + user_id: string; + + custom: CustomEventData; + + /** + * The type of event: "reminder.created" in this case + */ + type: string; + + /** + * The ID of the parent message, if the reminder is for a thread message + */ + parent_id?: string; + + received_at?: Date; + + reminder?: ReminderResponseData; +} + +export interface ReminderDeletedEvent { + /** + * The CID of the Channel for which the reminder was created + */ + cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The ID of the message for which the reminder was created + */ + message_id: string; + + /** + * The ID of the user for whom the reminder was created + */ + user_id: string; + + custom: CustomEventData; + + /** + * The type of event: "reminder.deleted" in this case + */ + type: string; + + /** + * The ID of the parent message, if the reminder is for a thread message + */ + parent_id?: string; + + received_at?: Date; + + reminder?: ReminderResponseData; +} + +export interface ReminderNotificationEvent { + /** + * The CID of the Channel for which the reminder was created + */ + cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The ID of the message for which the reminder was created + */ + message_id: string; + + /** + * The ID of the user for whom the reminder was created + */ + user_id: string; + + custom: CustomEventData; + + /** + * The type of event: "notification.reminder_due" in this case + */ + type: string; + + parent_id?: string; + + received_at?: Date; + + reminder?: ReminderResponseData; +} + +export interface ReminderResponseData { + channel_cid: string; + + created_at: Date; + + message_id: string; + + updated_at: Date; + + user_id: string; + + remind_at?: Date; + + channel?: ChannelResponse; + + message?: MessageResponse; + + user?: UserResponse; +} + +export interface ReminderUpdatedEvent { + /** + * The CID of the Channel for which the reminder was created + */ + cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The ID of the message for which the reminder was created + */ + message_id: string; + + /** + * The ID of the user for whom the reminder was created + */ + user_id: string; + + custom: CustomEventData; + + /** + * The type of event: "reminder.updated" in this case + */ + type: string; + + /** + * The ID of the parent message, if the reminder is for a thread message + */ + parent_id?: string; + + received_at?: Date; + + reminder?: ReminderResponseData; +} + +export interface RemoveUserGroupMembersRequest { + /** + * List of user IDs to remove + */ + member_ids: Array; + + team_id?: string; +} + +export interface RemoveUserGroupMembersResponse { + duration: string; + + user_group?: UserGroupResponse; +} + +export interface Response { + /** + * Duration of the request in milliseconds + */ + duration: string; +} + +export interface RestoreActionRequestPayload { + /** + * Reason for the appeal decision + */ + decision_reason?: string; +} + +export interface ReviewQueueItemResponse { + /** + * AI-determined text severity + */ + ai_text_severity: string; + + /** + * When the item was created + */ + created_at: Date; + + /** + * ID of the entity being reviewed + */ + entity_id: string; + + /** + * Type of entity being reviewed + */ + entity_type: string; + + /** + * Whether the item has been escalated + */ + escalated: boolean; + + flags_count: number; + + /** + * Unique identifier of the review queue item + */ + id: string; + + latest_moderator_action: string; + + /** + * Suggested moderation action + */ + recommended_action: string; + + /** + * ID of the moderator who reviewed the item + */ + reviewed_by: string; + + /** + * Severity level of the content + */ + severity: number; + + /** + * Current status of the review + */ + status: string; + + /** + * When the item was last updated + */ + updated_at: Date; + + /** + * Moderation actions taken + */ + actions: Array; + + /** + * Associated ban records + */ + bans: Array; + + /** + * Associated flag records + */ + flags: Array; + + /** + * Detected languages in the content + */ + languages: Array; + + /** + * When the review was completed + */ + completed_at?: Date; + + config_key?: string; + + /** + * ID of who created the entity + */ + entity_creator_id?: string; + + /** + * When the item was escalated + */ + escalated_at?: Date; + + /** + * ID of the moderator who escalated the item + */ + escalated_by?: string; + + /** + * When the item was reviewed + */ + reviewed_at?: Date; + + /** + * Teams associated with this item + */ + teams?: Array; + + activity?: EnrichedActivity; + + appeal?: AppealItemResponse; + + assigned_to?: UserResponse; + + call?: CallResponse; + + entity_creator?: EntityCreatorResponse; + + escalation_metadata?: EscalationMetadata; + + feeds_v2_activity?: EnrichedActivity; + + feeds_v2_reaction?: Reaction; + + feeds_v3_activity?: FeedsV3ActivityResponse; + + feeds_v3_comment?: FeedsV3CommentResponse; + + message?: ChatMessageResponse; + + moderation_payload?: ModerationPayloadResponse; + + reaction?: Reaction; +} + +export interface Role { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Whether this is a custom role or built-in + */ + custom: boolean; + + /** + * Unique role name + */ + name: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * List of scopes where this role is currently present. `.app` means that role is present in app-level grants + */ + scopes: Array; +} + +export interface RuleBuilderAction { + reason?: string; + + skip_inbox?: boolean; + + type?: + | 'ban_user' + | 'flag_user' + | 'flag_content' + | 'block_content' + | 'shadow_content' + | 'bounce_flag_content' + | 'bounce_content' + | 'bounce_remove_content' + | 'mute_video' + | 'mute_audio' + | 'blur' + | 'call_blur' + | 'end_call' + | 'kick_user' + | 'warning' + | 'call_warning' + | 'webhook_only'; + + ban_options?: BanOptions; + + call_options?: CallActionOptions; + + flag_user_options?: FlagUserOptions; +} + +export interface RuleBuilderCondition { + confidence?: number; + + type?: string; + + call_custom_property_params?: CallCustomPropertyParameters; + + call_type_rule_params?: CallTypeRuleParameters; + + call_violation_count_params?: CallViolationCountParameters; + + channel_message_count_rule_params?: ChannelMessageCountRuleParameters; + + closed_caption_rule_params?: ClosedCaptionRuleParameters; + + content_count_rule_params?: ContentCountRuleParameters; + + content_custom_property_count_params?: ContentCustomPropertyCountParameters; + + content_custom_property_params?: ContentCustomPropertyParameters; + + content_flag_count_rule_params?: FlagCountRuleParameters; + + image_content_params?: ImageContentParameters; + + image_rule_params?: ImageRuleParameters; + + keyframe_ocr_rule_params?: KeyframeOCRRuleParameters; + + keyframe_rule_params?: KeyframeRuleParameters; + + text_content_params?: TextContentParameters; + + text_rule_params?: TextRuleParameters; + + user_created_within_params?: UserCreatedWithinParameters; + + user_custom_property_params?: UserCustomPropertyParameters; + + user_flag_count_rule_params?: FlagCountRuleParameters; + + user_identical_content_count_params?: UserIdenticalContentCountParameters; + + user_role_params?: UserRoleParameters; + + user_rule_params?: UserRuleParameters; + + video_content_params?: VideoContentParameters; + + video_rule_params?: VideoRuleParameters; +} + +export interface RuleBuilderConditionGroup { + logic?: string; + + conditions?: Array; +} + +export interface RuleBuilderConfig { + async?: boolean; + + rules?: Array; +} + +export interface RuleBuilderRule { + rule_type: string; + + cooldown_period?: string; + + id?: string; + + logic?: string; + + action_sequences?: Array; + + conditions?: Array; + + groups?: Array; + + action?: RuleBuilderAction; +} + +export interface SearchPayload { + /** + * Channel filter conditions + */ + filter_conditions: Filters<{ + app_banned: { + type: string; + operators: '$eq'; + }; + + archived: { + type: boolean; + operators: '$eq'; + }; + + blocked: { + type: boolean; + operators: '$eq'; + }; + + channel_role: { + type: string; + operators: '$eq' | '$in'; + }; + + cid: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + created_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + created_by_id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + disabled: { + type: boolean; + operators: '$eq'; + }; + + distinct: { + type: boolean; + operators: '$eq'; + }; + + filter_tags: { + type: string; + operators: '$eq' | '$in'; + }; + + frozen: { + type: boolean; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + has_unread: { + type: boolean; + operators: '$eq'; + }; + + hidden: { + type: boolean; + operators: '$eq'; + }; + + id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + invite: { + type: string; + operators: '$eq'; + }; + + joined: { + type: boolean; + operators: '$eq'; + }; + + last_message_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + last_updated: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + 'member.user.name': { + type: string; + operators: '$autocomplete' | '$eq' | '$ne'; + }; + + member_count: { + type: number; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + members: { + type: string; + operators: '$eq' | '$in' | '$nin'; + }; + + message_count: { + type: number; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + muted: { + type: boolean; + operators: '$eq'; + }; + + name: { + type: string; + operators: + | '$autocomplete' + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin' + | '$q'; + }; + + pinned: { + type: boolean; + operators: '$eq'; + }; + + team: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + type: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + updated_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + }>; + + force_default_search?: boolean; + + force_sql_v2_backend?: boolean; + + /** + * Number of messages to return + */ + limit?: number; + + /** + * Pagination parameter. Cannot be used with non-zero offset. + */ + next?: string; + + /** + * Pagination offset. Cannot be used with sort or next. + */ + offset?: number; + + /** + * Search phrase + */ + query?: string; + + /** + * Sort parameters. Cannot be used with non-zero offset + */ + sort?: Array; + + /** + * Message filter conditions + */ + message_filter_conditions?: Filters<{ + attachments: { + type: boolean; + operators: '$exists'; + }; + + 'attachments.type': { + type: string; + operators: '$eq' | '$in'; + }; + + created_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + 'mentioned_users.id': { + type: string; + operators: '$contains'; + }; + + parent_id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + pinned: { + type: boolean; + operators: '$eq'; + }; + + reply_count: { + type: number; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + text: { + type: string; + operators: + | '$any' + | '$autocomplete' + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin' + | '$q'; + }; + + type: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + updated_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + 'user.id': { + type: string; + operators: '$eq' | '$in' | '$ne' | '$nin'; + }; + + user_id: { + type: string; + operators: '$eq' | '$in' | '$ne' | '$nin'; + }; + }>; + + message_options?: MessageOptions; +} + +export interface SearchResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Search results + */ + results: Array; + + /** + * Value to pass to the next search query in order to paginate + */ + next?: string; + + /** + * Value that points to the previous page. Pass as the next value in a search query to paginate backwards + */ + previous?: string; + + results_warning?: SearchWarning; +} + +export interface SearchResult { + message?: SearchResultMessage; +} + +export interface SearchResultMessage { + cid: string; + + created_at: Date; + + deleted_reply_count: number; + + html: string; + + id: string; + + mentioned_channel: boolean; + + mentioned_here: boolean; + + pinned: boolean; + + reply_count: number; + + shadowed: boolean; + + silent: boolean; + + text: string; + + type: string; + + updated_at: Date; + + attachments: Array; + + latest_reactions: Array; + + mentioned_users: Array; + + own_reactions: Array; + + restricted_visibility: Array; + + custom: CustomMessageData; + + reaction_counts: Record; + + reaction_scores: Record; + + user: UserResponse; + + command?: string; + + deleted_at?: Date; + + deleted_for_me?: boolean; + + message_text_updated_at?: Date; + + mml?: string; + + parent_id?: string; + + pin_expires?: Date; + + pinned_at?: Date; + + poll_id?: string; + + quoted_message_id?: string; + + show_in_channel?: boolean; + + mentioned_group_ids?: Array; + + mentioned_groups?: Array; + + mentioned_roles?: Array; + + thread_participants?: Array; + + channel?: ChannelResponse; + + draft?: DraftResponse; + + i18n?: Record; + + image_labels?: Record>; + + member?: ChannelMemberResponse; + + moderation?: ModerationV2Response; + + pinned_by?: UserResponse; + + poll?: PollResponseData; + + quoted_message?: MessageResponse; + + reaction_groups?: Record; + + reminder?: ReminderResponseData; + + shared_location?: SharedLocationResponseData; +} + +export interface SearchRolesResponse { + duration: string; + + /** + * Matching roles, sorted ascending by name + */ + roles: Array; +} + +export interface SearchUserGroupsResponse { + duration: string; + + /** + * List of matching user groups + */ + user_groups: Array; +} + +export interface SearchWarning { + /** + * Code corresponding to the warning + */ + warning_code: number; + + /** + * Description of the warning + */ + warning_description: string; + + /** + * Number of channels searched + */ + channel_search_count?: number; + + /** + * Channel CIDs for the searched channels + */ + channel_search_cids?: Array; +} + +export interface SendEventRequest { + event: EventRequest; +} + +export interface SendMessageRequest { + message: MessageRequest; + + keep_channel_hidden?: boolean; + + skip_enrich_url?: boolean; + + skip_push?: boolean; +} + +export interface SendMessageResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message: MessageResponse; + + /** + * Pending message metadata + */ + pending_message_metadata?: Record; +} + +export interface SendReactionRequest { + reaction: ReactionRequest; + + /** + * Whether to replace all existing user reactions + */ + enforce_unique?: boolean; + + /** + * Skips any mobile push notifications + */ + skip_push?: boolean; +} + +export interface SendReactionResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message: MessageResponse; + + reaction: ReactionResponse; +} + +export interface ShadowBlockActionRequestPayload { + /** + * Reason for shadow blocking + */ + reason?: string; +} + +export interface SharedLocation { + latitude: number; + + longitude: number; + + created_by_device_id?: string; + + end_at?: Date; +} + +export interface SharedLocationResponse { + /** + * Channel CID + */ + channel_cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Device ID that created the live location + */ + created_by_device_id: string; + + duration: string; + + /** + * Latitude coordinate + */ + latitude: number; + + /** + * Longitude coordinate + */ + longitude: number; + + /** + * Message ID + */ + message_id: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * User ID + */ + user_id: string; + + /** + * Time when the live location expires + */ + end_at?: Date; + + channel?: ChannelResponse; + + message?: MessageResponse; +} + +export interface SharedLocationResponseData { + channel_cid: string; + + created_at: Date; + + created_by_device_id: string; + + latitude: number; + + longitude: number; + + message_id: string; + + updated_at: Date; + + user_id: string; + + end_at?: Date; + + channel?: ChannelResponse; + + message?: MessageResponse; +} + +export interface SharedLocationsResponse { + duration: string; + + active_live_locations: Array; +} + +export interface ShowChannelRequest {} + +export interface ShowChannelResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; +} + +export interface SortParamRequest { + /** + * Direction of sorting, 1 for Ascending, -1 for Descending, default is 1. One of: -1, 1 + */ + direction?: number; + + /** + * Name of field to sort by + */ + field?: string; + + /** + * Type of field to sort by. Empty string or omitted means string type (default). One of: number, boolean + */ + type?: string; +} + +export interface SubmitActionRequest { + /** + * Type of moderation action to perform. One of: mark_reviewed, delete_message, delete_activity, delete_comment, delete_reaction, ban, custom, unban, restore, delete_user, unblock, block, shadow_block, unmask, kick_user, end_call, escalate, de_escalate + */ + + action_type: + | 'flag' + | 'mark_reviewed' + | 'delete_message' + | 'delete_activity' + | 'delete_comment' + | 'delete_reaction' + | 'ban' + | 'custom' + | 'unban' + | 'restore' + | 'delete_user' + | 'unblock' + | 'block' + | 'shadow_block' + | 'unmask' + | 'kick_user' + | 'end_call' + | 'reject_appeal' + | 'escalate' + | 'de_escalate' + | 'bypass'; + + /** + * UUID of the appeal to act on (required for reject_appeal, optional for other actions) + */ + appeal_id?: string; + + /** + * UUID of the review queue item to act on + */ + item_id?: string; + + ban?: BanActionRequestPayload; + + block?: BlockActionRequestPayload; + + bypass?: BypassActionRequest; + + custom?: CustomActionRequestPayload; + + delete_activity?: DeleteActivityRequestPayload; + + delete_comment?: DeleteCommentRequestPayload; + + delete_message?: DeleteMessageRequestPayload; + + delete_reaction?: DeleteReactionRequestPayload; + + delete_user?: DeleteUserRequestPayload; + + escalate?: EscalatePayload; + + flag?: FlagRequest; + + mark_reviewed?: MarkReviewedRequestPayload; + + reject_appeal?: RejectAppealRequestPayload; + + restore?: RestoreActionRequestPayload; + + shadow_block?: ShadowBlockActionRequestPayload; + + unban?: UnbanActionRequestPayload; + + unblock?: UnblockActionRequestPayload; +} + +export interface SubmitActionResponse { + duration: string; + + /** + * Present when the appeal was accepted but the entity could not be restored automatically. The moderator should restore it manually. + */ + auto_restore_warning?: string; + + appeal_item?: AppealItemResponse; + + item?: ReviewQueueItemResponse; +} + +export interface SyncRequest { + /** + * Date from which synchronization should happen + */ + last_sync_at: Date; + + /** + * List of channel CIDs to sync + */ + channel_cids: Array; +} + +export interface SyncResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of events + */ + events: Array; + + /** + * List of CIDs that user can't access + */ + inaccessible_cids?: Array; +} + +export interface TextContentParameters { + contains_url?: boolean; + + label_operator?: string; + + severity?: string; + + text_length?: number; + + text_length_operator?: string; + + blocklist_match?: Array; + + harm_labels?: Array; + + llm_harm_labels?: Record; +} + +export interface TextRuleParameters { + contains_url?: boolean; + + semantic_filter_min_threshold?: number; + + severity?: string; + + threshold?: number; + + time_window?: string; + + blocklist_match?: Array; + + harm_labels?: Array; + + semantic_filter_names?: Array; + + llm_harm_labels?: Record; +} + +export interface ThreadParticipant { + app_pk: number; + + channel_cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + last_read_at: Date; + + custom: CustomThreadData; + + last_thread_message_at?: Date; + + /** + * Left Thread At is the time when the user left the thread + */ + left_thread_at?: Date; + + /** + * Thead ID is unique string identifier of the thread + */ + thread_id?: string; + + /** + * User ID is unique string identifier of the user + */ + user_id?: string; + + user?: UserResponse; +} + +export interface ThreadResponse { + /** + * Active Participant Count + */ + active_participant_count: number; + + /** + * Channel CID + */ + channel_cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Created By User ID + */ + created_by_user_id: string; + + /** + * Parent Message ID + */ + parent_message_id: string; + + /** + * Participant Count + */ + participant_count: number; + + /** + * Title + */ + title: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * Custom data for this object + */ + custom: CustomThreadData; + + /** + * Deleted At + */ + deleted_at?: Date; + + /** + * Last Message At + */ + last_message_at?: Date; + + /** + * Reply Count + */ + reply_count?: number; + + /** + * Thread Participants + */ + thread_participants?: Array; + + channel?: ChannelResponse; + + created_by?: UserResponse; + + parent_message?: MessageResponse; +} + +export interface ThreadStateResponse { + /** + * Active Participant Count + */ + active_participant_count: number; + + /** + * Channel CID + */ + channel_cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Created By User ID + */ + created_by_user_id: string; + + /** + * Parent Message ID + */ + parent_message_id: string; + + /** + * Participant Count + */ + participant_count: number; + + /** + * Title + */ + title: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + latest_replies: Array; + + /** + * Custom data for this object + */ + custom: CustomThreadData; + + /** + * Deleted At + */ + deleted_at?: Date; + + /** + * Last Message At + */ + last_message_at?: Date; + + /** + * Reply Count + */ + reply_count?: number; + + read?: Array; + + /** + * Thread Participants + */ + thread_participants?: Array; + + channel?: ChannelResponse; + + created_by?: UserResponse; + + draft?: DraftResponse; + + parent_message?: MessageResponse; +} + +export interface ThreadUpdatedEvent { + created_at: Date; + + custom: CustomEventData; + + type: string; + + channel_id?: string; + + channel_type?: string; + + cid?: string; + + received_at?: Date; + + thread?: ThreadResponse; +} + +export interface Thresholds { + explicit?: LabelThresholds; + + spam?: LabelThresholds; + + toxic?: LabelThresholds; +} + +export interface Time {} + +export interface TranslateMessageRequest { + /** + * Language to translate message to + */ + + language: + | 'af' + | 'sq' + | 'am' + | 'ar' + | 'az' + | 'bn' + | 'bs' + | 'bg' + | 'zh' + | 'zh-TW' + | 'hr' + | 'cs' + | 'da' + | 'fa-AF' + | 'nl' + | 'en' + | 'et' + | 'fi' + | 'fr' + | 'fr-CA' + | 'ka' + | 'de' + | 'el' + | 'ha' + | 'he' + | 'hi' + | 'hu' + | 'id' + | 'it' + | 'ja' + | 'ko' + | 'lv' + | 'ms' + | 'no' + | 'fa' + | 'ps' + | 'pl' + | 'pt' + | 'ro' + | 'ru' + | 'sr' + | 'sk' + | 'sl' + | 'so' + | 'es' + | 'es-MX' + | 'sw' + | 'sv' + | 'tl' + | 'ta' + | 'th' + | 'tr' + | 'uk' + | 'ur' + | 'vi' + | 'lt' + | 'ht'; +} + +export interface TruncateChannelRequest { + /** + * Permanently delete channel data (messages, reactions, etc.) + */ + hard_delete?: boolean; + + /** + * When `message` is set disables all push notifications for it + */ + skip_push?: boolean; + + /** + * Truncate channel data up to `truncated_at`. The system message (if provided) creation time is always greater than `truncated_at` + */ + truncated_at?: Date; + + /** + * List of member IDs to hide message history for. If empty, truncates the channel for all members + */ + member_ids?: Array; + + message?: MessageRequest; +} + +export interface TruncateChannelResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + channel?: ChannelResponse; + + message?: MessageResponse; +} + +export interface TypingIndicatorsResponse { + enabled: boolean; +} + +export interface TypingStartEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "typing.start" in this case + */ + type: string; + + /** + * The ID of the channel where the user started typing + */ + channel_id?: string; + + /** + * The type of the channel where the user started typing + */ + channel_type?: string; + + /** + * The CID of the channel where the user started typing + */ + cid?: string; + + /** + * The parent ID if the user started typing in a thread + */ + parent_id?: string; + + received_at?: Date; + + user?: UserResponseCommonFields; +} + +export interface TypingStopEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "typing.stop" in this case + */ + type: string; + + /** + * The ID of the channel where the user stopped typing + */ + channel_id?: string; + + /** + * The type of the channel where the user stopped typing + */ + channel_type?: string; + + /** + * The CID of the channel where the user stopped typing + */ + cid?: string; + + /** + * The parent ID if the user stopped typing in a thread + */ + parent_id?: string; + + received_at?: Date; + + user?: UserResponseCommonFields; +} + +export interface UnbanActionRequestPayload { + /** + * Channel CID for channel-specific unban + */ + channel_cid?: string; + + /** + * Reason for the appeal decision + */ + decision_reason?: string; + + /** + * Also remove the future channels ban for this user + */ + remove_future_channels_ban?: boolean; +} + +export interface UnblockActionRequestPayload { + /** + * Reason for the appeal decision + */ + decision_reason?: string; +} + +export interface UnblockUsersRequest { + blocked_user_id: string; +} + +export interface UnblockUsersResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; +} + +export interface UnmuteChannelRequest { + /** + * Duration of mute in milliseconds + */ + expiration?: number; + + /** + * Channel CIDs to mute (if multiple channels) + */ + channel_cids?: Array; +} + +export interface UnmuteResponse { + duration: string; + + /** + * A list of users that can't be found. Common cause for this is deleted users + */ + non_existing_users?: Array; +} + +export interface UnreadCountsChannel { + channel_id: string; + + last_read: Date; + + unread_count: number; +} + +export interface UnreadCountsChannelType { + channel_count: number; + + channel_type: string; + + unread_count: number; +} + +export interface UnreadCountsThread { + last_read: Date; + + last_read_message_id: string; + + parent_message_id: string; + + unread_count: number; +} + +export interface UpdateBlockListRequest { + is_confusable_folding_enabled?: boolean; + + is_leet_check_enabled?: boolean; + + is_plural_check_enabled?: boolean; + + is_substring_matching_enabled?: boolean; + + team?: string; + + /** + * List of words to block + */ + words?: Array; +} + +export interface UpdateBlockListResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + blocklist?: BlockListResponse; +} + +export interface UpdateChannelPartialRequest { + unset?: Array; + + set?: Record; +} + +export interface UpdateChannelPartialResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of updated members + */ + members: Array; + + channel?: ChannelResponse; +} + +export interface UpdateChannelRequest { + /** + * Set to `true` to accept the invite + */ + accept_invite?: boolean; + + /** + * Sets cool down period for the channel in seconds + */ + cooldown?: number; + + /** + * Set to `true` to hide channel's history when adding new members + */ + hide_history?: boolean; + + /** + * If set, hides channel's history before this time when adding new members. Takes precedence over `hide_history` when both are provided. Must be in RFC3339 format (e.g., "2024-01-01T10:00:00Z") and in the past. + */ + hide_history_before?: Date; + + /** + * Set to `true` to reject the invite + */ + reject_invite?: boolean; + + /** + * When `message` is set disables all push notifications for it + */ + skip_push?: boolean; + + /** + * List of filter tags to add to the channel + */ + add_filter_tags?: Array; + + /** + * List of user IDs to add to the channel + */ + add_members?: Array; + + /** + * List of user IDs to make channel moderators + */ + add_moderators?: Array; + + /** + * List of channel member role assignments. If any specified user is not part of the channel, the request will fail + */ + assign_roles?: Array; + + /** + * List of user IDs to take away moderators status from + */ + demote_moderators?: Array; + + /** + * List of user IDs to invite to the channel + */ + invites?: Array; + + /** + * List of filter tags to remove from the channel + */ + remove_filter_tags?: Array; + + /** + * List of user IDs to remove from the channel + */ + remove_members?: Array; + + data?: ChannelInputRequest; + + message?: MessageRequest; +} + +export interface UpdateChannelResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of channel members + */ + members: Array; + + channel?: ChannelResponse; + + message?: MessageResponse; +} + +export interface UpdateLiveLocationRequest { + /** + * Live location ID + */ + message_id: string; + + /** + * Time when the live location expires + */ + end_at?: Date; + + /** + * Latitude coordinate + */ + latitude?: number; + + /** + * Longitude coordinate + */ + longitude?: number; +} + +export interface UpdateMemberPartialRequest { + unset?: Array; + + set?: Record; +} + +export interface UpdateMemberPartialResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + channel_member?: ChannelMemberResponse; +} + +export interface UpdateMessagePartialRequest { + /** + * Skip enriching the URL in the message + */ + skip_enrich_url?: boolean; + + skip_push?: boolean; + + /** + * Array of field names to unset + */ + unset?: Array; + + /** + * Sets new field values + */ + set?: Record; +} + +export interface UpdateMessagePartialResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message?: MessageResponse; + + /** + * Pending message metadata + */ + pending_message_metadata?: Record; +} + +export interface UpdateMessageRequest { + message: MessageRequest; + + /** + * Skip enrich URL + */ + skip_enrich_url?: boolean; + + skip_push?: boolean; +} + +export interface UpdateMessageResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message: MessageResponse; + + pending_message_metadata?: Record; +} + +export interface UpdatePollOptionRequest { + /** + * Option ID + */ + id: string; + + /** + * Option text + */ + text: string; + + custom?: CustomPollOptionData; +} + +export interface UpdatePollPartialRequest { + /** + * Array of field names to unset + */ + unset?: Array; + + /** + * Sets new field values + */ + set?: Record; +} + +export interface UpdatePollRequest { + /** + * Poll ID + */ + id: string; + + /** + * Poll name + */ + name: string; + + /** + * Allow answers + */ + allow_answers?: boolean; + + /** + * Allow user suggested options + */ + allow_user_suggested_options?: boolean; + + /** + * Poll description + */ + description?: string; + + /** + * Enforce unique vote + */ + enforce_unique_vote?: boolean; + + /** + * Is closed + */ + is_closed?: boolean; + + /** + * Max votes allowed + */ + max_votes_allowed?: number; + + /** + * Voting visibility + */ + + voting_visibility?: 'anonymous' | 'public'; + + /** + * Poll options + */ + options?: Array; + + custom?: CustomPollData; +} + +export interface UpdateQueueRequest { + description?: string; + + name?: string; + + sort?: Array>; + + filters?: Record; +} + +export interface UpdateReminderRequest { + remind_at?: Date; +} + +export interface UpdateReminderResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + reminder: ReminderResponseData; +} + +export interface UpdateThreadPartialRequest { + /** + * Array of field names to unset + */ + unset?: Array; + + /** + * Sets new field values + */ + set?: Record; +} + +export interface UpdateThreadPartialResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + thread: ThreadResponse; +} + +export interface UpdateUserGroupRequest { + /** + * The new description for the group + */ + description?: string; + + /** + * The new name of the user group + */ + name?: string; + + team_id?: string; +} + +export interface UpdateUserGroupResponse { + duration: string; + + user_group?: UserGroupResponse; +} + +export interface UpdateUserPartialRequest { + /** + * User ID to update + */ + id: string; + + unset?: Array; + + set?: Record; +} + +export interface UpdateUsersPartialRequest { + users: Array; +} + +export interface UpdateUsersRequest { + /** + * Object containing users + */ + users: Record; +} + +export interface UpdateUsersResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + membership_deletion_task_id: string; + + /** + * Object containing users + */ + users: Record; +} + +export interface UploadChannelFileRequest { + /** + * file field + */ + file?: string; + + user?: OnlyUserID; +} + +export interface UploadChannelFileResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * URL to the uploaded asset. Should be used to put to `asset_url` attachment field + */ + file?: string; + + moderation_action?: string; + + /** + * URL of the file thumbnail for supported file formats. Should be put to `thumb_url` attachment field + */ + thumb_url?: string; +} + +export interface UploadChannelRequest { + file?: string; + + /** + * field with JSON-encoded array of image size configurations + */ + upload_sizes?: Array; + + user?: OnlyUserID; +} + +export interface UploadChannelResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + file?: string; + + moderation_action?: string; + + thumb_url?: string; + + /** + * Array of image size configurations + */ + upload_sizes?: Array; +} + +export interface UpsertActionConfigItem { + action: string; + + entity_type: string; + + order: number; + + description?: string; + + icon?: string; + + id?: string; + + queue_type?: string; + + custom?: Record; +} + +export interface UpsertActionConfigRequest { + /** + * The action to perform (e.g. ban, delete_message, custom) + */ + action: string; + + /** + * Type of entity this action applies to (e.g. stream:chat:v1:message) + */ + entity_type: string; + + /** + * Display order in the dashboard (0–100, lower numbers shown first) + */ + order: number; + + /** + * Human-readable label for the dashboard button + */ + description?: string; + + /** + * Icon identifier for the dashboard button + */ + icon?: string; + + /** + * UUID of an existing action config to update; omit to create a new record + */ + id?: string; + + /** + * Queue this config belongs to; null means the default queue + */ + queue_type?: string; + + /** + * Action-specific parameters passed to the action handler + */ + custom?: Record; +} + +export interface UpsertActionConfigResponse { + duration: string; + + action_config?: ModerationActionConfigResponse; +} + +export interface UpsertConfigRequest { + /** + * Unique identifier for the moderation configuration + */ + key: string; + + /** + * Whether moderation should be performed asynchronously + */ + async?: boolean; + + /** + * Team associated with the configuration + */ + team?: string; + + ai_image_config?: AIImageConfig; + + ai_text_config?: AITextConfig; + + ai_video_config?: AIVideoConfig; + + automod_platform_circumvention_config?: AutomodPlatformCircumventionConfig; + + automod_semantic_filters_config?: AutomodSemanticFiltersConfig; + + automod_toxicity_config?: AutomodToxicityConfig; + + aws_rekognition_config?: AIImageConfig; + + block_list_config?: BlockListConfig; + + bodyguard_config?: AITextConfig; + + flood_config?: FloodConfig; + + google_vision_config?: GoogleVisionConfig; + + llm_config?: LLMConfig; + + rule_builder_config?: RuleBuilderConfig; + + velocity_filter_config?: VelocityFilterConfig; + + video_call_rule_config?: VideoCallRuleConfig; +} + +export interface UpsertConfigResponse { + duration: string; + + config?: ConfigResponse; +} + +export interface UpsertPushPreferencesRequest { + /** + * A list of push preferences for channels, calls, or the user. + */ + preferences: Array; +} + +export interface UpsertPushPreferencesResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * The channel specific push notification preferences, only returned for channels you've edited. + */ + user_channel_preferences: Record< + string, + Record + >; + + /** + * The user preferences, always returned regardless if you edited it + */ + user_preferences: Record; +} + +export interface User { + id: string; + + data?: Record; +} + +export interface UserBannedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.banned" in this case + */ + type: string; + + /** + * The ID of the channel where the target user was banned + */ + channel_id?: string; + + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel where the target user was banned + */ + channel_type?: string; + + /** + * The CID of the channel where the target user was banned + */ + cid?: string; + + /** + * The expiration date of the ban + */ + expiration?: Date; + + /** + * The reason for the ban + */ + reason?: string; + + received_at?: Date; + + /** + * ID of the review queue item (flagged message) that triggered the ban, if the ban was applied from the moderation review queue + */ + review_queue_item_id?: string; + + /** + * Whether the user was shadow banned + */ + shadow?: boolean; + + /** + * The team of the channel where the target user was banned + */ + team?: string; + + total_bans?: number; + + channel_custom?: CustomChannelData; + + created_by?: UserResponseCommonFields; +} + +export interface UserCreatedWithinParameters { + max_age?: string; +} + +export interface UserCustomPropertyParameters { + operator?: string; + + property_key?: string; +} + +export interface UserDeactivatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.deactivated" in this case + */ + type: string; + + received_at?: Date; + + created_by?: UserResponseCommonFields; +} + +export interface UserDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The type of deletion that was used for the user's conversations. One of: hard, soft, pruning, (empty string) + */ + delete_conversation: string; + + /** + * Whether the user's conversation channels were deleted + */ + delete_conversation_channels: boolean; + + /** + * The type of deletion that was used for the user's messages. One of: hard, soft, pruning, (empty string) + */ + delete_messages: string; + + /** + * The type of deletion that was used for the user. One of: hard, soft, pruning, (empty string) + */ + delete_user: string; + + /** + * Whether the user was hard deleted + */ + hard_delete: boolean; + + /** + * Whether the user's messages were marked as deleted + */ + mark_messages_deleted: boolean; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.deleted" in this case + */ + type: string; + + received_at?: Date; +} + +export interface UserGroup { + app_pk: number; + + created_at: Date; + + id: string; + + name: string; + + updated_at: Date; + + created_by?: string; + + description?: string; + + team_id?: string; + + members?: Array; +} + +export interface UserGroupCreatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "user_group.created" in this case + */ + type: string; + + received_at?: Date; + + user?: UserResponseCommonFields; + + user_group?: UserGroup; +} + +export interface UserGroupDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "user_group.deleted" in this case + */ + type: string; + + received_at?: Date; + + user?: UserResponseCommonFields; + + user_group?: UserGroup; +} + +export interface UserGroupMember { + app_pk: number; + + created_at: Date; + + group_id: string; + + is_admin: boolean; + + user_id: string; +} + +export interface UserGroupMemberAddedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The user IDs that were added + */ + members: Array; + + custom: CustomEventData; + + /** + * The type of event: "user_group.member_added" in this case + */ + type: string; + + received_at?: Date; + + user?: UserResponseCommonFields; + + user_group?: UserGroup; +} + +export interface UserGroupMemberRemovedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The user IDs that were removed + */ + members: Array; + + custom: CustomEventData; + + /** + * The type of event: "user_group.member_removed" in this case + */ + type: string; + + received_at?: Date; + + user?: UserResponseCommonFields; + + user_group?: UserGroup; +} + +export interface UserGroupResponse { + created_at: Date; + + id: string; + + name: string; + + updated_at: Date; + + created_by?: string; + + description?: string; + + team_id?: string; + + members?: Array; +} + +export interface UserGroupUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "user_group.updated" in this case + */ + type: string; + + received_at?: Date; + + user?: UserResponseCommonFields; + + user_group?: UserGroup; +} + +export interface UserIdenticalContentCountParameters { + threshold?: number; + + time_window?: string; +} + +export interface UserMessagesDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.messages.deleted" in this case + */ + type: string; + + /** + * The ID of the channel where the target user's messages were deleted + */ + channel_id?: string; + + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel where the target user's messages were deleted + */ + channel_type?: string; + + /** + * The CID of the channel where the target user's messages were deleted + */ + cid?: string; + + /** + * Whether Messages were hard deleted + */ + hard_delete?: boolean; + + received_at?: Date; + + /** + * The team of the channel where the target user's messages were deleted + */ + team?: string; + + channel_custom?: CustomChannelData; +} + +export interface UserMuteResponse { + created_at: Date; + + updated_at: Date; + + expires?: Date; + + target?: UserResponse; + + user?: UserResponse; +} + +export interface UserMutedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.muted" in this case + */ + type: string; + + received_at?: Date; + + /** + * The target users that were muted + */ + target_users?: Array; + + target_user?: UserResponseCommonFields; +} + +export interface UserPresenceChangedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.presence.changed" in this case + */ + type: string; + + received_at?: Date; +} + +export interface UserReactivatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.reactivated" in this case + */ + type: string; + + received_at?: Date; + + created_by?: UserResponseCommonFields; +} + +export interface UserRequest { + /** + * User ID + */ + id: string; + + /** + * User's profile image URL + */ + image?: string; + + invisible?: boolean; + + language?: string; + + /** + * Optional name of user + */ + name?: string; + + /** + * Custom user data + */ + custom?: CustomUserData; + + privacy_settings?: PrivacySettingsResponse; +} + +export interface UserResponse { + /** + * Whether a user is banned or not + */ + banned: boolean; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Unique user identifier + */ + id: string; + + /** + * Preferred language of a user + */ + language: string; + + /** + * Whether a user online or not + */ + online: boolean; + + /** + * Determines the set of user permissions + */ + role: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + blocked_user_ids: Array; + + /** + * List of teams user is a part of + */ + teams: Array; + + /** + * Custom data for this object + */ + custom: CustomUserData; + + avg_response_time?: number; + + /** + * Date of deactivation + */ + deactivated_at?: Date; + + /** + * Date/time of deletion + */ + deleted_at?: Date; + + image?: string; + + /** + * Date of last activity + */ + last_active?: Date; + + /** + * Optional name of user + */ + name?: string; + + /** + * Revocation date for tokens + */ + revoke_tokens_issued_before?: Date; + + teams_role?: Record; +} + +export interface UserResponseCommonFields { + banned: boolean; + + created_at: Date; + + id: string; + + language: string; + + online: boolean; + + role: string; + + updated_at: Date; + + blocked_user_ids: Array; + + teams: Array; + + custom: CustomUserData; + + avg_response_time?: number; + + deactivated_at?: Date; + + deleted_at?: Date; + + image?: string; + + last_active?: Date; + + name?: string; + + revoke_tokens_issued_before?: Date; + + teams_role?: Record; +} + +export interface UserResponsePrivacyFields { + banned: boolean; + + created_at: Date; + + id: string; + + language: string; + + online: boolean; + + role: string; + + updated_at: Date; + + blocked_user_ids: Array; + + teams: Array; + + custom: CustomUserData; + + avg_response_time?: number; + + deactivated_at?: Date; + + deleted_at?: Date; + + image?: string; + + invisible?: boolean; + + last_active?: Date; + + name?: string; + + revoke_tokens_issued_before?: Date; + + privacy_settings?: PrivacySettingsResponse; + + teams_role?: Record; +} + +export interface UserRoleParameters { + operator?: string; + + role?: string; +} + +export interface UserRuleParameters { + max_age?: string; +} + +export interface UserUnbannedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.unbanned" in this case + */ + type: string; + + /** + * The ID of the channel where the target user was unbanned + */ + channel_id?: string; + + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel where the target user was unbanned + */ + channel_type?: string; + + /** + * The CID of the channel where the target user was unbanned + */ + cid?: string; + + received_at?: Date; + + /** + * Whether the target user was shadow unbanned + */ + shadow?: boolean; + + /** + * The team of the channel where the target user was unbanned + */ + team?: string; + + channel_custom?: CustomChannelData; + + created_by?: UserResponseCommonFields; +} + +export interface UserUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponsePrivacyFields; + + /** + * The type of event: "user.updated" in this case + */ + type: string; + + received_at?: Date; +} + +export interface UserWatchingStartEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The number of users watching the channel + */ + watcher_count: number; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.watching.start" in this case + */ + type: string; + + /** + * The ID of the channel which the user started watching + */ + channel_id?: string; + + /** + * The type of the channel which the user started watching + */ + channel_type?: string; + + /** + * The CID of the channel which the user started watching + */ + cid?: string; + + received_at?: Date; +} + +export interface UserWatchingStopEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The number of users watching the channel + */ + watcher_count: number; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.watching.stop" in this case + */ + type: string; + + /** + * The ID of the channel which the user stopped watching + */ + channel_id?: string; + + /** + * The type of the channel which the user stopped watching + */ + channel_type?: string; + + /** + * The CID of the channel which the user stopped watching + */ + cid?: string; + + received_at?: Date; +} + +export interface VelocityFilterConfig { + advanced_filters: boolean; + + cascading_actions: boolean; + + cids_per_user: number; + + enabled: boolean; + + first_message_only: boolean; + + rules: Array; + + async?: boolean; +} + +export interface VelocityFilterConfigRule { + action: 'flag' | 'shadow' | 'remove' | 'ban'; + + ban_duration: number; + + cascading_action: 'flag' | 'shadow' | 'remove' | 'ban'; + + cascading_threshold: number; + + check_message_context: boolean; + + fast_spam_threshold: number; + + fast_spam_ttl: number; + + ip_ban: boolean; + + probation_period: number; + + shadow_ban: boolean; + + slow_spam_threshold: number; + + slow_spam_ttl: number; + + url_only: boolean; + + slow_spam_ban_duration?: number; +} + +export interface VideoCallRuleConfig { + flag_all_labels: boolean; + + flagged_labels: Array; + + rules: Array; +} + +export interface VideoContentParameters { + label_operator?: string; + + harm_labels?: Array; +} + +export interface VideoEndCallRequestPayload {} + +export interface VideoKickUserRequestPayload {} + +export interface VideoRuleParameters { + threshold?: number; + + time_window?: string; + + harm_labels?: Array; +} + +export interface VoteData { + answer_text?: string; + + option_id?: string; +} + +export interface WSAuthMessage { + /** + * JWT token for authentication + */ + token: string; + + user_details: ConnectUserDetailsRequest; + + /** + * List of products to subscribe to. One of: chat, video, feeds + */ + products?: Array; +} + +export type WSClientEvent = + | ({ type: '*' } & CustomEvent) + | ({ type: 'ai_indicator.clear' } & AIIndicatorClearEvent) + | ({ type: 'ai_indicator.stop' } & AIIndicatorStopEvent) + | ({ type: 'ai_indicator.update' } & AIIndicatorUpdateEvent) + | ({ type: 'app.updated' } & AppUpdatedEvent) + | ({ type: 'channel.created' } & ChannelCreatedEvent) + | ({ type: 'channel.deleted' } & ChannelDeletedEvent) + | ({ type: 'channel.frozen' } & ChannelFrozenEvent) + | ({ type: 'channel.hidden' } & ChannelHiddenEvent) + | ({ type: 'channel.kicked' } & ChannelKickedEvent) + | ({ type: 'channel.max_streak_changed' } & MaxStreakChangedEvent) + | ({ type: 'channel.truncated' } & ChannelTruncatedEvent) + | ({ type: 'channel.unfrozen' } & ChannelUnFrozenEvent) + | ({ type: 'channel.updated' } & ChannelUpdatedEvent) + | ({ type: 'channel.visible' } & ChannelVisibleEvent) + | ({ type: 'draft.deleted' } & DraftDeletedEvent) + | ({ type: 'draft.updated' } & DraftUpdatedEvent) + | ({ type: 'health.check' } & HealthCheckEvent) + | ({ type: 'member.added' } & MemberAddedEvent) + | ({ type: 'member.removed' } & MemberRemovedEvent) + | ({ type: 'member.updated' } & MemberUpdatedEvent) + | ({ type: 'message.deleted' } & MessageDeletedEvent) + | ({ type: 'message.delivered' } & MessageDeliveredEvent) + | ({ type: 'message.new' } & MessageNewEvent) + | ({ type: 'message.pending' } & PendingMessageEvent) + | ({ type: 'message.read' } & MessageReadEvent) + | ({ type: 'message.undeleted' } & MessageUndeletedEvent) + | ({ type: 'message.updated' } & MessageUpdatedEvent) + | ({ type: 'moderation.custom_action' } & ModerationCustomActionEvent) + | ({ type: 'moderation.flagged' } & ModerationFlaggedEvent) + | ({ type: 'moderation.mark_reviewed' } & ModerationMarkReviewedEvent) + | ({ type: 'notification.added_to_channel' } & NotificationAddedToChannelEvent) + | ({ type: 'notification.channel_deleted' } & NotificationChannelDeletedEvent) + | ({ + type: 'notification.channel_mutes_updated'; + } & NotificationChannelMutesUpdatedEvent) + | ({ type: 'notification.channel_truncated' } & NotificationChannelTruncatedEvent) + | ({ type: 'notification.invite_accepted' } & NotificationInviteAcceptedEvent) + | ({ type: 'notification.invite_rejected' } & NotificationInviteRejectedEvent) + | ({ type: 'notification.invited' } & NotificationInvitedEvent) + | ({ type: 'notification.mark_read' } & NotificationMarkReadEvent) + | ({ type: 'notification.mark_unread' } & NotificationMarkUnreadEvent) + | ({ type: 'notification.message_new' } & NotificationNewMessageEvent) + | ({ type: 'notification.mutes_updated' } & NotificationMutesUpdatedEvent) + | ({ type: 'notification.reminder_due' } & ReminderNotificationEvent) + | ({ type: 'notification.removed_from_channel' } & NotificationRemovedFromChannelEvent) + | ({ type: 'notification.thread_message_new' } & NotificationThreadMessageNewEvent) + | ({ type: 'poll.closed' } & PollClosedEvent) + | ({ type: 'poll.deleted' } & PollDeletedEvent) + | ({ type: 'poll.updated' } & PollUpdatedEvent) + | ({ type: 'poll.vote_casted' } & PollVoteCastedEvent) + | ({ type: 'poll.vote_changed' } & PollVoteChangedEvent) + | ({ type: 'poll.vote_removed' } & PollVoteRemovedEvent) + | ({ type: 'reaction.deleted' } & ReactionDeletedEvent) + | ({ type: 'reaction.new' } & ReactionNewEvent) + | ({ type: 'reaction.updated' } & ReactionUpdatedEvent) + | ({ type: 'reminder.created' } & ReminderCreatedEvent) + | ({ type: 'reminder.deleted' } & ReminderDeletedEvent) + | ({ type: 'reminder.updated' } & ReminderUpdatedEvent) + | ({ type: 'thread.updated' } & ThreadUpdatedEvent) + | ({ type: 'typing.start' } & TypingStartEvent) + | ({ type: 'typing.stop' } & TypingStopEvent) + | ({ type: 'user.banned' } & UserBannedEvent) + | ({ type: 'user.deactivated' } & UserDeactivatedEvent) + | ({ type: 'user.deleted' } & UserDeletedEvent) + | ({ type: 'user.messages.deleted' } & UserMessagesDeletedEvent) + | ({ type: 'user.muted' } & UserMutedEvent) + | ({ type: 'user.presence.changed' } & UserPresenceChangedEvent) + | ({ type: 'user.reactivated' } & UserReactivatedEvent) + | ({ type: 'user.unbanned' } & UserUnbannedEvent) + | ({ type: 'user.updated' } & UserUpdatedEvent) + | ({ type: 'user.watching.start' } & UserWatchingStartEvent) + | ({ type: 'user.watching.stop' } & UserWatchingStopEvent) + | ({ type: 'user_group.created' } & UserGroupCreatedEvent) + | ({ type: 'user_group.deleted' } & UserGroupDeletedEvent) + | ({ type: 'user_group.member_added' } & UserGroupMemberAddedEvent) + | ({ type: 'user_group.member_removed' } & UserGroupMemberRemovedEvent) + | ({ type: 'user_group.updated' } & UserGroupUpdatedEvent); + +export type WSEvent = + | ({ type: '*' } & CustomEvent) + | ({ type: 'ai_indicator.clear' } & AIIndicatorClearEvent) + | ({ type: 'ai_indicator.stop' } & AIIndicatorStopEvent) + | ({ type: 'ai_indicator.update' } & AIIndicatorUpdateEvent) + | ({ type: 'app.updated' } & AppUpdatedEvent) + | ({ type: 'channel.created' } & ChannelCreatedEvent) + | ({ type: 'channel.deleted' } & ChannelDeletedEvent) + | ({ type: 'channel.frozen' } & ChannelFrozenEvent) + | ({ type: 'channel.hidden' } & ChannelHiddenEvent) + | ({ type: 'channel.kicked' } & ChannelKickedEvent) + | ({ type: 'channel.max_streak_changed' } & MaxStreakChangedEvent) + | ({ type: 'channel.truncated' } & ChannelTruncatedEvent) + | ({ type: 'channel.unfrozen' } & ChannelUnFrozenEvent) + | ({ type: 'channel.updated' } & ChannelUpdatedEvent) + | ({ type: 'channel.visible' } & ChannelVisibleEvent) + | ({ type: 'draft.deleted' } & DraftDeletedEvent) + | ({ type: 'draft.updated' } & DraftUpdatedEvent) + | ({ type: 'health.check' } & HealthCheckEvent) + | ({ type: 'member.added' } & MemberAddedEvent) + | ({ type: 'member.removed' } & MemberRemovedEvent) + | ({ type: 'member.updated' } & MemberUpdatedEvent) + | ({ type: 'message.deleted' } & MessageDeletedEvent) + | ({ type: 'message.delivered' } & MessageDeliveredEvent) + | ({ type: 'message.new' } & MessageNewEvent) + | ({ type: 'message.pending' } & PendingMessageEvent) + | ({ type: 'message.read' } & MessageReadEvent) + | ({ type: 'message.undeleted' } & MessageUndeletedEvent) + | ({ type: 'message.updated' } & MessageUpdatedEvent) + | ({ type: 'moderation.custom_action' } & ModerationCustomActionEvent) + | ({ type: 'moderation.flagged' } & ModerationFlaggedEvent) + | ({ type: 'moderation.mark_reviewed' } & ModerationMarkReviewedEvent) + | ({ type: 'notification.added_to_channel' } & NotificationAddedToChannelEvent) + | ({ type: 'notification.channel_deleted' } & NotificationChannelDeletedEvent) + | ({ + type: 'notification.channel_mutes_updated'; + } & NotificationChannelMutesUpdatedEvent) + | ({ type: 'notification.channel_truncated' } & NotificationChannelTruncatedEvent) + | ({ type: 'notification.invite_accepted' } & NotificationInviteAcceptedEvent) + | ({ type: 'notification.invite_rejected' } & NotificationInviteRejectedEvent) + | ({ type: 'notification.invited' } & NotificationInvitedEvent) + | ({ type: 'notification.mark_read' } & NotificationMarkReadEvent) + | ({ type: 'notification.mark_unread' } & NotificationMarkUnreadEvent) + | ({ type: 'notification.message_new' } & NotificationNewMessageEvent) + | ({ type: 'notification.mutes_updated' } & NotificationMutesUpdatedEvent) + | ({ type: 'notification.reminder_due' } & ReminderNotificationEvent) + | ({ type: 'notification.removed_from_channel' } & NotificationRemovedFromChannelEvent) + | ({ type: 'notification.thread_message_new' } & NotificationThreadMessageNewEvent) + | ({ type: 'poll.closed' } & PollClosedEvent) + | ({ type: 'poll.deleted' } & PollDeletedEvent) + | ({ type: 'poll.updated' } & PollUpdatedEvent) + | ({ type: 'poll.vote_casted' } & PollVoteCastedEvent) + | ({ type: 'poll.vote_changed' } & PollVoteChangedEvent) + | ({ type: 'poll.vote_removed' } & PollVoteRemovedEvent) + | ({ type: 'reaction.deleted' } & ReactionDeletedEvent) + | ({ type: 'reaction.new' } & ReactionNewEvent) + | ({ type: 'reaction.updated' } & ReactionUpdatedEvent) + | ({ type: 'reminder.created' } & ReminderCreatedEvent) + | ({ type: 'reminder.deleted' } & ReminderDeletedEvent) + | ({ type: 'reminder.updated' } & ReminderUpdatedEvent) + | ({ type: 'thread.updated' } & ThreadUpdatedEvent) + | ({ type: 'typing.start' } & TypingStartEvent) + | ({ type: 'typing.stop' } & TypingStopEvent) + | ({ type: 'user.banned' } & UserBannedEvent) + | ({ type: 'user.deactivated' } & UserDeactivatedEvent) + | ({ type: 'user.deleted' } & UserDeletedEvent) + | ({ type: 'user.messages.deleted' } & UserMessagesDeletedEvent) + | ({ type: 'user.muted' } & UserMutedEvent) + | ({ type: 'user.presence.changed' } & UserPresenceChangedEvent) + | ({ type: 'user.reactivated' } & UserReactivatedEvent) + | ({ type: 'user.unbanned' } & UserUnbannedEvent) + | ({ type: 'user.updated' } & UserUpdatedEvent) + | ({ type: 'user.watching.start' } & UserWatchingStartEvent) + | ({ type: 'user.watching.stop' } & UserWatchingStopEvent) + | ({ type: 'user_group.created' } & UserGroupCreatedEvent) + | ({ type: 'user_group.deleted' } & UserGroupDeletedEvent) + | ({ type: 'user_group.member_added' } & UserGroupMemberAddedEvent) + | ({ type: 'user_group.member_removed' } & UserGroupMemberRemovedEvent) + | ({ type: 'user_group.updated' } & UserGroupUpdatedEvent); + +export interface WrappedUnreadCountsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + total_unread_count: number; + + total_unread_threads_count: number; + + channel_type: Array; + + channels: Array; + + threads: Array; + + total_unread_count_by_team?: Record; +} diff --git a/src/gen/moderation/ModerationApi.ts b/src/gen/moderation/ModerationApi.ts new file mode 100644 index 0000000000..4793fce30b --- /dev/null +++ b/src/gen/moderation/ModerationApi.ts @@ -0,0 +1,607 @@ +import type { ApiClient, StreamResponse } from '../../gen-imports'; +import type { + AppealRequest, + AppealResponse, + BanRequest, + BulkActionAppealsRequest, + BulkActionAppealsResponse, + BulkDeleteActionConfigRequest, + BulkDeleteActionConfigResponse, + BulkUpsertActionConfigRequest, + BulkUpsertActionConfigResponse, + CreateQueueRequest, + DeleteActionConfigResponse, + DeleteModerationConfigResponse, + DeleteQueueRequest, + FlagItemResponse, + FlagRequest, + GetActionConfigResponse, + GetAppealResponse, + GetConfigResponse, + ListQueuesResponse, + ModerationBanResponse, + MuteRequest, + MuteResponse, + QueryAppealsRequest, + QueryAppealsResponse, + QueryModerationConfigsRequest, + QueryModerationConfigsResponse, + QueryReviewQueueRequest, + QueryReviewQueueResponse, + QueueResponse, + SubmitActionRequest, + SubmitActionResponse, + UpdateQueueRequest, + UpsertActionConfigRequest, + UpsertActionConfigResponse, + UpsertConfigRequest, + UpsertConfigResponse, +} from '../models'; +import { decoders } from '../model-decoders/decoders'; + +export class ModerationApi { + constructor(public readonly apiClient: ApiClient) {} + + async getActionConfig(request?: { + queue_type?: string; + entity_type?: string; + exclude_defaults?: boolean; + only_defaults?: boolean; + }): Promise> { + const queryParams = { + queue_type: request?.queue_type, + entity_type: request?.entity_type, + exclude_defaults: request?.exclude_defaults, + only_defaults: request?.only_defaults, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/moderation/action_config', undefined, queryParams); + + decoders['GetActionConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async upsertActionConfig( + request: UpsertActionConfigRequest, + ): Promise> { + const body = { + action: request?.action, + entity_type: request?.entity_type, + order: request?.order, + description: request?.description, + icon: request?.icon, + id: request?.id, + queue_type: request?.queue_type, + custom: request?.custom, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/action_config', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['UpsertActionConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async bulkUpsertActionConfig( + request: BulkUpsertActionConfigRequest, + ): Promise> { + const body = { + action_configs: request?.action_configs, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/action_config/bulk', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['BulkUpsertActionConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async bulkDeleteActionConfig( + request: BulkDeleteActionConfigRequest, + ): Promise> { + const body = { + ids: request?.ids, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/action_config/bulk_delete', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['BulkDeleteActionConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteActionConfig(request: { + id: string; + }): Promise> { + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('DELETE', '/api/v2/moderation/action_config/{id}', pathParams, undefined); + + decoders['DeleteActionConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async appeal(request: AppealRequest): Promise> { + const body = { + appeal_reason: request?.appeal_reason, + entity_id: request?.entity_id, + entity_type: request?.entity_type, + review_queue_item_id: request?.review_queue_item_id, + attachments: request?.attachments, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/moderation/appeal', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['AppealResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getAppeal(request: { id: string }): Promise> { + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/moderation/appeal/{id}', + pathParams, + undefined, + ); + + decoders['GetAppealResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryAppeals( + request?: QueryAppealsRequest, + ): Promise> { + const body = { + limit: request?.limit, + next: request?.next, + prev: request?.prev, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/appeals', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['QueryAppealsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async bulkActionAppeals( + request: BulkActionAppealsRequest, + ): Promise> { + const body = { + action_type: request?.action_type, + appeal_ids: request?.appeal_ids, + mark_reviewed: request?.mark_reviewed, + reject_appeal: request?.reject_appeal, + restore: request?.restore, + unban: request?.unban, + unblock: request?.unblock, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/appeals/bulk_action', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['BulkActionAppealsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async ban(request: BanRequest): Promise> { + const body = { + target_user_id: request?.target_user_id, + banned_by_id: request?.banned_by_id, + channel_cid: request?.channel_cid, + delete_messages: request?.delete_messages, + ip_ban: request?.ip_ban, + reason: request?.reason, + shadow: request?.shadow, + timeout: request?.timeout, + banned_by: request?.banned_by, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/moderation/ban', undefined, undefined, body, 'application/json'); + + decoders['ModerationBanResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async upsertConfig( + request: UpsertConfigRequest, + ): Promise> { + const body = { + key: request?.key, + async: request?.async, + team: request?.team, + ai_image_config: request?.ai_image_config, + ai_text_config: request?.ai_text_config, + ai_video_config: request?.ai_video_config, + automod_platform_circumvention_config: + request?.automod_platform_circumvention_config, + automod_semantic_filters_config: request?.automod_semantic_filters_config, + automod_toxicity_config: request?.automod_toxicity_config, + aws_rekognition_config: request?.aws_rekognition_config, + block_list_config: request?.block_list_config, + bodyguard_config: request?.bodyguard_config, + flood_config: request?.flood_config, + google_vision_config: request?.google_vision_config, + llm_config: request?.llm_config, + rule_builder_config: request?.rule_builder_config, + velocity_filter_config: request?.velocity_filter_config, + video_call_rule_config: request?.video_call_rule_config, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/config', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['UpsertConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteConfig(request: { + key: string; + team?: string; + }): Promise> { + const queryParams = { + team: request?.team, + }; + const pathParams = { + key: request?.key, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('DELETE', '/api/v2/moderation/config/{key}', pathParams, queryParams); + + decoders['DeleteModerationConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getConfig(request: { + key: string; + team?: string; + }): Promise> { + const queryParams = { + team: request?.team, + }; + const pathParams = { + key: request?.key, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/moderation/config/{key}', + pathParams, + queryParams, + ); + + decoders['GetConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryModerationConfigs( + request?: QueryModerationConfigsRequest, + ): Promise> { + const body = { + limit: request?.limit, + next: request?.next, + prev: request?.prev, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/configs', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['QueryModerationConfigsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async flag(request: FlagRequest): Promise> { + const body = { + entity_id: request?.entity_id, + entity_type: request?.entity_type, + entity_creator_id: request?.entity_creator_id, + reason: request?.reason, + custom: request?.custom, + moderation_payload: request?.moderation_payload, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/moderation/flag', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['FlagItemResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async mute(request: MuteRequest): Promise> { + const body = { + target_ids: request?.target_ids, + timeout: request?.timeout, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/moderation/mute', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['MuteResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async listQueues(): Promise> { + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/moderation/queues', + undefined, + undefined, + ); + + decoders['ListQueuesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createQueue(request: CreateQueueRequest): Promise> { + const body = { + name: request?.name, + type: request?.type, + description: request?.description, + sort: request?.sort, + filters: request?.filters, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/moderation/queues', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['QueueResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getQueue(request: { id: string }): Promise> { + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/moderation/queues/{id}', + pathParams, + undefined, + ); + + decoders['QueueResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateQueue( + request: UpdateQueueRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + description: request?.description, + name: request?.name, + sort: request?.sort, + filters: request?.filters, + }; + + const response = await this.apiClient.sendRequest>( + 'PATCH', + '/api/v2/moderation/queues/{id}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['QueueResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteQueue( + request: DeleteQueueRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = {}; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/moderation/queues/{id}/delete', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['QueueResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryReviewQueue( + request?: QueryReviewQueueRequest, + ): Promise> { + const body = { + exclude_default_action_config: request?.exclude_default_action_config, + limit: request?.limit, + lock_count: request?.lock_count, + lock_duration: request?.lock_duration, + lock_items: request?.lock_items, + next: request?.next, + prev: request?.prev, + stats_only: request?.stats_only, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/review_queue', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['QueryReviewQueueResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async submitAction( + request: SubmitActionRequest, + ): Promise> { + const body = { + action_type: request?.action_type, + appeal_id: request?.appeal_id, + item_id: request?.item_id, + ban: request?.ban, + block: request?.block, + bypass: request?.bypass, + custom: request?.custom, + delete_activity: request?.delete_activity, + delete_comment: request?.delete_comment, + delete_message: request?.delete_message, + delete_reaction: request?.delete_reaction, + delete_user: request?.delete_user, + escalate: request?.escalate, + flag: request?.flag, + mark_reviewed: request?.mark_reviewed, + reject_appeal: request?.reject_appeal, + restore: request?.restore, + shadow_block: request?.shadow_block, + unban: request?.unban, + unblock: request?.unblock, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/submit_action', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['SubmitActionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } +} diff --git a/src/index.ts b/src/index.ts index 402e55c30b..28953be440 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,4 @@ export * from './base64'; -export * from './campaign'; -export * from './channel_batch_updater'; export * from './client'; export * from './client_state'; export * from './channel'; @@ -8,8 +6,8 @@ export * from './channel_state'; export * from './configuration'; export * from './connection'; export { type CooldownTimerState } from './CooldownTimer'; -export * from './events'; export * from './insights'; +export * from './logger'; export * from './messageComposer'; export * from './messageDelivery'; export * from './middleware'; @@ -21,7 +19,6 @@ export * from './poll'; export * from './poll_manager'; export * from './reminders'; export * from './search'; -export * from './segment'; export * from './signing'; export * from './store'; export { Thread } from './thread'; diff --git a/src/insights.ts b/src/insights.ts index bc11790d30..9a05edf6af 100644 --- a/src/insights.ts +++ b/src/insights.ts @@ -18,11 +18,12 @@ export class InsightMetrics { } /** - * postInsights is not supposed to be used by end users directly within chat application, and thus is kept isolated - * from all the client/connection code/logic. + * Posts internal insights telemetry to the Stream insights endpoint. Not intended for end-user use; + * kept isolated from the client/connection code/logic. * - * @param insightType - * @param insights + * @internal + * @param insightType - The category of insight being reported (e.g. `'ws_fatal'`). + * @param insights - The insight payload to send. */ export const postInsights = async ( insightType: InsightTypes, @@ -63,7 +64,7 @@ function buildWsBaseInsight(connection: StableWSConnection) { end_ts: new Date().getTime(), auth_type: client.getAuthType(), token: client.tokenManager.token, - user_id: client.userID, + user_id: client.userId, user_details: client._user, device: client.options.device, client_id: connection.connectionID, diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000000..c30d312948 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,28 @@ +import * as scopedLogger from '@stream-io/logger'; + +export type ChatLoggerScope = + | 'api-client' + | 'channel' + | 'channel-manager' + | 'client' + | 'connection' + | 'connection-fallback' + | 'message-composer' + | 'offline-db' + | 'state-store' + | 'text-composer' + | 'thread' + | 'thread-manager' + | 'token-manager' + | 'upload-manager' + | 'utils'; + +/** + * @internal + */ +export type ScopedLogger = scopedLogger.Logger; + +export { LogLevelEnum } from '@stream-io/logger'; +export type { ConfigureLoggersOptions, LogLevel, Sink } from '@stream-io/logger'; + +export const chatLoggerSystem = scopedLogger.createLoggerSystem(); diff --git a/src/messageComposer/LocationComposer.ts b/src/messageComposer/LocationComposer.ts index 7c91d88be9..7738113c9a 100644 --- a/src/messageComposer/LocationComposer.ts +++ b/src/messageComposer/LocationComposer.ts @@ -4,7 +4,7 @@ import type { DraftMessage, LiveLocationPayload, LocalMessage, - StaticLocationPayload, + SharedLocation, } from '../types'; export type Coords = { latitude: number; longitude: number }; @@ -14,10 +14,13 @@ export type LocationComposerOptions = { message?: DraftMessage | LocalMessage; }; -export type StaticLocationPreview = StaticLocationPayload; +export type StaticLocationPreview = SharedLocation & { + message_id?: string; +}; export type LiveLocationPreview = Omit & { durationMs?: number; + message_id?: string; }; export type LocationComposerState = { @@ -59,7 +62,7 @@ export class LocationComposer { return this.state.getLatestValue().location; } - get validLocation(): StaticLocationPayload | LiveLocationPayload | null { + get validLocation(): SharedLocation | null { const { durationMs, ...location } = (this.location ?? {}) as LiveLocationPreview; if ( !!location?.created_by_device_id && @@ -71,8 +74,9 @@ export class LocationComposer { ) { return { ...location, - end_at: durationMs && new Date(Date.now() + durationMs).toISOString(), - } as StaticLocationPayload | LiveLocationPayload; + end_at: + typeof durationMs === 'number' ? new Date(Date.now() + durationMs) : undefined, + }; } return null; } diff --git a/src/messageComposer/attachmentIdentity.ts b/src/messageComposer/attachmentIdentity.ts index 81d72463dc..38f866ce75 100644 --- a/src/messageComposer/attachmentIdentity.ts +++ b/src/messageComposer/attachmentIdentity.ts @@ -1,4 +1,4 @@ -import type { Attachment, SharedLocationResponse } from '../types'; +import type { Attachment, SharedLocationResponseData } from '../types'; import type { AudioAttachment, FileAttachment, @@ -33,8 +33,10 @@ export const isFileAttachment = ( ): attachment is FileAttachment => attachment.type === 'file' || !!( - attachment.mime_type && - supportedVideoFormat.indexOf(attachment.mime_type) === -1 && + (attachment as FileAttachment).custom?.mime_type && + supportedVideoFormat.indexOf( + (attachment as FileAttachment).custom?.mime_type as string, + ) === -1 && attachment.type !== 'video' ); @@ -76,7 +78,12 @@ export const isVideoAttachment = ( supportedVideoFormat: string[] = [], ): attachment is VideoAttachment => attachment.type === 'video' || - !!(attachment.mime_type && supportedVideoFormat.indexOf(attachment.mime_type) !== -1); + !!( + (attachment as VideoAttachment).custom?.mime_type && + supportedVideoFormat.indexOf( + (attachment as VideoAttachment).custom?.mime_type as string, + ) !== -1 + ); export const isLocalVideoAttachment = ( attachment: Attachment | LocalAttachment, @@ -92,12 +99,12 @@ export const isUploadedAttachment = ( isVideoAttachment(attachment) || isVoiceRecordingAttachment(attachment); -export const isSharedLocationResponse = ( +export const isSharedLocationResponseData = ( location: unknown, -): location is SharedLocationResponse => - !!(location as SharedLocationResponse).latitude && - !!(location as SharedLocationResponse).longitude && - !!(location as SharedLocationResponse).channel_cid; +): location is SharedLocationResponseData => + !!(location as SharedLocationResponseData).latitude && + !!(location as SharedLocationResponseData).longitude && + !!(location as SharedLocationResponseData).channel_cid; export const isGiphyAttachment = ( attachment: Attachment, diff --git a/src/messageComposer/attachmentManager.ts b/src/messageComposer/attachmentManager.ts index c3b1f57d66..42c07dc835 100644 --- a/src/messageComposer/attachmentManager.ts +++ b/src/messageComposer/attachmentManager.ts @@ -129,14 +129,14 @@ export class AttachmentManager { this.composer.updateConfig({ attachments: { acceptedFiles } }); } - /* + /** @deprecated attachments can be filtered using injecting pre-upload middleware */ get fileUploadFilter() { return this.config.fileUploadFilter; } - /* + /** @deprecated attachments can be filtered using injecting pre-upload middleware */ set fileUploadFilter(fileUploadFilter: AttachmentManagerConfig['fileUploadFilter']) { @@ -168,8 +168,16 @@ export class AttachmentManager { )?.includes('upload-file'); } + get hasCustomDoUploadRequest() { + return typeof this.config.doUploadRequest === 'function'; + } + + get hasAvailableUploadSlots() { + return this.availableUploadSlots > 0; + } + get isUploadEnabled() { - return this.hasUploadPermission && this.availableUploadSlots > 0; + return this.hasUploadPermission && this.hasAvailableUploadSlots; } get successfulUploads() { @@ -418,8 +426,10 @@ export class AttachmentManager { }); const localAttachment: LocalUploadAttachment = { - file_size: file.size, - mime_type: file.type, + custom: { + file_size: file.size, + mime_type: file.type, + }, localMetadata: { file, id: generateUUIDv4(), @@ -448,8 +458,12 @@ export class AttachmentManager { localAttachment.thumb_url = fileLike.thumb_url; } - if (isFileReference(fileLike) && fileLike.duration) { - localAttachment.duration = fileLike.duration; + if ( + isFileReference(fileLike) && + fileLike.duration && + localAttachment.type === 'voiceRecording' + ) { + localAttachment.custom.duration = fileLike.duration; } return localAttachment; @@ -551,8 +565,7 @@ export class AttachmentManager { mimeType: fileLike.type, }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { duration, ...result } = await this.channel[ + const { duration: _duration, ...result } = await this.channel[ isImageFile(fileLike) ? 'sendImage' : 'sendFile' ](file, undefined, undefined, undefined, axiosUploadConfig); return result; @@ -726,7 +739,12 @@ export class AttachmentManager { }; uploadFiles = async (files: FileReference[] | FileList | FileLike[]) => { - if (!this.isUploadEnabled) return; + if ( + (this.hasCustomDoUploadRequest && !this.hasAvailableUploadSlots) || + (!this.hasCustomDoUploadRequest && !this.isUploadEnabled) + ) + return; + const iterableFiles: FileReference[] | FileLike[] = isFileList(files) ? Array.from(files) : files; diff --git a/src/messageComposer/configuration/types.ts b/src/messageComposer/configuration/types.ts index 7e3d3a0f15..193fbe4697 100644 --- a/src/messageComposer/configuration/types.ts +++ b/src/messageComposer/configuration/types.ts @@ -2,7 +2,7 @@ import type { LinkPreview } from '../linkPreviewsManager'; import type { FileUploadFilter } from '../attachmentManager'; import type { MessageComposer } from '../messageComposer'; import type { FileLike, FileReference } from '../types'; -import type { CommandResponse, UserResponse } from '../../types'; +import type { Command, UserResponse } from '../../types'; export type MinimumUploadRequestResult = { file: string; thumb_url?: string } & Partial< Record @@ -41,14 +41,14 @@ export type TextComposerConfig = { }; export type CommandSendability = { - command: CommandResponse; + command: Command; ready: boolean; reason?: string & {}; metadata?: Record; }; export type CommandSendValidationContext = { - command: CommandResponse; + command: Command; composer: MessageComposer; commandArgsText: string; mentionedUsersInText: UserResponse[]; @@ -80,17 +80,17 @@ export type AttachmentManagerConfig = { /** Function that allows to customize the upload request. */ doUploadRequest?: UploadRequestFn; /** - * When true, the attachment manager sets `localMetadata.uploadProgress` and passes `options.onProgress` - * to `doUploadRequest` (built-in and custom). Set to false to disable progress tracking. - * @default true + * When `true`, the attachment manager sets `localMetadata.uploadProgress` and passes + * `options.onProgress` to `doUploadRequest` (built-in and custom). Set to `false` to disable + * progress tracking (defaults to `true`). */ trackUploadProgress: boolean; }; export type LinkPreviewsManagerConfig = { - /** Number of milliseconds to debounce firing the URL enrichment queries when typing. The default value is 1500(ms). */ + /** Number of milliseconds to debounce firing the URL enrichment queries when typing (defaults to `1500`). */ debounceURLEnrichmentMs: number; - /** Allows for toggling the URL enrichment and link previews in `MessageInput`. By default, the feature is disabled. */ + /** Allows for toggling the URL enrichment and link previews in `MessageInput` (defaults to `false`). */ enabled: boolean; /** Custom function to identify URLs in a string and request OG data */ findURLFn: (text: string) => string[]; @@ -100,11 +100,11 @@ export type LinkPreviewsManagerConfig = { export type LocationComposerConfig = { /** - * Allows for toggling the location addition. - * By default, the feature is enabled but has to be enabled also on channel level config via shared_locations. + * Allows for toggling the location addition (defaults to `true`). The feature also has to be + * enabled at the channel-level config via `shared_locations`. */ enabled: boolean; - /** Function that provides a stable id for a device from which the location is shared */ + /** Function that provides a stable ID for the device from which the location is shared. */ getDeviceId: () => string; }; diff --git a/src/messageComposer/linkPreviewsManager.ts b/src/messageComposer/linkPreviewsManager.ts index 10a50c1782..61b0715e4a 100644 --- a/src/messageComposer/linkPreviewsManager.ts +++ b/src/messageComposer/linkPreviewsManager.ts @@ -18,13 +18,13 @@ export interface ILinkPreviewsManager { } export enum LinkPreviewStatus { - /** Link preview has been dismissed using **/ + /** Link preview has been dismissed using */ DISMISSED = 'dismissed', - /** Link preview could not be loaded, the enrichment request has failed. **/ + /** Link preview could not be loaded, the enrichment request has failed. */ FAILED = 'failed', - /** Link preview has been successfully loaded. **/ + /** Link preview has been successfully loaded. */ LOADED = 'loaded', - /** The enrichment query is in progress for a given link. **/ + /** The enrichment query is in progress for a given link. */ LOADING = 'loading', /** The preview reference enrichment has not begun. Default status if not set. */ PENDING = 'pending', @@ -238,10 +238,9 @@ export class LinkPreviewsManager implements ILinkPreviewsManager { await Promise.all( newLinkPreviews.map(async (linkPreview) => { try { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { duration, ...ogAttachment } = await this.client.enrichURL( - linkPreview.og_scrape_url, - ); + const { duration: _duration, ...ogAttachment } = await this.client.getOG({ + url: linkPreview.og_scrape_url, + }); if (this.shouldDiscardEnrichQueries) return; // due to typing and text changes, the URL may not be anymore in the store if (this.previews.has(linkPreview.og_scrape_url)) { @@ -302,6 +301,7 @@ export class LinkPreviewsManager implements ILinkPreviewsManager { ...finalPreview, og_scrape_url: url, status, + custom: {}, }), }); }; @@ -330,8 +330,7 @@ export class LinkPreviewsManager implements ILinkPreviewsManager { preview.status === LinkPreviewStatus.PENDING; static getPreviewData = (preview: LinkPreview) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { status, ...data } = preview; + const { status: _status, ...data } = preview; return data; }; } diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index e2a4bd098e..dc16bd16a1 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -13,21 +13,22 @@ import { } from './middleware'; import type { Unsubscribe } from '../store'; import { StateStore } from '../store'; -import { formatMessage, generateUUIDv4, isLocalMessage, unformatMessage } from '../utils'; +import { formatMessage, generateUUIDv4, isLocalMessage } from '../utils'; import { mergeWith } from '../utils/mergeWith'; import { Channel } from '../channel'; import { Thread } from '../thread'; import type { - ChannelAPIResponse, - CommandResponse, + Attachment, + ChannelStateResponseFields, + Command, DraftMessage, DraftResponse, - EventTypes, + EventType, LocalMessage, - LocalMessageBase, MessageResponse, - MessageResponseBase, + UserResponse, } from '../types'; +import { chatLoggerSystem } from '../logger'; import { WithSubscriptions } from '../utils/WithSubscriptions'; import type { StreamChat } from '../client'; import type { CommandSendability, MessageComposerConfig } from './configuration/types'; @@ -90,7 +91,7 @@ export type MessageComposerState = { id: string; draftId: string | null; pollId: string | null; - quotedMessage: LocalMessageBase | null; + quotedMessage: LocalMessage | null; showReplyInChannel: boolean; /** * Baseline snapshot of the message being edited (if any). @@ -162,14 +163,15 @@ const initState = ( draftId, id, pollId: message.poll_id ?? null, - quotedMessage: quotedMessage - ? formatMessage(quotedMessage as MessageResponseBase) - : null, + quotedMessage: quotedMessage ? formatMessage(quotedMessage) : null, showReplyInChannel: false, editedMessage, }; }; +const logger = chatLoggerSystem.getLogger('message-composer'); +const offlineDbLogger = chatLoggerSystem.getLogger('offline-db'); + export class MessageComposer extends WithSubscriptions { readonly channel: Channel; readonly state: StateStore; @@ -386,7 +388,7 @@ export class MessageComposer extends WithSubscriptions { } getCommandDisabledReason = ( - command: CommandResponse, + command: Command, ): CommandSuggestionDisabledReason | undefined => { if (this.editedMessage) return 'editing'; @@ -400,11 +402,10 @@ export class MessageComposer extends WithSubscriptions { return undefined; }; - isCommandDisabled = (command: CommandResponse) => - !!this.getCommandDisabledReason(command); + isCommandDisabled = (command: Command) => !!this.getCommandDisabledReason(command); validateCommandSendability = ( - command: CommandResponse, + command: Command, text = this.textComposer.text, ): CommandSendability => { const currentMentionedUsers = this.textComposer.mentionedUsers; @@ -506,8 +507,8 @@ export class MessageComposer extends WithSubscriptions { this.state.next(initState(composition)); }; - initStateFromChannelResponse = (channelApiResponse: ChannelAPIResponse) => { - if (this.channel.cid !== channelApiResponse.channel.cid) { + initStateFromChannelResponse = (channelApiResponse: ChannelStateResponseFields) => { + if (this.channel.cid !== channelApiResponse.channel?.cid) { return; } if (channelApiResponse.draft) { @@ -616,12 +617,12 @@ export class MessageComposer extends WithSubscriptions { private subscribeMessageUpdated = () => { // todo: test the impact of 'reaction.new', 'reaction.deleted', 'reaction.updated' - const eventTypes: EventTypes[] = [ + const eventTypes = [ 'message.updated', 'reaction.new', 'reaction.deleted', // todo: do we need to subscribe to this especially when the whole state is overriden? 'reaction.updated', // todo: do we need to subscribe to this especially when the whole state is overriden? - ]; + ] satisfies EventType[]; const unsubscribeFunctions = eventTypes.map( (eventType) => @@ -866,21 +867,21 @@ export class MessageComposer extends WithSubscriptions { type: 'regular', }, localMessage: { - attachments: [], + attachments: [] as Attachment[], cid: this.channel.cid, // it is needed to match local paginator filters to be ingested into its state created_at, // only assigned to localMessage as this is used for optimistic update - deleted_at: null, + deleted_at: undefined, error: undefined, id: this.id, - mentioned_users: [], + mentioned_users: [] as UserResponse[], parent_id: this.threadId ?? undefined, - pinned_at: this.editedMessage?.pinned_at || null, - reaction_groups: null, + pinned_at: this.editedMessage?.pinned_at || undefined, + reaction_groups: undefined, status: this.editedMessage ? this.editedMessage.status : 'sending', text, type: 'regular', updated_at: created_at, - }, + } as LocalMessage, sendOptions: {}, }, }); @@ -894,7 +895,12 @@ export class MessageComposer extends WithSubscriptions { const { state, status } = await this.draftCompositionMiddlewareExecutor.execute({ eventName: 'compose', initialValue: { - draft: { id: this.id, parent_id: this.threadId ?? undefined, text: '' }, + draft: { + id: this.id, + parent_id: this.threadId ?? undefined, + text: '', + custom: {}, + }, }, }); if (status === 'discard') return; @@ -914,23 +920,20 @@ export class MessageComposer extends WithSubscriptions { try { const optimisticDraftResponse = { channel_cid: this.channel.cid, - created_at: new Date().toISOString(), + created_at: new Date(), message: draft as DraftMessage, parent_id: draft.parent_id, - quoted_message: this.quotedMessage - ? unformatMessage(this.quotedMessage) - : undefined, + quoted_message: this.quotedMessage ?? undefined, }; await this.client.offlineDb.upsertDraft({ draft: optimisticDraftResponse }); } catch (error) { - this.client.logger('error', `offlineDb:upsertDraft`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('createDraft', this.channel.cid) + .error('Upserting the draft to the offline database failed.', { error }); } } this.logDraftUpdateTimestamp(); - await this.channel.createDraft(draft); + await this.channel.createDraft({ message: draft }); }; deleteDraft = async () => { @@ -944,10 +947,9 @@ export class MessageComposer extends WithSubscriptions { parent_id: parentId, }); } catch (error) { - this.client.logger('error', `offlineDb:deleteDraft`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('deleteDraft', this.channel.cid) + .error('Deleting the draft from the offline database failed.', { error }); } } this.logDraftUpdateTimestamp(); @@ -955,11 +957,11 @@ export class MessageComposer extends WithSubscriptions { }; getDraft = async () => { - if (this.editedMessage || !this.config.drafts.enabled || !this.client.userID) return; + if (this.editedMessage || !this.config.drafts.enabled || !this.client.userId) return; const draftFromOfflineDB = await this.client.offlineDb?.getDraft({ cid: this.channel.cid, - userId: this.client.userID, + userId: this.client.userId, parent_id: this.threadId ?? undefined, }); @@ -986,10 +988,9 @@ export class MessageComposer extends WithSubscriptions { this.initState({ composition: draft }); } catch (error) { - this.client.logger('error', `messageComposer:getDraft`, { - tags: ['channel', 'messageComposer'], - error, - }); + logger + .withExtraTags('getDraft', this.channel.cid) + .error('Retrieving the draft from the server failed.', { error }); } }; diff --git a/src/messageComposer/middleware/messageComposer/attachments.ts b/src/messageComposer/middleware/messageComposer/attachments.ts index 3bde7dbd08..3793b66625 100644 --- a/src/messageComposer/middleware/messageComposer/attachments.ts +++ b/src/messageComposer/middleware/messageComposer/attachments.ts @@ -10,8 +10,7 @@ import type { } from './types'; const localAttachmentToAttachment = (localAttachment: LocalAttachment) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { localMetadata, ...attachment } = localAttachment; + const { localMetadata: _localMetadata, ...attachment } = localAttachment; return attachment as Attachment; }; diff --git a/src/messageComposer/middleware/messageComposer/cleanData.ts b/src/messageComposer/middleware/messageComposer/cleanData.ts index 18e1686c57..450411f00d 100644 --- a/src/messageComposer/middleware/messageComposer/cleanData.ts +++ b/src/messageComposer/middleware/messageComposer/cleanData.ts @@ -39,7 +39,7 @@ export const createCompositionDataCleanupMiddleware = ( ...editedMessagePayloadToBeSent, ...state.message, ...common, - }, + } as typeof state.message, sendOptions: composer.editedMessage && state.sendOptions?.skip_enrich_url ? { skip_enrich_url: state.sendOptions?.skip_enrich_url } diff --git a/src/messageComposer/middleware/messageComposer/compositionValidation.ts b/src/messageComposer/middleware/messageComposer/compositionValidation.ts index 59f85cebf2..0094218d38 100644 --- a/src/messageComposer/middleware/messageComposer/compositionValidation.ts +++ b/src/messageComposer/middleware/messageComposer/compositionValidation.ts @@ -1,5 +1,5 @@ import { textIsEmpty } from '../../textComposer'; -import type { CommandResponse } from '../../../types'; +import type { Command } from '../../../types'; import { CommandSearchSource } from '../textComposer/commands'; import { getCommandByName, @@ -20,7 +20,7 @@ const getDisabledRawCommand = ( composer: MessageComposer, searchSource: CommandSearchSource, text?: string, -): CommandResponse | undefined => { +): Command | undefined => { const rawCommand = getCommandByName(searchSource, getRawCommandName(text)); if (rawCommand && composer.isCommandDisabled(rawCommand)) { return rawCommand; diff --git a/src/messageComposer/middleware/messageComposer/messageComposerState.ts b/src/messageComposer/middleware/messageComposer/messageComposerState.ts index cf13487812..d12fe3a8b2 100644 --- a/src/messageComposer/middleware/messageComposer/messageComposerState.ts +++ b/src/messageComposer/middleware/messageComposer/messageComposerState.ts @@ -5,7 +5,7 @@ import type { MessageDraftCompositionMiddleware, } from './types'; import type { MessageComposer } from '../../messageComposer'; -import type { LocalMessage, LocalMessageBase } from '../../../types'; +import type { LocalMessage } from '../../../types'; import type { MiddlewareHandlerParams } from '../../../middleware'; export const createMessageComposerStateCompositionMiddleware = ( @@ -37,7 +37,7 @@ export const createMessageComposerStateCompositionMiddleware = ( localMessage: { ...state.localMessage, ...payload, - quoted_message: (composer.quotedMessage as LocalMessageBase) ?? undefined, + quoted_message: composer.quotedMessage ?? undefined, }, message: { ...state.message, diff --git a/src/messageComposer/middleware/messageComposer/sharedLocation.ts b/src/messageComposer/middleware/messageComposer/sharedLocation.ts index 00e17b68d6..aaf15be764 100644 --- a/src/messageComposer/middleware/messageComposer/sharedLocation.ts +++ b/src/messageComposer/middleware/messageComposer/sharedLocation.ts @@ -1,4 +1,5 @@ import type { MiddlewareHandlerParams } from '../../../middleware'; +import type { SharedLocationResponseData as Gen_SharedLocationResponseData } from '../../../gen/models'; import type { MessageComposer } from '../../messageComposer'; import type { MessageComposerMiddlewareState, @@ -18,7 +19,7 @@ export const createSharedLocationCompositionMiddleware = ( const { locationComposer } = composer; const location = locationComposer.validLocation; if (!locationComposer || !location || !composer.client.user) return forward(); - const timestamp = new Date().toISOString(); + const timestamp = new Date(); return next({ ...state, @@ -30,12 +31,12 @@ export const createSharedLocationCompositionMiddleware = ( created_at: timestamp, updated_at: timestamp, user_id: composer.client.user.id, - }, + } as Gen_SharedLocationResponseData, }, message: { ...state.message, shared_location: location, - }, + } as typeof state.message, }); }, }, diff --git a/src/messageComposer/middleware/messageComposer/textComposer.ts b/src/messageComposer/middleware/messageComposer/textComposer.ts index 054dcfe1c8..cd94f5e64d 100644 --- a/src/messageComposer/middleware/messageComposer/textComposer.ts +++ b/src/messageComposer/middleware/messageComposer/textComposer.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandlerParams } from '../../../middleware'; -import type { DraftMessage, LocalMessage, UserResponse } from '../../../types'; +import type { LocalMessage, MessageRequest, UserResponse } from '../../../types'; import type { MessageComposer } from '../../messageComposer'; import { mentionEntityToUserResponse } from '../textComposer/mentionUtils'; import type { MentionEntity } from '../textComposer/types'; @@ -30,7 +30,7 @@ type BuildMentionCompositionMetadataParams = { }; type DraftMentionPayload = Pick< - DraftMessage, + MessageRequest, | 'mentioned_channel' | 'mentioned_group_ids' | 'mentioned_here' diff --git a/src/messageComposer/middleware/messageComposer/types.ts b/src/messageComposer/middleware/messageComposer/types.ts index 51ae110fd5..b2c58f698c 100644 --- a/src/messageComposer/middleware/messageComposer/types.ts +++ b/src/messageComposer/middleware/messageComposer/types.ts @@ -1,15 +1,14 @@ import type { Middleware, MiddlewareExecutionResult } from '../../../middleware'; import type { - DraftMessagePayload, LocalMessage, - Message, + MessageRequest, SendMessageOptions, UpdatedMessage, } from '../../../types'; import type { MessageComposer } from '../../messageComposer'; export type MessageComposerMiddlewareState = { - message: Message | UpdatedMessage; + message: MessageRequest | UpdatedMessage; localMessage: LocalMessage; sendOptions: SendMessageOptions; }; @@ -22,7 +21,7 @@ export type MessageComposerMiddlewareExecutorOptions = { }; export type MessageDraftComposerMiddlewareValueState = { - draft: DraftMessagePayload; + draft: MessageRequest; }; export type MessageDraftComposerMiddlewareExecutorOptions = { diff --git a/src/messageComposer/middleware/messageComposer/userDataInjection.ts b/src/messageComposer/middleware/messageComposer/userDataInjection.ts index 9526045caa..c6f22a16cd 100644 --- a/src/messageComposer/middleware/messageComposer/userDataInjection.ts +++ b/src/messageComposer/middleware/messageComposer/userDataInjection.ts @@ -4,7 +4,7 @@ import type { MessageCompositionMiddleware, } from './types'; import type { MiddlewareHandlerParams } from '../../../middleware'; -import type { OwnUserResponse } from '../../../types'; +import type { OwnUserResponse, RequireLiteral } from '../../../types'; export const createUserDataInjectionMiddleware = ( composer: MessageComposer, @@ -27,14 +27,18 @@ export const createUserDataInjectionMiddleware = ( // precedence after we connectUser the first time and we get the connection health // check event. Due to how liberal the type of client.user is, we have to do it this // way to maintain type safety. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { channel_mutes, devices, mutes, ...messageUser } = composer.client - .user as OwnUserResponse; + + const { + channel_mutes: _channel_mutes, + devices: _devices, + mutes: _mutes, + ...messageUser + } = composer.client.user; return next({ ...state, localMessage: { ...state.localMessage, - user: messageUser, + user: messageUser as RequireLiteral, // TODO: drop RequireLiteral once the oapi spec is adjusted, user_id: messageUser.id, }, }); diff --git a/src/messageComposer/middleware/pollComposer/state.ts b/src/messageComposer/middleware/pollComposer/state.ts index ba380bac7b..2cce5ce8c2 100644 --- a/src/messageComposer/middleware/pollComposer/state.ts +++ b/src/messageComposer/middleware/pollComposer/state.ts @@ -21,7 +21,6 @@ export type PollStateValidationOutput = Partial< export type PollStateChangeValidator = (params: { data: PollComposerState['data']; - // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any; currentError?: PollComposerFieldErrors[keyof PollComposerFieldErrors]; }) => PollStateValidationOutput; @@ -95,7 +94,6 @@ export type PollCompositionStateProcessorOutput = Partial PollCompositionStateProcessorOutput; diff --git a/src/messageComposer/middleware/pollComposer/types.ts b/src/messageComposer/middleware/pollComposer/types.ts index d2aeb14010..9dcca6b030 100644 --- a/src/messageComposer/middleware/pollComposer/types.ts +++ b/src/messageComposer/middleware/pollComposer/types.ts @@ -1,5 +1,5 @@ import type { MiddlewareExecutionResult } from '../../../middleware'; -import type { CreatePollData, VotingVisibility } from '../../../types'; +import type { CreatePollRequest, VotingVisibility } from '../../../types'; export type PollComposerOption = { id: string; @@ -19,17 +19,15 @@ export type UpdateFieldsData = Partial, 'options'> & { + Omit, 'options'> & { options?: Record; } >; export type PollComposerState = { data: { - id: Id; + id: string; max_votes_allowed: string; name: string; options: PollComposerOption[]; @@ -38,14 +36,13 @@ export type PollComposerState = { description?: string; enforce_unique_vote?: boolean; is_closed?: boolean; - user_id?: string; voting_visibility?: VotingVisibility; }; errors: PollComposerFieldErrors; }; export type PollComposerCompositionMiddlewareValueState = { - data: CreatePollData; + data: CreatePollRequest; errors: PollComposerFieldErrors; }; diff --git a/src/messageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.ts b/src/messageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.ts index 167a24c751..61478bd7c5 100644 --- a/src/messageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.ts +++ b/src/messageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.ts @@ -2,6 +2,7 @@ import { createCommandsMiddleware } from './commands'; import { createCommandEffectsMiddleware } from './commandEffects'; import { createMentionsMiddleware } from './mentions'; import { createTextComposerPreValidationMiddleware } from './validation'; +import { chatLoggerSystem } from '../../../logger'; import { MiddlewareExecutor } from '../../../middleware'; import type { ExecuteParams, @@ -16,6 +17,8 @@ import type { TextComposerState, } from './types'; +const logger = chatLoggerSystem.getLogger('text-composer'); + export type TextComposerMiddlewareExecutorState = TextComposerState & { change?: { @@ -71,7 +74,13 @@ export class TextComposerMiddlewareExecutor< * That means the result of the previous search call as the debounced call result is unknown at the moment. * Custom search source implementation should handle errors meaningfully internally. */ - searchSource?.search(query)?.catch(console.error); + searchSource + ?.search(query) + ?.catch((error) => + logger + .withExtraTags('execute') + .error('Searching for suggestions failed.', { error }), + ); return result; } diff --git a/src/messageComposer/middleware/textComposer/commandEffects.ts b/src/messageComposer/middleware/textComposer/commandEffects.ts index a579f0174e..81d05e49bc 100644 --- a/src/messageComposer/middleware/textComposer/commandEffects.ts +++ b/src/messageComposer/middleware/textComposer/commandEffects.ts @@ -1,5 +1,5 @@ import type { Middleware } from '../../../middleware'; -import type { CommandResponse } from '../../../types'; +import type { Command } from '../../../types'; import type { CommandSuggestion, TextComposerCommandActivationEffect, @@ -18,14 +18,14 @@ const emptyCommandStateToRestore: TextComposerCommandActivationStateToRestore = }; const createCommandActivationEffect = ( - command: CommandResponse, + command: Command, ): TextComposerCommandActivationEffect => ({ command, stateToRestore: emptyCommandStateToRestore, type: 'command.activate', }); -const isCommandResponse = (suggestion: unknown): suggestion is CommandSuggestion => +const isCommand = (suggestion: unknown): suggestion is CommandSuggestion => typeof (suggestion as CommandSuggestion | undefined)?.name === 'string'; export const createCommandEffectsMiddleware = (): CommandEffectsMiddleware => ({ @@ -34,7 +34,7 @@ export const createCommandEffectsMiddleware = (): CommandEffectsMiddleware => ({ onSuggestionItemSelect: ({ state, next, forward }) => { const { selectedSuggestion } = state.change ?? {}; if ( - !isCommandResponse(selectedSuggestion) || + !isCommand(selectedSuggestion) || !state.command || state.command.name !== selectedSuggestion.name ) { diff --git a/src/messageComposer/middleware/textComposer/commandUtils.ts b/src/messageComposer/middleware/textComposer/commandUtils.ts index 2b5939d3dd..a46e7cf2b7 100644 --- a/src/messageComposer/middleware/textComposer/commandUtils.ts +++ b/src/messageComposer/middleware/textComposer/commandUtils.ts @@ -1,5 +1,5 @@ import type { MessageComposer } from '../../messageComposer'; -import type { CommandResponse, UserResponse } from '../../../types'; +import type { Command, UserResponse } from '../../../types'; import type { CommandSendability } from '../../configuration'; import type { CommandSearchSource } from './commands'; @@ -49,7 +49,7 @@ export const getMentionedUsersInText = (text: string, mentionedUsers: UserRespon export const getCommandByName = ( searchSource: CommandSearchSource, commandName?: string, -): CommandResponse | undefined => { +): Command | undefined => { if (!commandName) return; const normalizedCommandName = commandName.toLowerCase(); @@ -58,10 +58,7 @@ export const getCommandByName = ( .items.find((command) => command.name?.toLowerCase() === normalizedCommandName); }; -export const notifyCommandDisabled = ( - composer: MessageComposer, - command: CommandResponse, -) => { +export const notifyCommandDisabled = (composer: MessageComposer, command: Command) => { const disabledReason = composer.getCommandDisabledReason(command); if (!disabledReason) return; diff --git a/src/messageComposer/middleware/textComposer/commands.ts b/src/messageComposer/middleware/textComposer/commands.ts index 9066f27669..cc8eea7aeb 100644 --- a/src/messageComposer/middleware/textComposer/commands.ts +++ b/src/messageComposer/middleware/textComposer/commands.ts @@ -2,7 +2,7 @@ import type { Channel } from '../../../channel'; import type { Middleware } from '../../../middleware'; import type { SearchSourceOptions } from '../../../search'; import { BaseSearchSourceSync } from '../../../search'; -import type { CommandResponse } from '../../../types'; +import type { Command } from '../../../types'; import { mergeWith } from '../../../utils/mergeWith'; import type { MessageComposer } from '../../messageComposer'; import type { CommandSuggestion, TextComposerMiddlewareOptions } from './types'; @@ -40,8 +40,8 @@ export class CommandSearchSource extends BaseSearchSourceSync query(searchQuery: string) { const channelConfig = this.channel.getConfig(); const commands = channelConfig?.commands || []; - const selectedCommands: (CommandResponse & { name: string })[] = commands.filter( - (command): command is CommandResponse & { name: string } => + const selectedCommands: Command[] = commands.filter( + (command): command is Command => !!( command.name && command.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1 diff --git a/src/messageComposer/middleware/textComposer/mentionUtils.ts b/src/messageComposer/middleware/textComposer/mentionUtils.ts index 0b0af1fa22..e49ab1dcb7 100644 --- a/src/messageComposer/middleware/textComposer/mentionUtils.ts +++ b/src/messageComposer/middleware/textComposer/mentionUtils.ts @@ -13,8 +13,8 @@ export const userResponsesToMentionEntities = (users: UserResponse[]) => users.map(userResponseToMentionEntity); export const mentionEntityToUserResponse = (entity: UserMentionEntity): UserResponse => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { mentionType, ...user } = entity; + void mentionType; return user; }; diff --git a/src/messageComposer/middleware/textComposer/mentions.ts b/src/messageComposer/middleware/textComposer/mentions.ts index 096566e8c8..57d338cf8b 100644 --- a/src/messageComposer/middleware/textComposer/mentions.ts +++ b/src/messageComposer/middleware/textComposer/mentions.ts @@ -487,7 +487,7 @@ export class MentionsSearchSource extends BaseSearchSource { return this.getMembersAndWatchers() .filter((user) => { - if (user.id === this.client.userID) return false; + if (user.id === this.client.userId) return false; if (!searchQuery) return true; const updatedId = this.transliterate(removeDiacritics(user.id)).toLowerCase(); @@ -513,7 +513,7 @@ export class MentionsSearchSource extends BaseSearchSource { if (!this.memberSort) return (a.name || '').localeCompare(b.name || ''); // Apply each sort criteria in order - for (const [field, direction] of Object.entries(this.memberSort)) { + for (const { field, direction } of this.memberSort) { const aValue = a[field as keyof UserResponse]; const bValue = b[field as keyof UserResponse]; @@ -534,20 +534,22 @@ export class MentionsSearchSource extends BaseSearchSource { ], ...this.userFilters, } as UserFilters, - sort: this.userSort ?? ([{ name: 1 }, { id: 1 }] as UserSort), // todo: document the change - the sort is overridden, not merged + sort: + this.userSort ?? + ([ + { field: 'name', direction: 1 }, + { field: 'id', direction: 1 }, + ] satisfies UserSort), // todo: document the change - the sort is overridden, not merged options: { ...this.searchOptions, limit: this.pageSize, offset }, }); prepareQueryMembersParams = (searchQuery: string, offset = 0) => { // QueryMembers failed with error: \"sort must contain at maximum 1 item\" - const maxSortParamsCount = 1; - let sort: MemberSort = [{ user_id: 1 }]; - if (!this.memberSort) { - sort = [{ user_id: 1 }]; - } else if (Array.isArray(this.memberSort)) { - sort = this.memberSort[0]; - } else if (Object.keys(this.memberSort).length === maxSortParamsCount) { - sort = this.memberSort; + let sort: MemberSort = [{ field: 'user_id', direction: 1 }]; + if (!this.memberSort || !this.memberSort.length) { + sort = [{ field: 'user_id', direction: 1 }]; + } else { + sort = this.memberSort.slice(0, 1); } // todo: document the change - the sort is overridden, not merged return { // todo: document the change - the filter is overridden, not merged @@ -560,7 +562,13 @@ export class MentionsSearchSource extends BaseSearchSource { queryUsers = async (searchQuery: string, offset = 0) => { const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery, offset); - const { users } = await this.client.queryUsers(filters, sort, options); + const { users } = await this.client.queryUsers({ + payload: { + filter_conditions: filters, + sort, + ...options, + }, + }); return users; }; @@ -569,7 +577,13 @@ export class MentionsSearchSource extends BaseSearchSource { searchQuery, offset, ); - const response = await this.channel.queryMembers(filters, sort, options); + const response = await this.channel.queryMembers({ + payload: { + filter_conditions: filters, + sort, + ...options, + }, + }); return response.members.map((member) => member.user) as UserResponse[]; }; @@ -721,13 +735,15 @@ export class MentionsSearchSource extends BaseSearchSource { return data.filter( (suggestion) => suggestion.mentionType === 'user' && - mutedUsers.some((mute) => mute.target.id === suggestion.id), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + mutedUsers.some((mute) => mute.target!.id === suggestion.id), ); } return data.filter( (suggestion) => suggestion.mentionType !== 'user' || - mutedUsers.every((mute) => mute.target.id !== suggestion.id), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + mutedUsers.every((mute) => mute.target!.id !== suggestion.id), ); } diff --git a/src/messageComposer/middleware/textComposer/types.ts b/src/messageComposer/middleware/textComposer/types.ts index e99eae0e77..9967368a30 100644 --- a/src/messageComposer/middleware/textComposer/types.ts +++ b/src/messageComposer/middleware/textComposer/types.ts @@ -1,6 +1,6 @@ import type { MessageComposer } from '../../messageComposer'; import type { MessageComposerEffect } from '../../messageComposer'; -import type { CommandResponse, Event, UserResponse } from '../../../types'; +import type { Command, Event, UserResponse } from '../../../types'; import type { TokenizationPayload } from './textMiddlewareUtils'; import type { SearchSource, SearchSourceSync } from '../../../search'; import type { CustomTextComposerSuggestion } from '../../types.custom'; @@ -15,7 +15,7 @@ export type BaseSuggestion = { export type CommandSuggestionDisabledReason = 'editing' | 'quoted_message'; -export type CommandSuggestion = BaseSuggestion & CommandResponse; +export type CommandSuggestion = BaseSuggestion & Command; export type UserSuggestion = BaseSuggestion & UserResponse & TokenizationPayload & { @@ -90,7 +90,7 @@ export type TextComposerCommandActivationStateToRestore = Partial; export type TextComposerCommandActivationEffect = { - command: CommandResponse; + command: Command; stateToRestore?: TextComposerCommandActivationStateToRestore; type: 'command.activate'; }; @@ -131,6 +131,6 @@ export type TextComposerState = { * Maps `user.id` -> latest typing event (`typing.start`/`typing.stop`) for that user. */ typing: Record; - command?: CommandResponse | null; + command?: Command | null; suggestions?: Suggestions; }; diff --git a/src/messageComposer/pollComposer.ts b/src/messageComposer/pollComposer.ts index 0a4b001d2e..762e09a6c6 100644 --- a/src/messageComposer/pollComposer.ts +++ b/src/messageComposer/pollComposer.ts @@ -45,7 +45,6 @@ export class PollComposer { max_votes_allowed: '', name: '', options: [{ id: generateUUIDv4(), text: '' }], - user_id: this.composer.client.user?.id, voting_visibility: VotingVisibility.public, }, errors: {}, @@ -76,9 +75,6 @@ export class PollComposer { get options() { return this.state.getLatestValue().data.options; } - get user_id() { - return this.state.getLatestValue().data.user_id; - } get voting_visibility() { return this.state.getLatestValue().data.voting_visibility; } @@ -86,7 +82,7 @@ export class PollComposer { get canCreatePoll() { const { data, errors } = this.state.getLatestValue(); const hasAtLeastOneNonEmptyOption = - data.options.filter((o) => !!o.text.trim()).length > 0; + Array.isArray(data.options) && data.options.some((o) => !!o.text?.trim()); const hasName = !!data.name; const maxVotesAllowedNumber = parseInt( data.max_votes_allowed?.match(VALID_MAX_VOTES_VALUE_REGEX)?.[0] || '', @@ -116,9 +112,11 @@ export class PollComposer { }; /** - * Updates specified fields and generates relevant errors - * @param data - * @param injectedFieldErrors - errors produced externally that will take precedence over the errors generated in the middleware chaing + * Updates specified fields and generates relevant errors. + * + * @param data - Partial poll data with the fields to update. + * @param injectedFieldErrors - Errors produced externally that will take precedence over the + * errors generated in the middleware chain. */ // FIXME: change method params to a single object with the next major release updateFields = async ( diff --git a/src/messageComposer/textComposer.ts b/src/messageComposer/textComposer.ts index a1b8b9c9c7..bb5c91a041 100644 --- a/src/messageComposer/textComposer.ts +++ b/src/messageComposer/textComposer.ts @@ -18,13 +18,7 @@ import { userResponseToMentionEntity, } from './middleware/textComposer/mentionUtils'; import type { MessageComposer } from './messageComposer'; -import type { - CommandResponse, - DraftMessage, - Event, - LocalMessage, - UserResponse, -} from '../types'; +import type { Command, DraftMessage, Event, LocalMessage, UserResponse } from '../types'; export type TextComposerOptions = { composer: MessageComposer; @@ -372,7 +366,7 @@ export class TextComposer { this.setMentions(mentions); }; - setCommand = (command: CommandResponse | null) => { + setCommand = (command: Command | null) => { if (!command) { this.clearCommand(); return; diff --git a/src/messageComposer/types.ts b/src/messageComposer/types.ts index 8c0545b940..5c41fab047 100644 --- a/src/messageComposer/types.ts +++ b/src/messageComposer/types.ts @@ -1,4 +1,5 @@ -import type { Attachment, FileUploadConfig, GiphyData } from '../types'; +import type { CustomAttachmentData } from '../custom_types'; +import type { Attachment, FileUploadConfig } from '../types'; export type LocalAttachment = AnyLocalAttachment | LocalUploadAttachment; @@ -57,54 +58,31 @@ export type UploadedAttachment = | VoiceRecordingAttachment; export type VoiceRecordingAttachment = Attachment & { - asset_url: string; type: 'voiceRecording'; - duration?: number; - file_size?: number; - mime_type?: string; - title?: string; - waveform_data?: Array; + custom: CustomAttachmentData & { + duration?: number; + waveform_data?: Array; + }; }; export type FileAttachment = Attachment & { type: 'file'; - asset_url?: string; - file_size?: number; - mime_type?: string; - title?: string; }; export type AudioAttachment = Attachment & { type: 'audio'; - asset_url?: string; - file_size?: number; - mime_type?: string; - title?: string; }; export type VideoAttachment = Attachment & { type: 'video'; - asset_url?: string; - file_size?: number; - mime_type?: string; - thumb_url?: string; - title?: string; }; export type ImageAttachment = Attachment & { type: 'image'; - fallback?: string; - image_url?: string; - original_height?: number; - original_width?: number; }; export type GiphyAttachment = Attachment & { type: 'giphy'; - giphy?: GiphyData; - title?: string; - title_link?: string; - thumbnail_url?: string; }; export type BaseLocalAttachmentMetadata = { diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index 2140551845..757d4ee71e 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -3,15 +3,16 @@ import { Channel } from '../channel'; import type { ThreadUserReadState } from '../thread'; import { Thread } from '../thread'; import type { - ErrorFromResponse, EventAPIResponse, LocalMessage, - MarkDeliveredOptions, - MarkReadOptions, + MarkDeliveredRequest, + MarkReadRequest, + StreamAPIError, + StreamResponse, } from '../types'; -import { type APIErrorResponse } from '../types'; import { throttle, userHasReadReceipts } from '../utils'; import { isAPIError, isErrorRetryable } from '../errors'; +import type { MarkReadResponse as Gen_MarkReadResponse } from '../gen/models'; const MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD = 100 as const; const MARK_AS_DELIVERED_BUFFER_TIMEOUT = 1000 as const; @@ -25,7 +26,7 @@ type MessageId = string; type ChannelThreadCompositeId = string; export type AnnounceDeliveryOptions = Omit< - MarkDeliveredOptions, + MarkDeliveredRequest, 'latest_delivered_messages' >; @@ -41,7 +42,7 @@ export class MessageDeliveryReporter { protected nextDeliveryReportCandidates: Map = new Map(); - protected markDeliveredRequestPromise: Promise | null = null; + protected markDeliveredRequestPromise: Promise | null = null; protected markDeliveredTimeout: ReturnType | null = null; protected requestTimeoutMs: number = MARK_AS_DELIVERED_BUFFER_TIMEOUT; @@ -85,7 +86,11 @@ export class MessageDeliveryReporter { } /** - * Build latest_delivered_messages payload from an arbitrary buffer (deliveryReportCandidates / nextDeliveryReportCandidates) + * Builds the `latest_delivered_messages` payload from an arbitrary buffer + * (`deliveryReportCandidates` or `nextDeliveryReportCandidates`). + * + * @param map - The buffer mapping channel/thread composite IDs to the latest delivered message ID. + * @returns The payload entries ready to be sent to the server. */ private confirmationsFrom(map: Map) { return Array.from(map.entries()).map(([key, messageId]) => { @@ -107,9 +112,10 @@ export class MessageDeliveryReporter { } /** - * Generate candidate key for storing in the candidates buffer - * @param collection - * @private + * Generates a candidate key for storing in the candidates buffer. + * + * @param collection - The channel or thread to derive a candidate key for. + * @returns The composite identifier, or `undefined` when the collection is neither a Channel nor a Thread. */ private candidateKeyFor( collection: Channel | Thread, @@ -119,8 +125,11 @@ export class MessageDeliveryReporter { } /** - * Retrieve the reference to the latest message in the state that is nor read neither reported as delivered - * @param collection + * Retrieves a reference to the latest message in the state that is neither read nor reported as + * delivered. + * + * @param collection - The channel or thread to inspect. + * @returns The next candidate to report as delivered, or `undefined` when none applies. */ private getNextDeliveryReportCandidate = ( collection: Channel | Thread, @@ -133,6 +142,7 @@ export class MessageDeliveryReporter { let lastReadAt: Date | undefined; let key: string | undefined = undefined; + // todo: unify the API for read state access btw channel and threads if (isChannel(collection)) { latestMessages = collection.messagePaginator.headItems; const ownReadState = collection.state.read[ownUserId] ?? {}; @@ -140,7 +150,10 @@ export class MessageDeliveryReporter { lastDeliveredAt = ownReadState?.last_delivered_at; key = collection.cid; } else if (isThread(collection)) { - latestMessages = collection.messagePaginator.state.getLatestValue().items ?? []; + // Use the head (newest-loaded) window, not the active/visible interval: the candidate logic + // below inspects the newest message, which the active interval only reflects when scrolled to + // the head. Mirrors the channel branch above. + latestMessages = collection.messagePaginator.headItems; const ownReadState = collection.state.getLatestValue().read[ownUserId] ?? ({} as ThreadUserReadState); lastReadAt = ownReadState?.lastReadAt; @@ -168,8 +181,9 @@ export class MessageDeliveryReporter { }; /** - * Updates the delivery candidates buffer with the latest delivery candidates - * @param collection + * Updates the delivery candidates buffer with the latest delivery candidates. + * + * @param collection - The channel or thread whose latest delivery candidate to track. */ private trackDeliveredCandidate(collection: Channel | Thread) { if (!MessageDeliveryReporter.hasPermissionToReportDeliveryFor(collection)) return; @@ -183,9 +197,9 @@ export class MessageDeliveryReporter { } /** - * Removes candidate from the delivery report buffer - * @param collection - * @private + * Removes a candidate from the delivery report buffer. + * + * @param collection - The channel or thread whose candidate should be removed. */ private removeCandidateFor(collection: Channel | Thread) { const candidateKey = this.candidateKeyFor(collection); @@ -195,10 +209,11 @@ export class MessageDeliveryReporter { } /** - * Records the latest message delivered for Channel or Thread instances and schedules the next report - * if not already scheduled and candidates exist. - * Should be used for WS handling (message.new) as well as for ingesting HTTP channel query results. - * @param collections + * Records the latest message delivered for Channel or Thread instances and schedules the next + * report if not already scheduled and candidates exist. Should be used for WS handling + * (`message.new`) as well as for ingesting HTTP channel query results. + * + * @param collections - The channels or threads whose candidates should be synced. */ public syncDeliveredCandidates(collections: (Channel | Thread)[]) { if (this.client.user?.privacy_settings?.delivery_receipts?.enabled === false) return; @@ -207,8 +222,9 @@ export class MessageDeliveryReporter { } /** - * Fires delivery announcement request followed by immediate delivery candidate buffer reset. - * @param options + * Fires a delivery announcement request followed by an immediate delivery candidate buffer reset. + * + * @param options - Flags forwarded to `client.markDelivered` (optional). */ public announceDelivery = (options?: AnnounceDeliveryOptions) => { if (!this.canExecuteRequest) return; @@ -240,7 +256,7 @@ export class MessageDeliveryReporter { postFlightReconcile(); }; - const handleError = (error: ErrorFromResponse | Error) => { + const handleError = (error: StreamAPIError | Error) => { // re-populate relevant candidates for the next report // but make sure to keep the items that failed to be reported the first next time const newDeliveryReportCandidates = new Map(sendBuffer); @@ -251,7 +267,9 @@ export class MessageDeliveryReporter { if ( (isAPIError(error) && isErrorRetryable(error)) || - (error as ErrorFromResponse).status >= 500 + (typeof (error as StreamAPIError).status === 'number' && + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (error as StreamAPIError).status! >= 500) ) { this.increaseBackOff(); postFlightReconcile(); @@ -261,7 +279,7 @@ export class MessageDeliveryReporter { }; this.markDeliveredRequestPromise = this.client - .markChannelsDelivered(payload) + .markDelivered(payload) .then(handleSuccess, handleError); }; @@ -274,11 +292,13 @@ export class MessageDeliveryReporter { }; /** - * Delegates the mark-read call to the Channel or Thread instance - * @param collection - * @param options + * Delegates the mark-read call to the Channel or Thread instance. + * + * @param collection - The channel or thread to mark as read. + * @param options - Flags forwarded to the underlying `markRead` call (optional). + * @returns The server response, or `null` when the collection is unsupported. */ - public markRead = async (collection: Channel | Thread, options?: MarkReadOptions) => { + public markRead = async (collection: Channel | Thread, options?: MarkReadRequest) => { if (!userHasReadReceipts(this.client)) return null; const isThreadCollection = isThread(collection); const channel = isThreadCollection ? collection.channel : collection; @@ -286,14 +306,14 @@ export class MessageDeliveryReporter { ? { ...options, thread_id: collection.id } : options; - let result: EventAPIResponse | null = null; + let result: EventAPIResponse | StreamResponse | null = null; if (isThreadCollection) { const markReadRequestHandler = collection.configState.getLatestValue() .requestHandlers?.markReadRequest as | ((params: { thread: Thread; - options?: MarkReadOptions; + options?: MarkReadRequest; }) => Promise | void) | undefined; result = markReadRequestHandler @@ -301,18 +321,18 @@ export class MessageDeliveryReporter { options: requestOptions, thread: collection, })) ?? null) - : await channel.markAsReadRequest(requestOptions); + : await channel.markRead(requestOptions); } else { const markReadRequestHandler = channel.configState.getLatestValue().requestHandlers ?.markReadRequest as | ((params: { channel: Channel; - options?: MarkReadOptions; + options?: MarkReadRequest; }) => Promise | void) | undefined; result = markReadRequestHandler ? ((await markReadRequestHandler({ channel, options: requestOptions })) ?? null) - : await channel.markAsReadRequest(requestOptions); + : await channel.markRead(requestOptions); } this.removeCandidateFor(collection); @@ -321,11 +341,23 @@ export class MessageDeliveryReporter { /** * Throttles the MessageDeliveryReporter.markRead call + * * @param collection * @param options */ - public throttledMarkRead = throttle(this.markRead, MARK_AS_READ_THROTTLE_TIMEOUT, { - leading: true, - trailing: true, - }); + // Auto mark-read is throttled and fire-and-forget: it's triggered by state changes / WS events, + // not by an awaiting caller, so a rejection here has nowhere to propagate and would otherwise + // surface as an unhandled rejection (e.g. `channel.markRead` throwing when read events are + // disabled, or a transient network error). Swallow it — the auto path retries on the next + // trigger, and explicit `markRead()` callers still receive the error. + public throttledMarkRead = throttle( + (collection: Channel | Thread, options?: MarkReadRequest) => { + void this.markRead(collection, options).catch(() => undefined); + }, + MARK_AS_READ_THROTTLE_TIMEOUT, + { + leading: true, + trailing: true, + }, + ); } diff --git a/src/messageDelivery/MessageReceiptsTracker.ts b/src/messageDelivery/MessageReceiptsTracker.ts index ea9787528b..c265051c6b 100644 --- a/src/messageDelivery/MessageReceiptsTracker.ts +++ b/src/messageDelivery/MessageReceiptsTracker.ts @@ -1,4 +1,4 @@ -import type { ReadResponse, UserResponse } from '../types'; +import type { ReadStateResponse, UserResponse } from '../types'; import { StateStore } from '../store'; import type { Channel } from '../channel'; import { WithSubscriptions } from '../utils/WithSubscriptions'; @@ -62,10 +62,13 @@ const findIndex = (arr: T[], target: MsgRef, keyOf: (x: T) => MsgRef): number }; /** - * For insertion after the last equal item. E.g. array [a] exists and b is being inserted -> we want [a,b], not [b,a]. - * @param arr - * @param target - * @param keyOf + * Finds the insertion index after the last equal item. E.g. when array `[a]` exists and `b` is + * being inserted we want `[a, b]`, not `[b, a]`. + * + * @param arr - The sorted array to search. + * @param target - The reference value to compare against. + * @param keyOf - Accessor that maps an item to its comparable reference. + * @returns The insertion index in `arr`. */ const findUpperIndex = (arr: T[], target: MsgRef, keyOf: (x: T) => MsgRef): number => { let lo = 0, @@ -131,7 +134,7 @@ export type OwnMessageReceiptsTrackerOptions = { * * Event ingestion * --------------- - * - `ingestInitial(rows: ReadResponse[])`: Builds initial state from server snapshot. + * - `ingestInitial(rows: ReadStateResponse[])`: Builds initial state from server snapshot. * If a user’s `last_read` is ahead of `last_delivered_at`, the tracker enforces * the invariant `lastDeliveredRef >= lastReadRef`. * - `onMessageRead(user, readAtISO)`: @@ -261,7 +264,7 @@ export class MessageReceiptsTracker extends WithSubscriptions { } /** Build initial state from server snapshots (single pass + sort). */ - ingestInitial(responses: ReadResponse[]) { + ingestInitial(responses: ReadStateResponse[]) { this.byUser.clear(); this.readSorted = []; this.deliveredSorted = []; @@ -303,13 +306,13 @@ export class MessageReceiptsTracker extends WithSubscriptions { lastDeliveredMessageId, }: { user: UserResponse; - deliveredAt: string; + deliveredAt: Date; lastDeliveredMessageId?: string; }) { - const timestampMs = new Date(deliveredAt).getTime(); + const timestampMs = deliveredAt.getTime(); const msgRef = lastDeliveredMessageId ? { timestampMs, msgId: lastDeliveredMessageId } - : this.locateMessage(new Date(deliveredAt).getTime()); + : this.locateMessage(deliveredAt.getTime()); if (!msgRef) return; const userProgress = this.ensureUser(user); @@ -338,10 +341,10 @@ export class MessageReceiptsTracker extends WithSubscriptions { lastReadMessageId, }: { user: UserResponse; - readAt: string; + readAt: Date; lastReadMessageId?: string; }) { - const timestampMs = new Date(readAt).getTime(); + const timestampMs = readAt.getTime(); const msgRef = lastReadMessageId ? { timestampMs, msgId: lastReadMessageId } : this.locateMessage(timestampMs); @@ -387,13 +390,13 @@ export class MessageReceiptsTracker extends WithSubscriptions { lastReadMessageId, }: { user: UserResponse; - lastReadAt?: string; + lastReadAt?: Date; lastReadMessageId?: string; }) { const userProgress = this.ensureUser(user); const newReadRef: MsgRef = lastReadAt - ? { timestampMs: new Date(lastReadAt).getTime(), msgId: lastReadMessageId ?? '' } + ? { timestampMs: lastReadAt.getTime(), msgId: lastReadMessageId ?? '' } : { ...MIN_REF }; // If no change, exit early. @@ -649,26 +652,28 @@ export class MessageReceiptsTracker extends WithSubscriptions { private readStoreStateToResponses( readState: Record, - ): ReadResponse[] { - return Object.values(readState).reduce((responses, userReadState) => { - if (!isValidReadState(userReadState)) return responses; - const lastReadDate = new Date(userReadState.last_read); - if (Number.isNaN(lastReadDate.getTime())) return responses; - const lastReadIso = lastReadDate.toISOString(); - - responses.push({ - last_read: lastReadIso, - user: userReadState.user, - last_read_message_id: userReadState.last_read_message_id, - unread_messages: userReadState.unread_messages ?? 0, - last_delivered_at: userReadState.last_delivered_at - ? new Date(userReadState.last_delivered_at).toISOString() - : undefined, - last_delivered_message_id: userReadState.last_delivered_message_id, - }); + ): ReadStateResponse[] { + return Object.values(readState).reduce( + (responses, userReadState) => { + if (!isValidReadState(userReadState)) return responses; + const lastReadDate = new Date(userReadState.last_read); + if (Number.isNaN(lastReadDate.getTime())) return responses; + + responses.push({ + last_read: lastReadDate, + user: userReadState.user, + last_read_message_id: userReadState.last_read_message_id, + unread_messages: userReadState.unread_messages ?? 0, + last_delivered_at: userReadState.last_delivered_at + ? new Date(userReadState.last_delivered_at) + : undefined, + last_delivered_message_id: userReadState.last_delivered_message_id, + }); - return responses; - }, []); + return responses; + }, + [], + ); } private emitSnapshot() { diff --git a/src/messageOperations/MessageOperationStatePolicy.ts b/src/messageOperations/MessageOperationStatePolicy.ts index 329690d835..8db6083f6a 100644 --- a/src/messageOperations/MessageOperationStatePolicy.ts +++ b/src/messageOperations/MessageOperationStatePolicy.ts @@ -1,9 +1,4 @@ -import type { - APIErrorResponse, - ErrorFromResponse, - LocalMessage, - MessageResponse, -} from '../types'; +import type { LocalMessage, MessageResponse, StreamAPIError } from '../types'; import { formatMessage } from '../utils'; export type MessageOperationStatePolicyContext = { @@ -11,17 +6,12 @@ export type MessageOperationStatePolicyContext = { get: (id: string) => LocalMessage | undefined; }; -const parseError = (error: unknown): ErrorFromResponse => { +const parseError = (error: unknown): StreamAPIError => { const stringError = JSON.stringify(error); - return ( - stringError ? JSON.parse(stringError) : {} - ) as ErrorFromResponse; + return (stringError ? JSON.parse(stringError) : {}) as StreamAPIError; }; -const isAlreadyExistsError = ( - error: unknown, - parsed: ErrorFromResponse, -) => +const isAlreadyExistsError = (error: unknown, parsed: StreamAPIError) => parsed.code === 4 && error instanceof Error && error.message.includes('already exists'); export class MessageOperationStatePolicy { diff --git a/src/messageOperations/MessageOperations.ts b/src/messageOperations/MessageOperations.ts index 8fb2314016..5d5a8eb9bf 100644 --- a/src/messageOperations/MessageOperations.ts +++ b/src/messageOperations/MessageOperations.ts @@ -1,5 +1,5 @@ // todo: add tests -import type { Message, UpdateMessageOptions } from '../types'; +import type { MessageRequest, UpdateMessageOptions } from '../types'; import { formatMessage, localMessageToNewMessagePayload } from '../utils'; import { MessageOperationStatePolicy } from './MessageOperationStatePolicy'; import type { @@ -13,7 +13,7 @@ const FAILED_SEND_CACHE_MAX_SIZE = 100; const FAILED_SEND_CACHE_TTL_MS = 5 * 60 * 1000; type FailedSendCacheEntry = { - message: Message; + message: MessageRequest; options?: OperationParams<'send'>['options']; cachedAt: number; }; @@ -28,7 +28,7 @@ export class MessageOperations { this.policy = new MessageOperationStatePolicy({ ingest: ctx.ingest, get: ctx.get }); } - private normalizeMessage(message: Message): Message { + private normalizeMessage(message: MessageRequest): MessageRequest { return this.ctx.normalizeOutgoingMessage ? this.ctx.normalizeOutgoingMessage(message) : message; @@ -46,7 +46,7 @@ export class MessageOperations { private cacheFailedSend(params: { messageId: string; - message: Message; + message: MessageRequest; options?: OperationParams<'send'>['options']; }) { this.pruneExpiredFailedSendCache(); diff --git a/src/messageOperations/types.ts b/src/messageOperations/types.ts index 4d7ce185e6..1ac9911323 100644 --- a/src/messageOperations/types.ts +++ b/src/messageOperations/types.ts @@ -1,7 +1,7 @@ import type { DeleteMessageOptions, LocalMessage, - Message, + MessageRequest, MessageResponse, SendMessageAPIResponse, SendMessageOptions, @@ -33,7 +33,7 @@ export type MessageOperationSpec = { export type OperationParams = { localMessage: LocalMessage; options?: MessageOperationSpec[K]['options']; -} & (K extends 'send' | 'retry' ? { message?: Message } : {}); +} & (K extends 'send' | 'retry' ? { message?: MessageRequest } : {}); export type OperationResponse = { message: MessageResponse }; @@ -52,11 +52,11 @@ export type MessageOperationsContext = { ingest: (m: LocalMessage) => void; get: (id: string) => LocalMessage | undefined; - normalizeOutgoingMessage?: (m: Message) => Message; + normalizeOutgoingMessage?: (m: MessageRequest) => MessageRequest; defaults: { delete: (id: string, o?: DeleteMessageOptions) => Promise; - send: (m: Message, o?: SendMessageOptions) => Promise; + send: (m: MessageRequest, o?: SendMessageOptions) => Promise; update: (m: LocalMessage, o?: UpdateMessageOptions) => Promise; }; diff --git a/src/moderation.ts b/src/moderation.ts index b40c265091..a95e4ed36f 100644 --- a/src/moderation.ts +++ b/src/moderation.ts @@ -1,464 +1,71 @@ -import type { - APIResponse, - CheckResponse, - CustomCheckFlag, - CustomCheckResponse, - GetConfigResponse, - GetUserModerationReportOptions, - GetUserModerationReportResponse, - ModerationConfig, - ModerationFlagOptions, - ModerationMuteOptions, - ModerationRule, - ModerationRuleRequest, - MuteUserResponse, - Pager, - QueryConfigsResponse, - QueryModerationConfigsFilters, - QueryModerationConfigsSort, - QueryModerationRulesFilters, - QueryModerationRulesResponse, - QueryModerationRulesSort, - RequireAtLeastOne, - ReviewQueueFilters, - ReviewQueuePaginationOptions, - ReviewQueueResponse, - ReviewQueueSort, - SubmitActionOptions, - SubmitActionResponse, - UnmuteUserResponse, - UpsertConfigResponse, - UpsertModerationRuleResponse, -} from './types'; +import type { ModerationFlagOptions, UnmuteUserResponse } from './types'; import type { StreamChat } from './client'; -import { normalizeQuerySort } from './utils'; +import { ModerationApi } from './gen/moderation/ModerationApi'; export const MODERATION_ENTITY_TYPES = { user: 'stream:user', message: 'stream:chat:v1:message', - userprofile: 'stream:v1:user_profile', }; // Moderation class provides all the endpoints related to moderation v2. -export class Moderation { +export class Moderation extends ModerationApi { client: StreamChat; constructor(client: StreamChat) { + super(client.api); this.client = client; } /** - * Flag a user + * Flags a user. * - * @param {string} flaggedUserID User ID to be flagged - * @param {string} reason Reason for flagging the user - * @param {Object} options Additional options for flagging the user - * @param {string} options.user_id (For server side usage) User ID of the user who is flagging the target user - * @param {Object} options.custom Additional data to be stored with the flag - * @returns + * @param flaggedUserId - User ID to be flagged. + * @param reason - Reason for flagging the user. + * @param options - Additional options for flagging the user (optional, defaults to `{}`). + * @param options.custom - Additional data to be stored with the flag (optional). + * @returns The flag response. */ - flagUser(flaggedUserID: string, reason: string, options: ModerationFlagOptions = {}) { - return this.flag(MODERATION_ENTITY_TYPES.user, flaggedUserID, '', reason, options); + flagUser(flaggedUserId: string, reason: string, options: ModerationFlagOptions = {}) { + return this.flag({ + entity_type: MODERATION_ENTITY_TYPES.user, + entity_id: flaggedUserId, + entity_creator_id: '', + reason, + ...options, + }); } /** - * Flag a message + * Flags a message. * - * @param {string} messageID Message ID to be flagged - * @param {string} reason Reason for flagging the message - * @param {Object} options Additional options for flagging the message - * @param {string} options.user_id (For server side usage) User ID of the user who is flagging the target message - * @param {Object} options.custom Additional data to be stored with the flag - * @returns + * @param messageId - MessageRequest ID to be flagged. + * @param reason - Reason for flagging the message. + * @param options - Additional options for flagging the message (optional, defaults to `{}`). + * @param options.custom - Additional data to be stored with the flag (optional). + * @returns The flag response. */ - flagMessage(messageID: string, reason: string, options: ModerationFlagOptions = {}) { - return this.flag(MODERATION_ENTITY_TYPES.message, messageID, '', reason, options); + flagMessage(messageId: string, reason: string, options: ModerationFlagOptions = {}) { + return this.flag({ + entity_type: MODERATION_ENTITY_TYPES.message, + entity_id: messageId, + entity_creator_id: '', + reason, + ...options, + }); } /** - * Flag a user + * Unmutes a user. * - * @param {string} entityType Entity type to be flagged - * @param {string} entityId Entity ID to be flagged - * @param {string} entityCreatorID User ID of the entity creator - * @param {string} reason Reason for flagging the entity - * @param {Object} options Additional options for flagging the entity - * @param {string} options.user_id (For server side usage) User ID of the user who is flagging the target entity - * @param {Object} options.moderation_payload Content to be flagged e.g., { texts: ['text1', 'text2'], images: ['image1', 'image2']} - * @param {Object} options.custom Additional data to be stored with the flag - * @returns + * @param targetId - User ID to be unmuted. + * @returns The unmute response. */ - async flag( - entityType: string, - entityId: string, - entityCreatorID: string, - reason: string, - options: ModerationFlagOptions = {}, - ) { - return await this.client.post<{ item_id: string } & APIResponse>( - this.client.baseURL + '/api/v2/moderation/flag', - { - entity_type: entityType, - entity_id: entityId, - entity_creator_id: entityCreatorID, - reason, - ...options, - }, - ); - } - - /** - * Mute a user - * @param {string} targetID User ID to be muted - * @param {Object} options Additional options for muting the user - * @param {string} options.user_id (For server side usage) User ID of the user who is muting the target user - * @param {number} options.timeout Timeout for the mute in minutes - * @returns - */ - async muteUser(targetID: string, options: ModerationMuteOptions = {}) { - return await this.client.post( - this.client.baseURL + '/api/v2/moderation/mute', - { - target_ids: [targetID], - ...options, - }, - ); - } - - /** - * Unmute a user - * @param {string} targetID User ID to be unmuted - * @param {Object} options Additional options for unmuting the user - * @param {string} options.user_id (For server side usage) User ID of the user who is unmuting the target user - * @returns - */ - async unmuteUser( - targetID: string, - options: { - user_id?: string; - }, - ) { - return await this.client.post( + async unmuteUser(targetId: string) { + return await this.client.api.post( this.client.baseURL + '/api/v2/moderation/unmute', { - target_ids: [targetID], - ...options, - }, - ); - } - - /** - * Get moderation report for a user - * @param {string} userID User ID for which moderation report is to be fetched - * @param {Object} options Additional options for fetching the moderation report - * @param {boolean} options.create_user_if_not_exists Create user if not exists - * @param {boolean} options.include_user_blocks Include user blocks - * @param {boolean} options.include_user_mutes Include user mutes - */ - async getUserModerationReport( - userID: string, - options: GetUserModerationReportOptions = {}, - ) { - return await this.client.get( - this.client.baseURL + `/api/v2/moderation/user_report`, - { - user_id: userID, - ...options, - }, - ); - } - - /** - * Query review queue - * @param {Object} filterConditions Filter conditions for querying review queue - * @param {Object} sort Sort conditions for querying review queue - * @param {Object} options Pagination options for querying review queue - */ - async queryReviewQueue( - filterConditions: ReviewQueueFilters = {}, - sort: ReviewQueueSort = [], - options: ReviewQueuePaginationOptions = {}, - ) { - return await this.client.post( - this.client.baseURL + '/api/v2/moderation/review_queue', - { - filter: filterConditions, - sort: normalizeQuerySort(sort), - ...options, - }, - ); - } - - /** - * Upsert moderation config - * @param {Object} config Moderation config to be upserted - */ - async upsertConfig(config: ModerationConfig) { - return await this.client.post( - this.client.baseURL + '/api/v2/moderation/config', - config, - ); - } - - /** - * Get moderation config - * @param {string} key Key for which moderation config is to be fetched - */ - async getConfig(key: string, data?: { team?: string }) { - return await this.client.get( - this.client.baseURL + '/api/v2/moderation/config/' + key, - data, - ); - } - - async deleteConfig(key: string, data?: { team?: string }) { - return await this.client.delete( - this.client.baseURL + '/api/v2/moderation/config/' + key, - data, - ); - } - - /** - * Query moderation configs - * @param {Object} filterConditions Filter conditions for querying moderation configs - * @param {Object} sort Sort conditions for querying moderation configs - * @param {Object} options Additional options for querying moderation configs - */ - async queryConfigs( - filterConditions: QueryModerationConfigsFilters, - sort: QueryModerationConfigsSort, - options: Pager = {}, - ) { - return await this.client.post( - this.client.baseURL + '/api/v2/moderation/configs', - { - filter: filterConditions, - sort, - ...options, - }, - ); - } - - async submitAction( - actionType: string, - itemID: string, - options: SubmitActionOptions = {}, - ) { - return await this.client.post( - this.client.baseURL + '/api/v2/moderation/submit_action', - { - action_type: actionType, - item_id: itemID, - ...options, - }, - ); - } - - /** - * - * @param {string} entityType string Type of entity to be checked E.g., stream:user, stream:chat:v1:message, or any custom string - * @param {string} entityID string ID of the entity to be checked. This is mainly for tracking purposes - * @param {string} entityCreatorID string ID of the entity creator - * @param {object} moderationPayload object Content to be checked for moderation. E.g., { texts: ['text1', 'text2'], images: ['image1', 'image2']} - * @param {Array} moderationPayload.texts array Array of texts to be checked for moderation - * @param {Array} moderationPayload.images array Array of images to be checked for moderation - * @param {Array} moderationPayload.videos array Array of videos to be checked for moderation - * @param configKey - * @param options - * @returns - */ - async check( - entityType: string, - entityID: string, - entityCreatorID: string, - moderationPayload: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - custom?: Record; - images?: string[]; - texts?: string[]; - videos?: string[]; - }, - configKey: string, - options?: { - force_sync?: boolean; - }, - testMode?: boolean, - ) { - return await this.client.post( - this.client.baseURL + `/api/v2/moderation/check`, - { - entity_type: entityType, - entity_id: entityID, - entity_creator_id: entityCreatorID, - moderation_payload: moderationPayload, - config_key: configKey, - options, - test_mode: testMode, - }, - ); - } - - /** - * Experimental: Check user profile - * - * Warning: This is an experimental feature and the API is subject to change. - * - * This function is used to check a user profile for moderation. - * This will not create any review queue items for the user profile. - * You can just use this to check whether to allow a certain user profile to be created or not. - * - * Example: - * - * ```ts - * const res = await client.moderation.checkUserProfile(userId, { username: "fuck_boy_001", image: "https://example.com/profile.jpg" }); - * if (res.recommended_action === "remove") { - * // Block the user profile from being created - * } else { - * // Allow the user profile to be created - * } - * ``` - * - * @param userId - * @param profile.username - * @param profile.image - * @returns - */ - async checkUserProfile( - userId: string, - profile: RequireAtLeastOne<{ image?: string; username?: string }>, - ) { - if (!profile.username && !profile.image) { - throw new Error('Either username or image must be provided'); - } - - const moderationPayload: { images?: string[]; texts?: string[] } = {}; - if (profile.username) { - moderationPayload.texts = [profile.username]; - } - if (profile.image) { - moderationPayload.images = [profile.image]; - } - - return await this.check( - MODERATION_ENTITY_TYPES.userprofile, - userId, - userId, - moderationPayload, - 'user_profile:default', - { - force_sync: true, - }, - true, - ); - } - - /** - * - * @param {string} entityType string Type of entity to be checked E.g., stream:user, stream:chat:v1:message, or any custom string - * @param {string} entityID string ID of the entity to be checked. This is mainly for tracking purposes - * @param {string} entityCreatorID string ID of the entity creator - * @param {object} moderationPayload object Content to be checked for moderation. E.g., { texts: ['text1', 'text2'], images: ['image1', 'image2']} - * @param {Array} moderationPayload.texts array Array of texts to be checked for moderation - * @param {Array} moderationPayload.images array Array of images to be checked for moderation - * @param {Array} moderationPayload.videos array Array of videos to be checked for moderation - * @param {object} moderationPayload.custom object Additional custom data to attach to the moderation review queue item - * @param {Array} flags Array of CustomCheckFlag to be passed to flag the entity - * @returns - */ - async addCustomFlags( - entityType: string, - entityID: string, - entityCreatorID: string, - moderationPayload: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - custom?: Record; - images?: string[]; - texts?: string[]; - videos?: string[]; - }, - flags: CustomCheckFlag[], - ) { - return await this.client.post( - this.client.baseURL + `/api/v2/moderation/custom_check`, - { - entity_type: entityType, - entity_id: entityID, - entity_creator_id: entityCreatorID, - moderation_payload: moderationPayload, - flags, + target_ids: [targetId], }, ); } - - /** - * Add custom flags to a message - * @param {string} messageID Message ID to be flagged - * @param {Array} flags Array of CustomCheckFlag to be passed to flag the message - * @returns - */ - async addCustomMessageFlags(messageID: string, flags: CustomCheckFlag[]) { - return await this.addCustomFlags( - MODERATION_ENTITY_TYPES.message, - messageID, - '', - {}, - flags, - ); - } - - /** - * Create or update a moderation rule - * @param {ModerationRuleRequest} rule Rule configuration to be upserted - * @returns - */ - async upsertModerationRule(rule: ModerationRuleRequest) { - return await this.client.post( - this.client.baseURL + '/api/v2/moderation/moderation_rule', - rule, - ); - } - - /** - * Query moderation rules - * @param {QueryModerationRulesFilters} filterConditions Filter conditions for querying moderation rules - * @param {QueryModerationRulesSort} sort Sort conditions for querying moderation rules - * @param {Pager} options Pagination options for querying moderation rules - * @returns - */ - async queryModerationRules( - filterConditions: QueryModerationRulesFilters = {}, - sort: QueryModerationRulesSort = [], - options: Pager = {}, - ) { - return await this.client.post( - this.client.baseURL + '/api/v2/moderation/moderation_rules', - { - filter: filterConditions, - sort, - ...options, - }, - ); - } - - /** - * Get a specific moderation rule by ID - * @param {string} id ID of the moderation rule to fetch - * @returns - */ - async getModerationRule(id: string) { - return await this.client.get<{ rule: ModerationRule }>( - this.client.baseURL + '/api/v2/moderation/moderation_rule/' + id, - ); - } - - /** - * Delete a moderation rule by ID - * @param {string} id ID of the moderation rule to delete - * @returns - */ - async deleteModerationRule(id: string) { - return await this.client.delete( - this.client.baseURL + '/api/v2/moderation/moderation_rule/' + id, - ); - } } diff --git a/src/notifications/types.ts b/src/notifications/types.ts index c929062d98..f764f69ead 100644 --- a/src/notifications/types.ts +++ b/src/notifications/types.ts @@ -33,7 +33,7 @@ export type Notification = { origin: NotificationOrigin; /** Array of action buttons for the notification */ actions?: NotificationAction[]; - /** The severity level of the notification. Defaults to undefined unless explicitly provided. */ + /** The severity level of the notification (defaults to `undefined` unless explicitly provided). */ severity?: NotificationSeverity; /** * Optional code that can be used to group the notifications of the same type, e.g. attachment-upload-blocked. @@ -55,9 +55,9 @@ export type Notification = { * 'validation:attachment:size:exceeded' // File size too large * 'validation:attachment:count:exceeded' // Too many attachments * - * Message related errors - * 'api:message:send:failed' // Message send failed - * 'validation:message:content:empty' // Message content validation failed + * MessageRequest related errors + * 'api:message:send:failed' // MessageRequest send failed + * 'validation:message:content:empty' // MessageRequest content validation failed * * Channel related errors * 'api:channel:join:failed' // Channel join failed @@ -104,11 +104,12 @@ export type NotificationOptions = Partial< }; /** - * State shape for the notification store - * @deprcated use NotificationManagerState + * State shape for the notification store. + * + * @deprecated Use {@link NotificationManagerState} instead. */ export type NotificationState = { - /** Array of current notification objects */ + /** Array of current notification objects. */ notifications: Notification[]; }; diff --git a/src/offline-support/offline_support_api.ts b/src/offline-support/offline_support_api.ts index 8ee0511070..05178047ef 100644 --- a/src/offline-support/offline_support_api.ts +++ b/src/offline-support/offline_support_api.ts @@ -1,10 +1,14 @@ import type { - APIErrorResponse, + APIError, ChannelResponse, Event, + EventPayload, + EventType, LocalMessage, - Message, + MessageRequest, MessageResponse, + OwnUserResponse, + RequireLiteral, } from '../types'; import type { @@ -17,6 +21,7 @@ import { OfflineError } from './types'; import type { StreamChat } from '../client'; import type { AxiosError } from 'axios'; import { OfflineDBSyncManager } from './offline_sync_manager'; +import { chatLoggerSystem } from '../logger'; import { StateStore } from '../store'; import { channelHasReadEvents, @@ -27,6 +32,9 @@ import { } from '../utils'; import { isMessageUpdateReplayable } from './util'; +const logger = chatLoggerSystem.getLogger('offline-db'); +import type { WSEvent } from '../gen/models'; + /** * Abstract base class for an offline database implementation used with StreamChat. * @@ -46,12 +54,11 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { this.syncManager = new OfflineDBSyncManager({ client, offlineDb: this }); this.state = new StateStore({ initialized: false, - userId: this.client.userID, + userId: this.client.userId, }); } /** - * @abstract * Inserts a reaction into the DB. * Will write to: * - The reactions table with the new reaction @@ -59,24 +66,24 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * - The users table with any users associated * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBInsertReactionType} options * @returns {Promise} */ abstract insertReaction: OfflineDBApi['insertReaction']; /** - * @abstract * Upserts the list of CIDs for a filter + sort query hash. * Will write to only the table containing the cids. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertCidsForQueryType} options * @returns {Promise} */ abstract upsertCidsForQuery: OfflineDBApi['upsertCidsForQuery']; /** - * @abstract * Upserts the channels passed as an argument within the DB. Relies on * writing the properties we need from a ChannelResponse into the adequate * tables. @@ -90,72 +97,72 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * - The reads table for each user * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertChannelsType} options * @returns {Promise} */ abstract upsertChannels: OfflineDBApi['upsertChannels']; /** - * @abstract * Upserts the current active user's sync status. * Will only write to the sync status table. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertUserSyncStatusType} options * @returns {Promise} */ abstract upsertUserSyncStatus: OfflineDBApi['upsertUserSyncStatus']; /** - * @abstract * Upserts the app settings for the current Stream App into the DB. It * is only intended to be run once per lifecycle of the app. * Will only write to the respective app settings table. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertAppSettingsType} options * @returns {Promise} */ abstract upsertAppSettings: OfflineDBApi['upsertAppSettings']; /** - * @abstract * Upserts a poll fully in the DB. * Will write to the polls table. It should not update the message * associated due to how the poll state works. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertPollType} options * @returns {Promise} */ abstract upsertPoll: OfflineDBApi['upsertPoll']; /** - * @abstract * Upserts only the channel.data for the provided channels in the DB. * Will only write to the channels table. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertChannelDataType} options * @returns {Promise} */ abstract upsertChannelData: OfflineDBApi['upsertChannelData']; /** - * @abstract * Upserts the provided reads in the DB. * Will write to: * - The reads table * - The users table for each user associated with a read * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertReadsType} options * @returns {Promise} */ abstract upsertReads: OfflineDBApi['upsertReads']; /** - * @abstract * Upserts the messages in the DB. * Will write to: * - The messages table @@ -165,287 +172,288 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * - The users table * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertMessagesType} options * @returns {Promise} */ abstract upsertMessages: OfflineDBApi['upsertMessages']; /** - * @abstract * Upserts the members in the DB. * Will write to: * - The users table (for each user associated with a member) * - The members table * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertMembersType} options * @returns {Promise} */ abstract upsertMembers: OfflineDBApi['upsertMembers']; /** - * @abstract * Updates a reaction in the DB. Will update the DB the same way * a reaction.updated event would (it assumes enforce_unique is true * and removes all other reactions associated with the user. * Will write to the reactions table. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpdateReactionType} options * @returns {Promise} */ abstract updateReaction: OfflineDBApi['updateReaction']; /** - * @abstract * Updates a single message in the DB. This is used as a faster * alternative to upsertMessages with more optimized queries. * Will write to the messages table. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpdateMessageType} options * @returns {Promise} */ abstract updateMessage: OfflineDBApi['updateMessage']; /** - * @abstract * Fetches the provided draft from the DB. Should return as close to * the server side DraftResponse as possible. + * * @param {DBGetDraftType} options * @returns {Promise} */ abstract getDraft: OfflineDBApi['getDraft']; /** - * @abstract * Upserts a draft in the DB. * Will write to the draft table upserting the draft. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertDraftType} options * @returns {Promise} */ abstract upsertDraft: OfflineDBApi['upsertDraft']; /** - * @abstract * Deletes a draft from the DB. * Will write to the draft table removing the draft. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeleteDraftType} options * @returns {Promise} */ abstract deleteDraft: OfflineDBApi['deleteDraft']; /** - * @abstract * Fetches the provided channels from the DB and aggregates all data associated - * with them in a single ChannelAPIResponse. The implementation itself is responsible + * with them in a single ChannelStateResponseFields. The implementation itself is responsible * for aggregating and serialization of all of the data. Should return as close to - * the server side ChannelAPIResponse as possible. + * the server side ChannelStateResponseFields as possible. + * * @param {DBGetChannelsType} options - * @returns {Promise[] | null>} + * @returns {Promise[] | null>} */ abstract getChannels: OfflineDBApi['getChannels']; /** - * @abstract * Fetches the channels from the DB that were the last known response to a filters & sort - * hash as a query and aggregates all data associated with them in a single ChannelAPIResponse. + * hash as a query and aggregates all data associated with them in a single ChannelStateResponseFields. * The implementation itself is responsible for aggregating and serialization of all of the data. - * Should return as close to the server side ChannelAPIResponse as possible. + * Should return as close to the server side ChannelStateResponseFields as possible. + * * @param {DBGetChannelsForQueryType} options - * @returns {Promise[] | null>} + * @returns {Promise[] | null>} */ abstract getChannelsForQuery: OfflineDBApi['getChannelsForQuery']; /** - * @abstract * Will return a list of all available CIDs in the DB. The same can be achieved * by fetching all channels, however this is meant to be much faster as a query. + * * @returns {Promise} */ abstract getAllChannelCids: OfflineDBApi['getAllChannelCids']; /** - * @abstract * Fetches the timestamp of the last sync of the DB. + * * @param {DBGetLastSyncedAtType} options * @returns {Promise} */ abstract getLastSyncedAt: OfflineDBApi['getLastSyncedAt']; /** - * @abstract * Fetches all pending tasks from the DB. It will return them in an * ordered fashion by the time they were created. + * * @param {DBGetPendingTasksType} [conditions] * @returns {Promise} */ abstract getPendingTasks: OfflineDBApi['getPendingTasks']; /** - * @abstract * Fetches the app settings stored in the DB. Is mainly meant to be used * only while offline and opening the application, as we only update the * app settings whenever they are fetched again so it has the potential to * be stale. + * * @param {DBGetAppSettingsType} options - * @returns {Promise} + * @returns {Promise} */ abstract getAppSettings: OfflineDBApi['getAppSettings']; /** - * @abstract * Fetches reactions from the DB for a given filter & sort hash and * for a given message ID. + * * @param {DBGetReactionsType} options * @returns {Promise} */ abstract getReactions: OfflineDBApi['getReactions']; /** - * @abstract * Executes multiple queries in a batched fashion. It will also be done * within a transaction. + * * @param {ExecuteBatchDBQueriesType} queries * @returns {Promise} */ abstract executeSqlBatch: OfflineDBApi['executeSqlBatch']; /** - * @abstract * Adds a pending task to the pending tasks table. Can only be one of the * supported types of pending tasks, otherwise its execution will throw. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {PendingTask} task * @returns {Promise<() => Promise>} */ abstract addPendingTask: OfflineDBApi['addPendingTask']; /** - * @abstract * Updates a pending task in the DB, given its ID. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpdatePendingTaskType} options * @returns {Promise} */ abstract updatePendingTask: OfflineDBApi['updatePendingTask']; /** - * @abstract * Deletes a pending task from the DB, given its ID. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeletePendingTaskType} options * @returns {Promise} */ abstract deletePendingTask: OfflineDBApi['deletePendingTask']; /** - * @abstract * Deletes a reaction from the DB. * Will write to the reactions table removing the reaction. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeleteReactionType} options * @returns {Promise} */ abstract deleteReaction: OfflineDBApi['deleteReaction']; /** - * @abstract * Deletes a member from the DB. * Will only write to the members table. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeleteMemberType} options * @returns {Promise} */ abstract deleteMember: OfflineDBApi['deleteMember']; /** - * @abstract * Deletes a channel from the DB. * It will also delete all other entities associated with the channel in * a cascading fashion (messages, reactions, members etc.). * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeleteChannelType} options * @returns {Promise} */ abstract deleteChannel: OfflineDBApi['deleteChannel']; /** - * @abstract * Deletes multiple messages for a given channel. Works as `channel.truncated` would. * Should remove entities primarily from the messages table and then from all associated * tables in a cascading fashion (reactions, polls etc.). * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeleteMessagesForChannelType} options * @returns {Promise} */ abstract deleteMessagesForChannel: OfflineDBApi['deleteMessagesForChannel']; /** - * @abstract * Deletes all pending tasks from the DB. * Will only update the pending tasks table. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDropPendingTasksType} options * @returns {Promise} */ abstract dropPendingTasks: OfflineDBApi['dropPendingTasks']; /** - * @abstract * Deletes a message from the DB. * All other entities associated with the message will also be deleted * in a cascading fashion (reactions, polls etc.). * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeleteMessageType} options * @returns {Promise} */ abstract hardDeleteMessage: OfflineDBApi['hardDeleteMessage']; /** - * @abstract * Updates a message with a deleted_at value in the DB. * Will only update the messages table, as the message is simply marked * as deleted and not removed from the DB. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeleteMessageType} options * @returns {Promise} */ abstract softDeleteMessage: OfflineDBApi['softDeleteMessage']; /** - * @abstract * Drops all tables and reinitializes the connection to the DB. + * * @returns {Promise} */ abstract resetDB: OfflineDBApi['resetDB']; /** - * @abstract * A utility query that checks whether a specific channel exists in the DB. * Technically the same as actually fetching that channel through other queries, * but much faster. + * * @param {DBChannelExistsType} options * @returns {Promise} */ abstract channelExists: OfflineDBApi['channelExists']; /** - * @abstract * Initializes the DB (typically creating a simple file handle as a connection pointer for * SQLite and likely similar for other DBs). + * * @returns {Promise} */ abstract initializeDB: OfflineDBApi['initializeDB']; @@ -453,6 +461,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { /** * Initializes the DB as well as its syncManager for a given userId. * It will update the DBs reactive state with initialization values. + * * @param userId - the user ID for which we want to initialize */ public init = async (userId: string) => { @@ -470,13 +479,16 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { } } catch (error) { this.state.partialNext({ initialized: false, userId: undefined }); - console.log('Error Initializing DB:', error); + logger + .withExtraTags('init') + .error('Failed to initialize the offline database.', { error }); } }; /** * Checks whether the DB should be initialized or if it has been initialized already. - * @param {string} userId - the user ID for which we want to check initialization + * + * @param userId - the user ID for which we want to check initialization */ public shouldInitialize(userId: string): boolean { const { userId: userIdFromState, initialized } = this.state.getLatestValue(); @@ -488,6 +500,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * passed uses a reference to the DB itself and will handle errors gracefully * and silently. Only really meant to be used for write queries that need to * be run in synchronous functions. + * * @param queryCallback - a callback wrapping all query logic that is to be executed * @param method - a utility parameter used for proper logging (will make sure the method * is logged on failure) @@ -516,21 +529,29 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * If both fail, it will not execute the query as it would result in a foreign key constraint * error. * - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. - * @param forceUpdate - whether to upsert the channel data anyway - * @param createQueries - a callback function to creation of the queries that we want to execute + * @param event - The WS event we are trying to process. + * @param event.execute - Whether to immediately execute the operation (optional, defaults to `true`). + * @param event.forceUpdate - Whether to upsert the channel data anyway (optional, defaults to `false`). + * @param createQueries - A callback that creates the queries to execute. + * @returns The list of prepared queries (executed when `execute` is `true`). */ public queriesWithChannelGuard = async ( { event, execute = true, forceUpdate = false, - }: { event: Event; execute?: boolean; forceUpdate?: boolean }, + }: { + event: Extract< + Event, + { channel?: any; cid?: any; channel_type?: any; channel_id?: any } + >; + execute?: boolean; + forceUpdate?: boolean; + }, createQueries: (executeOverride?: boolean) => Promise, ) => { - const channelFromEvent = event.channel; - const cid = event.cid || channelFromEvent?.cid; + const channelFromEvent = (event as Extract).channel; + const cid = (event as Extract).cid || channelFromEvent?.cid; const type = event.type; if (!cid) { @@ -543,11 +564,13 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { // This can happen for example when a message.new event is received for a channel that is not in the db due to a channel being hidden. const shouldUpsertChannelData = forceUpdate || !(await this.channelExists({ cid })); if (shouldUpsertChannelData) { + const event_ = event as Extract; + let channelData = channelFromEvent; - if (!channelData && event.channel_type && event.channel_id) { + if (!channelData && event_.channel_type && event_.channel_id) { const channelFromState = this.client.channel( - event.channel_type, - event.channel_id, + event_.channel_type, + event_.channel_id, ); if (channelFromState.initialized && !channelFromState.disconnected) { channelData = channelFromState.data as unknown as ChannelResponse; @@ -566,17 +589,21 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { } return newQueries; } else { - console.warn( - `Couldn't create channel queries on ${type} event for an initialized channel that is not in DB, skipping event`, - { event }, - ); + logger + .withExtraTags('queriesWithChannelGuard') + .warn( + `Could not create channel queries on a "${type}" event for an initialized channel that is not in the database. Skipping the event.`, + { event }, + ); return []; } } else { - console.warn( - `Received ${type} event for a non initialized channel that is not in DB, skipping event`, - { event }, - ); + logger + .withExtraTags('queriesWithChannelGuard') + .warn( + `Received a "${type}" event for a non-initialized channel that is not in the database. Skipping the event.`, + { event }, + ); return []; } } @@ -588,14 +615,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * and it is going to make sure that both messages and reads are upserted. It will not * try to fetch the reads from the DB first and it will rely on channel.state to handle * the number of unreads. - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleNewMessage = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<'message.new'>; execute?: boolean; }) => { const client = this.client; @@ -629,10 +657,13 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { execute: false, reads: [ { - last_read: (ownReads?.last_read ?? new Date(0)).toISOString() as string, + last_read: ownReads?.last_read ?? new Date(0), last_read_message_id: ownReads?.last_read_message_id, unread_messages: unreadCount, - user: client.user, + user: client.user as RequireLiteral< + OwnUserResponse, + 'blocked_user_ids' + >, // TODO: drop RequireLiteral once the oapi spec is adjusted }, ], }); @@ -653,14 +684,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { /** * A handler for message deletion. It provides a channel guard and determines whether * it should hard delete or soft delete the message. - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleDeleteMessage = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<'message.deleted'>; execute?: boolean; }) => { const { message, deleted_for_me, hard_delete = false } = event; @@ -685,8 +717,9 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * A utility method used for removing a message that has already failed from the * state as well as the DB. We want to drop all pending tasks and finally hard * delete the message from the DB. - * @param messageId - the message id of the message we want to remove - * @param execute - whether to immediately execute the operation. + * + * @param payload.messageId - The ID of the message we want to remove. + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleRemoveMessage = async ({ messageId, @@ -718,22 +751,29 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * The unreadMessages argument is useful for cases where we know the exact number of unreads * (for example reading an entire channel), but `unread_messages` might not necessarily exist * in the event (or it exists with a stale value if we know what we want to ultimately update to). - * @param event - the WS event we are trying to process - * @param unreadMessages - an override of unread_messages that will be preferred when upserting reads - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - The WS event we are trying to process. + * @param payload.unreadMessages - An override of `unread_messages` that will be preferred when upserting reads. + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleRead = async ({ event, unreadMessages, execute = true, }: { - event: Event; + event: EventPayload< + | 'message.read' + | 'message.read_locally' + | 'notification.mark_read' + | 'notification.mark_unread' + >; unreadMessages?: number; execute?: boolean; }) => { const { - received_at: last_read, + received_at: last_read = new Date(), last_read_message_id, + // @ts-expect-error property missing unread_messages = 0, user, cid, @@ -748,7 +788,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { execute: executeOverride, reads: [ { - last_read: last_read as string, + last_read, last_read_message_id, unread_messages: overriddenUnreadMessages, user, @@ -765,14 +805,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * A utility method used to handle member events. It guards the processing * of each event with a channel guard and also forces an update of member_count * for the respective channel if applicable. - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleMemberEvent = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<`member.${string}`>; execute?: boolean; }) => { const { member, cid, type } = event; @@ -803,14 +844,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { /** * A utility method used to handle message.updated events. It guards each * event handler within a channel guard. - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleMessageUpdatedEvent = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<'message.updated' | 'message.undeleted'>; execute?: boolean; }) => { const { message } = event; @@ -832,14 +874,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * simple upsertion is not enough. * It will update the hidden property of a channel to true if handling the `channel.hidden` * event and to false if handling `channel.visible`. - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload. - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleChannelVisibilityEvent = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<'channel.visible' | 'channel.hidden'>; execute?: boolean; }) => { const { type, channel } = event; @@ -859,14 +902,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * A utility handler used to handle channel.truncated events. It handles both * removing all messages and relying on truncated_at as well. It will also upsert * reads adequately (and calculate the correct unread messages when truncating). - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleChannelTruncatedEvent = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<'channel.truncated'>; execute?: boolean; }) => { const { channel } = event; @@ -901,10 +945,10 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { execute: false, reads: [ { - last_read: (ownReads?.last_read ?? new Date(0)).toString() as string, + last_read: ownReads?.last_read ?? new Date(0), last_read_message_id: ownReads?.last_read_message_id, unread_messages: unreadCount, - user: ownUser, + user: ownUser as RequireLiteral, // TODO: drop RequireLiteral once the oapi spec is adjusted }, ], }); @@ -927,14 +971,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * - reaction.new -> insertReaction * - reaction.updated -> updateReaction * - reaction.deleted -> deleteReaction - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleReactionEvent = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<`reaction.${string}`>; execute?: boolean; }) => { const { type, message, reaction } = event; @@ -943,7 +988,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { return []; } - const getReactionMethod = (type: Event['type']) => { + const getReactionMethod = (type: EventType) => { switch (type) { case 'reaction.new': return this.insertReaction; @@ -969,14 +1014,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * A utility handler for all draft events: * - draft.updated -> updateDraft * - draft.deleted -> deleteDraft - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ handleDraftEvent = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<`draft.${string}`>; execute?: boolean; }) => { const { cid, draft, type } = event; @@ -1007,8 +1053,9 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * A generic event handler that decides which DB API to invoke based on * event.type for all events we are currently handling. It is used to both * react on WS events as well as process the sync API events. - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleEvent = async ({ event, @@ -1017,10 +1064,13 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { event: Event; execute?: boolean; }) => { - const { type, channel } = event; + const { type } = event; if (type.startsWith('reaction')) { - return await this.handleReactionEvent({ event, execute }); + return await this.handleReactionEvent({ + event: event as EventPayload<`reaction.${string}`>, + execute, + }); } if (type === 'message.new') { @@ -1056,7 +1106,10 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { } if (type.startsWith('member.')) { - return await this.handleMemberEvent({ event, execute }); + return await this.handleMemberEvent({ + event: event as EventPayload<`member.${string}`>, + execute, + }); } if (type === 'channel.hidden' || type === 'channel.visible') { @@ -1078,18 +1131,18 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { (type === 'channel.updated' || type === 'notification.message_new' || type === 'notification.added_to_channel') && - channel + event.channel ) { - return await this.upsertChannelData({ channel, execute }); + return await this.upsertChannelData({ channel: event.channel, execute }); } if ( (type === 'channel.deleted' || type === 'notification.channel_deleted' || type === 'notification.removed_from_channel') && - channel + event.channel ) { - return await this.deleteChannel({ cid: channel.cid, execute }); + return await this.deleteChannel({ cid: event.channel.cid, execute }); } if (type === 'channel.truncated') { @@ -1108,6 +1161,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * 3. If it is, it will insert the task in the pending tasks table * * It will return the response from the execution if it succeeded. + * * @param task - the pending task we want to execute */ public queueTask = async ({ task }: { task: PendingTask }): Promise => { @@ -1123,7 +1177,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { try { return await attemptTaskExecution(); } catch (e) { - if (!this.shouldSkipQueueingTask(e as AxiosError)) { + if (!this.shouldSkipQueueingTask(e as AxiosError)) { await this.handleAddPendingTask({ task }); } throw e; @@ -1131,13 +1185,14 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { }; /** - * A utility method that determines if a failed task should be added to the - * queue based on its error. - * Error code 4 - bad request data - * Error code 17 - missing own_capabilities to execute the task - * @param error + * A utility method that determines if a failed task should be added to the queue based on its + * error. Error code 4 — bad request data. Error code 17 — missing `own_capabilities` to execute + * the task. + * + * @param error - The failed task's Axios error. + * @returns `true` when the task should not be re-queued. */ - private shouldSkipQueueingTask = (error: AxiosError) => + private shouldSkipQueueingTask = (error: AxiosError) => error?.response?.data?.code === 4 || error?.response?.data?.code === 17; private mergeFailedMessageUpdateIntoPendingSendMessage = ({ @@ -1145,13 +1200,13 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { pendingMessage, }: { editedMessage: LocalMessage | Partial; - pendingMessage: Message; + pendingMessage: MessageRequest; }) => { const normalizedEditedMessageSource = { ...editedMessage, } as LocalMessage & { message_text_updated_at?: string }; - if (editedMessage.status === 'failed') { + if ((editedMessage as LocalMessage).status === 'failed') { delete normalizedEditedMessageSource.message_text_updated_at; } @@ -1166,7 +1221,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { ...(typeof pendingMessageStatus !== 'undefined' ? { status: pendingMessageStatus } : {}), - } as Message; + } as MessageRequest; }; private isPendingSendMessageTask = ( @@ -1177,12 +1232,12 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { private handleOfflineFailedUpdateMessagePendingTask = async ( task: Extract, ) => { - const [message] = task.payload; - if (!message.id) { + const [{ id, message }] = task.payload; + if (!id) { return; } - const pendingTasks = await this.getPendingTasks({ messageId: message.id }); + const pendingTasks = await this.getPendingTasks({ messageId: id }); const pendingSendMessageTask = pendingTasks.find(this.isPendingSendMessageTask); if (!pendingSendMessageTask) { @@ -1191,14 +1246,20 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { const updatedPendingSendMessage = this.mergeFailedMessageUpdateIntoPendingSendMessage( { - editedMessage: message, - pendingMessage: pendingSendMessageTask.payload[0], + // TODO: this is not good, we have too many message types, should probably only have two (request, response) + editedMessage: message as unknown as LocalMessage, + pendingMessage: pendingSendMessageTask.payload[0].message as MessageRequest, }, ); const updatedPendingTask: Extract = { ...pendingSendMessageTask, - payload: [updatedPendingSendMessage, pendingSendMessageTask.payload[1]], + payload: [ + { + ...pendingSendMessageTask.payload[0], + message: updatedPendingSendMessage, + }, + ], }; if (pendingSendMessageTask.id) { @@ -1220,14 +1281,17 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * or rewrites an existing pending `send-message` task for offline edits of failed messages. */ public handleAddPendingTask = async ({ task }: { task: PendingTask }) => { - if (task.type === 'update-message' && !isMessageUpdateReplayable(task.payload[0])) { + if ( + task.type === 'update-message' && + !isMessageUpdateReplayable(task.payload[0].message ?? {}) + ) { return; } if ( task.type === 'update-message' && !this.client.wsConnection?.isHealthy && - task.payload[0].status === 'failed' + (task.payload[0].message as { status?: string } | undefined)?.status === 'failed' ) { await this.handleOfflineFailedUpdateMessagePendingTask(task); return; @@ -1247,6 +1311,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * - Creating a draft * - Deleting a draft * It will throw if we try to execute a pending task that is not supported. + * * @param task - The task we want to execute * @param isPendingTask - a control value telling us if it's an actual pending task being executed * or delayed execution @@ -1326,7 +1391,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { true, ); } catch (e) { - const error = e as AxiosError; + const error = e as AxiosError; if (!this.shouldSkipQueueingTask(error)) { // executing the pending task has failed, so keep it in the queue continue; diff --git a/src/offline-support/offline_sync_manager.ts b/src/offline-support/offline_sync_manager.ts index 28e706ef91..6b1de9a2c6 100644 --- a/src/offline-support/offline_sync_manager.ts +++ b/src/offline-support/offline_sync_manager.ts @@ -3,7 +3,10 @@ import type { StreamChat } from '../client'; import type { AbstractOfflineDB } from './offline_support_api'; import type { AxiosError } from 'axios'; import { isAxiosError } from 'axios'; -import type { APIErrorResponse } from '../types'; +import { chatLoggerSystem } from '../logger'; +import type { APIError } from '../types'; + +const logger = chatLoggerSystem.getLogger('offline-db'); /** * Manages synchronization between the local offline database and the Stream backend. @@ -40,8 +43,8 @@ export class OfflineDBSyncManager { */ public init = async () => { try { - // If the websocket connection is already active, then call - // the sync api straight away and also execute pending api calls. + // If the WebSocket connection is already active, then call + // the sync API straight away and also execute pending API calls. // Otherwise wait for the `connection.changed` event. if (this.client.user?.id && this.client.wsConnection?.isHealthy) { await this.syncAndExecutePendingTasks(); @@ -69,7 +72,9 @@ export class OfflineDBSyncManager { }, ); } catch (error) { - console.log('Error in DBSyncManager.init: ', error); + logger + .withExtraTags('init') + .error('Failed to initialize the offline DB sync manager.', { error }); } }; @@ -159,7 +164,10 @@ export class OfflineDBSyncManager { // In that case reset the entire DB and start fresh. await this.offlineDb.resetDB(); } else { - const result = await this.client.sync(cids, lastSyncedAtDate.toISOString()); + const result = await this.client.sync({ + channel_cids: cids, + last_sync_at: lastSyncedAtDate, + }); const queryPromises = result.events.map((event) => this.offlineDb.handleEvent({ event, execute: false }), ); @@ -176,14 +184,16 @@ export class OfflineDBSyncManager { lastSyncedAt: new Date().toString(), }); } catch (e) { - console.log('An error has occurred while syncing the DB.', e); + logger + .withExtraTags('syncAndExecutePendingTasks') + .error('An error occurred while syncing the database.', { error: e }); if (isAxiosError(e) && e.code === 'ECONNABORTED') { // If the sync was aborted due to timeout, we can simply return return; } - const error = e as AxiosError; + const error = e as AxiosError; if (error.response?.data?.code === 23) { return; diff --git a/src/offline-support/types.ts b/src/offline-support/types.ts index 840297b9d5..5504c88903 100644 --- a/src/offline-support/types.ts +++ b/src/offline-support/types.ts @@ -1,19 +1,20 @@ import type { - AppSettingsAPIResponse, - ChannelAPIResponse, ChannelFilters, ChannelMemberResponse, ChannelOptions, ChannelResponse, ChannelSort, + ChannelStateResponseFields, DraftResponse, + GetApplicationResponse, LocalMessage, MessageResponse, - PollResponse, + PollResponse_old, + QueryChannelsRequest, ReactionFilters, ReactionResponse, ReactionSort, - ReadResponse, + ReadStateResponse, } from '../types'; import type { Channel } from '../channel'; import type { StreamChat } from '../client'; @@ -26,7 +27,7 @@ export type PrepareBatchDBQueries = * Options to insert a reaction into a message. */ export type DBInsertReactionType = { - /** Message to which the reaction is applied. */ + /** MessageRequest to which the reaction is applied. */ message: MessageResponse | LocalMessage; /** The reaction to insert. */ reaction: ReactionResponse; @@ -55,7 +56,7 @@ export type DBUpsertCidsForQueryType = { */ export type DBUpsertChannelsType = { /** Array of channel API responses. */ - channels: ChannelAPIResponse[]; + channels: ChannelStateResponseFields[]; /** Whether to immediately execute the operation. */ execute?: boolean; /** If true, marks that the latest messages are already set. */ @@ -67,7 +68,7 @@ export type DBUpsertChannelsType = { */ export type DBUpsertAppSettingsType = { /** App settings data. */ - appSettings: AppSettingsAPIResponse; + appSettings: GetApplicationResponse; /** ID of the user the settings belong to. */ userId: string; /** Whether to immediately execute the operation. */ @@ -91,7 +92,7 @@ export type DBUpsertUserSyncStatusType = { */ export type DBUpsertPollType = { /** Poll data to be stored. */ - poll: PollResponse; + poll: PollResponse_old; /** Whether to immediately execute the operation. */ execute?: boolean; }; @@ -113,7 +114,7 @@ export type DBUpsertReadsType = { /** Channel ID. */ cid: string; /** Array of read statuses. */ - reads: ReadResponse[]; + reads: ReadStateResponse[]; /** Whether to immediately execute the operation. */ execute?: boolean; }; @@ -144,7 +145,7 @@ export type DBUpsertMembersType = { * Options to update a reaction. */ export type DBUpdateReactionType = { - /** Message associated with the reaction. */ + /** MessageRequest associated with the reaction. */ message: MessageResponse | LocalMessage; /** The updated reaction. */ reaction: ReactionResponse; @@ -156,7 +157,7 @@ export type DBUpdateReactionType = { * Options to update a message. */ export type DBUpdateMessageType = { - /** Message to update. */ + /** MessageRequest to update. */ message: MessageResponse | LocalMessage; /** Whether to immediately execute the operation. */ execute?: boolean; @@ -179,15 +180,11 @@ export type DBGetChannelsForQueryType = { /** ID of the user. */ userId: string; /** Optional filters for channels. */ - filters?: ChannelFilters; - /** Optional full query options for channels. */ - options?: ChannelOptions; - /** Optional sorting for the channels. */ - sort?: ChannelSort; + options?: QueryChannelsRequest; }; /** - * Get the last sync timestamp for a user. + * Payload for retrieving the last sync timestamp for a user. */ export type DBGetLastSyncedAtType = { /** ID of the user. */ @@ -203,7 +200,7 @@ export type DBGetPendingTasksType = { }; /** - * Get application settings for a user. + * Payload for retrieving application settings for a user. */ export type DBGetAppSettingsType = { /** ID of the user. */ @@ -217,7 +214,7 @@ export type DBGetReactionsType = { /** ID of the message. */ messageId: string; /** Optional filter to apply to reactions. */ - filters?: Pick; + filters?: ReactionFilters; /** Optional sorting for reactions. */ sort?: ReactionSort; /** Optional maximum number of reactions to return. */ @@ -225,7 +222,7 @@ export type DBGetReactionsType = { }; /** - * Delete a pending task by ID. + * Payload for deleting a pending task by ID. */ export type DBDeletePendingTaskType = { /** ID of the pending task. */ @@ -233,7 +230,7 @@ export type DBDeletePendingTaskType = { }; /** - * Update a pending task by ID. + * Payload for updating a pending task by ID. */ export type DBUpdatePendingTaskType = { /** ID of the pending task. */ @@ -305,13 +302,13 @@ export type DBDeleteMessagesForChannelType = { /** Channel ID. */ cid: string; /** Timestamp before which messages are deleted. */ - truncated_at?: string; + truncated_at?: Date; /** Whether to immediately execute the operation. */ execute?: boolean; }; /** - * Check if a channel exists by ID. + * Payload for checking whether a channel exists by ID. */ export type DBChannelExistsType = { /** Channel ID. */ @@ -373,15 +370,15 @@ export interface OfflineDBApi { getDraft: (options: DBGetDraftType) => Promise; getChannels: ( options: DBGetChannelsType, - ) => Promise[] | null>; + ) => Promise[] | null>; getChannelsForQuery: ( options: DBGetChannelsForQueryType, - ) => Promise[] | null>; + ) => Promise[] | null>; getAllChannelCids: () => Promise; getLastSyncedAt: (options: DBGetLastSyncedAtType) => Promise; getAppSettings: ( options: DBGetAppSettingsType, - ) => Promise; + ) => Promise; getReactions: (options: DBGetReactionsType) => Promise; executeSqlBatch: (queries: ExecuteBatchDBQueriesType) => Promise; addPendingTask: (task: PendingTask) => Promise<() => Promise>; diff --git a/src/offline-support/util.ts b/src/offline-support/util.ts index d5b069d033..b173fe9d49 100644 --- a/src/offline-support/util.ts +++ b/src/offline-support/util.ts @@ -1,4 +1,4 @@ -import type { Attachment, LocalMessage, MessageResponse } from '../types'; +import type { Attachment } from '../types'; export const isLocalUrl = (value: string | undefined) => !!value && !value.startsWith('http'); @@ -11,9 +11,10 @@ export const isAttachmentReplayable = (attachment: Attachment) => { return !isLocalUrl(attachment.asset_url) && !isLocalUrl(attachment.image_url); }; -export const isMessageUpdateReplayable = ( - message: LocalMessage | Partial, -) => !message.attachments?.some((attachment) => !isAttachmentReplayable(attachment)); +export const isMessageUpdateReplayable = (minimalMessage: { + attachments?: Attachment[]; +}) => + !minimalMessage.attachments?.some((attachment) => !isAttachmentReplayable(attachment)); export const getPendingTaskChannelData = (cid?: string) => { if (!cid) { diff --git a/src/pagination/filterCompiler.ts b/src/pagination/filterCompiler.ts index fab5ad5f4f..fd1c1f3bba 100644 --- a/src/pagination/filterCompiler.ts +++ b/src/pagination/filterCompiler.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { arraysEqualAsSets, asArray, @@ -120,9 +119,10 @@ export function itemMatchesFilter( * $gt/$gte/$lt/$lte remain scalar-only (return false if either side is iterable), as you wanted. * * $in/$nin left may be scalar or iterable; the right is a list. - * @param a - * @param b - * @param ok + * + * @param a - Left-hand operand. + * @param b - Right-hand operand. + * @param ok - Predicate applied to the result of comparing `a` and `b`. */ function orderedCompareOp(a: any, b: any, ok: (c: number) => boolean): boolean { if (isIterableButNotString(a) || isIterableButNotString(b)) return false; diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 1ed3c51669..69e9dff0a6 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -320,7 +320,7 @@ export type PaginatorOptions = { * Function containing custom logic that decides, whether the next pagination query to be executed should be considered the first page query. * It makes sense to consider the next query as the first page query if filters, sort, options etc. (query params) excluding the page size have changed. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any + hasPaginationQueryShapeChanged?: PaginationQueryShapeChangeIdentifier; /** * Optional hook to fully control cursor + hasMore logic in 'derived' mode. @@ -371,7 +371,6 @@ const baseHasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< unknown > = (prevQueryShape, nextQueryShape) => !isEqual(prevQueryShape, nextQueryShape); -// eslint-disable-next-line @typescript-eslint/no-explicit-any export const DEFAULT_PAGINATION_OPTIONS: BasePaginatorConfig = { debounceMs: 300, lockItemOrder: false, @@ -768,10 +767,9 @@ export abstract class BasePaginator { /** * Subclasses must return the query shape. */ - protected getNextQueryShape({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - direction, - }: Pick, 'direction'> = {}): Q { + protected getNextQueryShape( + _params: Pick, 'direction'> = {}, + ): Q { throw new Error('Paginator.getNextQueryShape() is not implemented'); } @@ -820,8 +818,9 @@ export abstract class BasePaginator { /** * Applied by the effectiveComparator to take into consideration item boosts when sorting items. - * @param a - * @param b + * + * @param a - The first item to compare. + * @param b - The second item to compare. */ protected boostComparator = (a: T, b: T): number => { const now = Date.now(); @@ -848,8 +847,9 @@ export abstract class BasePaginator { /** * Increases the item's importance when sorting. * Boost affects position inside an item interval (if used), but should not redefine interval boundaries. - * @param itemId - * @param opts + * + * @param itemId - Id of the item to boost. + * @param opts - Boost options: `ttlMs` / `until` control expiry and `seq` orders concurrent boosts. */ boost(itemId: string, opts?: { ttlMs?: number; until?: number; seq?: number }) { const now = Date.now(); @@ -884,8 +884,7 @@ export abstract class BasePaginator { // Interval manipulation // --------------------------------------------------------------------------- - // eslint-disable-next-line @typescript-eslint/no-unused-vars - generateIntervalId(page: (T | string)[]): string { + generateIntervalId(_page: (T | string)[]): string { return `interval-${generateUUIDv4()}`; } @@ -1315,7 +1314,8 @@ export abstract class BasePaginator { /** * Locates the current position of the item and the index at which the item should be inserted * according to effectiveComparator. - * @param item + * + * @param item - The item to locate within the current state. */ protected locateItemInState(item: T): ItemLocation | null { const items = [...(this.items ?? [])]; @@ -2024,15 +2024,14 @@ export abstract class BasePaginator { return state; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isJumpQueryShape(queryShape: Q): boolean { + isJumpQueryShape(_queryShape: Q): boolean { return false; } protected getStateAfterQuery( stateUpdate: Partial>, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isFirstPage: boolean, + + _isFirstPage: boolean, ): PaginatorState { const current = this.state.getLatestValue(); return { @@ -2045,12 +2044,10 @@ export abstract class BasePaginator { } preloadFirstPageFromOfflineDb = ( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - params: PaginationQueryParams, + _params: PaginationQueryParams, ): Promise | T[] | undefined => undefined; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - populateOfflineDbAfterQuery = (params: { + populateOfflineDbAfterQuery = (_params: { items: T[] | undefined; queryShape: Q | undefined; }): Promise | T[] | undefined => undefined; @@ -2086,13 +2083,15 @@ export abstract class BasePaginator { /** * Falsy return value means query was not successful. - * @param direction - * @param keepPreviousItems - * @param forcedQueryShape - * @param reset - * @param retryCount - * @param silent - * @param updateState + * + * @param params - Query parameters. + * @param params.direction - Direction to paginate in (headward or tailward). + * @param params.keepPreviousItems - Keep already-loaded items instead of clearing them on a first-page query. + * @param params.queryShape - Explicit query shape overriding the one derived from current state. + * @param params.reset - Whether to reset the loaded state before querying. + * @param params.retryCount - Number of remaining retry attempts on failure. + * @param params.silent - Suppress loading/state updates for this query. + * @param params.updateState - Whether to write the query results back to state. */ async executeQuery({ direction, diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index 97d1796b0b..6edbcf2279 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -7,6 +7,7 @@ import type { SetPaginatorItemsParams, } from './BasePaginator'; import { BasePaginator } from './BasePaginator'; +import { chatLoggerSystem } from '../../logger'; import type { FilterBuilderOptions } from '../FilterBuilder'; import { FilterBuilder } from '../FilterBuilder'; import { makeComparator } from '../sortCompiler'; @@ -24,7 +25,10 @@ import type { FieldToDataResolver, PathResolver } from '../types.normalization'; import { resolveDotPathValue } from '../utility.normalization'; import { isEqual } from '../../utils/mergeWith/mergeWithCore'; -const DEFAULT_BACKEND_SORT: ChannelSort = { last_message_at: -1, updated_at: -1 }; // {last_updated: -1} +const DEFAULT_BACKEND_SORT: ChannelSort = [ + { direction: -1, field: 'last_message_at' }, + { direction: -1, field: 'updated_at' }, +]; export type ChannelQueryShape = { filters: ChannelFilters; @@ -47,17 +51,16 @@ export type ChannelPaginatorOptions = { id?: string; paginatorOptions?: PaginatorOptions; requestOptions?: ChannelPaginatorRequestOptions; - sort?: ChannelSort | ChannelSort[]; + sort?: ChannelSort; }; const getQueryShapeRelevantChannelOptions = (options: ChannelOptions) => { const { - /* eslint-disable @typescript-eslint/no-unused-vars */ limit: _, member_limit: __, message_limit: ___, offset: ____, - /* eslint-enable @typescript-eslint/no-unused-vars */ + ...relevantShape } = options; return relevantShape; @@ -155,7 +158,7 @@ const pinnedFilterResolver: FieldToDataResolver = { const mutedFilterResolver: FieldToDataResolver = { matchesField: (field) => field === 'muted', - // Mute state lives on the client (client.mutedChannels), not on channel.data — resolve it via + // UserMuteResponse state lives on the client (client.mutedChannels), not on channel.data — resolve it via // the client so `{ muted: true/false }` matches client-side, rather than letting the generic // data resolver read a non-existent `channel.data.muted` (which would resolve to undefined and // never equal a boolean filter value). @@ -195,7 +198,7 @@ export class ChannelPaginator extends BasePaginator private readonly _id: string; private client: StreamChat; protected _staticFilters: ChannelFilters | undefined; - protected _sort: ChannelSort | ChannelSort[] | undefined; + protected _sort: ChannelSort | undefined; protected _options: ChannelPaginatorRequestOptions | undefined; protected _channelStateOptions: ChannelStateOptions | undefined; protected _nextQueryShape: ChannelQueryShape | undefined; @@ -275,7 +278,7 @@ export class ChannelPaginator extends BasePaginator this._staticFilters = filters; } - set sort(sort: ChannelSort | ChannelSort[] | undefined) { + set sort(sort: ChannelSort | undefined) { this._sort = sort; this.sortComparator = makeComparator({ sort: this.sort ?? DEFAULT_BACKEND_SORT, @@ -335,8 +338,7 @@ export class ChannelPaginator extends BasePaginator try { const channelsFromDB = await this.client.offlineDb.getChannelsForQuery({ userId: this.client.user.id, - filters: queryShape.filters, - sort: queryShape.sort, + options: { filter_conditions: queryShape.filters, sort: queryShape.sort }, }); if (channelsFromDB) { @@ -358,7 +360,7 @@ export class ChannelPaginator extends BasePaginator return; } } catch (error) { - this.client.logger('error', (error as Error).message); + chatLoggerSystem.getLogger('channel').error((error as Error).message); if (this.config.throwErrors) throw error; } return; @@ -394,7 +396,10 @@ export class ChannelPaginator extends BasePaginator if (this.config.doRequest) { items = (await this.config.doRequest(this._nextQueryShape)).items; } else { - items = await this.client.queryChannels(filters, sort, options, stateOptions); + items = await this.client.queryChannelsAndHydrate( + { filter_conditions: filters, sort, ...options }, + stateOptions, + ); } return { items }; }; diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 56e2199466..80da4b5dbb 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -20,6 +20,7 @@ import type { AscDesc, LocalMessage, MessagePaginationOptions, + MessagePaginationParams, MessageResponse, PinnedMessagePaginationOptions, ReactionResponse, @@ -27,7 +28,12 @@ import type { } from '../../types'; import type { Channel } from '../../channel'; import { StateStore } from '../../store'; -import { formatMessage, generateUUIDv4, toDeletedMessage } from '../../utils'; +import { + formatMessage, + generateUUIDv4, + normalizeQuerySort, + toDeletedMessage, +} from '../../utils'; import { makeComparator } from '../sortCompiler'; import type { FieldToDataResolver } from '../types.normalization'; import { resolveDotPathValue } from '../utility.normalization'; @@ -314,13 +320,13 @@ export class MessageIntervalPaginator extends BasePaginator< : undefined; } else { const { messages } = this.parentMessageId - ? await this.channel.getReplies( - this.parentMessageId, - options, - Array.isArray(this.requestSort) ? this.requestSort : [this.requestSort], - ) + ? await this.channel.getReplies({ + parent_id: this.parentMessageId, + ...options, + sort: normalizeQuerySort(this.requestSort), + }) : await this.channel.query({ - messages: options, + messages: options as MessagePaginationParams, // todo: why do we query for watchers? // watchers: { limit: this.pageSize }, }); @@ -817,7 +823,7 @@ export class MessageIntervalPaginator extends BasePaginator< this.ingestItem({ ...message, quoted_message: toDeletedMessage({ - message: message.quoted_message, + message: formatMessage(message.quoted_message), hardDelete, deletedAt, }) as LocalMessage, @@ -883,15 +889,15 @@ export class MessageIntervalPaginator extends BasePaginator< * falling back to the event's own_reactions when the message is not loaded — matching the legacy * behavior where `_updateMessage` only mutated a message that existed locally. * - * @param params - * @param {MessageResponse | LocalMessage} params.message The reaction event's message, carrying the + * @param params - The reaction event payload. + * @param params.message - The reaction event's message, carrying the * server-computed `reaction_groups` / `latest_reactions`. Ingested as-is except for `own_reactions`. - * @param {ReactionResponse} params.reaction The reaction from the event. Only added to/removed from + * @param params.reaction - The reaction from the event. Only added to/removed from * `own_reactions` when its `user_id` is the current user; otherwise the current user's * `own_reactions` are left untouched. - * @param {boolean} [params.removed=false] `true` for `reaction.deleted` (remove the reaction from + * @param [params.removed=false] - `true` for `reaction.deleted` (remove the reaction from * `own_reactions`); `false` for `reaction.new` / `reaction.updated` (add it). - * @param {boolean} [params.enforceUnique=false] When adding, first clear the current user's existing + * @param [params.enforceUnique=false] - When adding, first clear the current user's existing * `own_reactions` so only the incoming one remains (used by `reaction.updated`, where a user's * reaction replaces their previous one). */ diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index d86bb05174..5591a71c23 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -43,7 +43,7 @@ export type MessagePaginatorAggregateState = { * * Lives here, NOT derived from pagination `state`, so it stays reactive when a WS message lands in * the head interval while an older window is active — the pagination store only emits when the - * *active* interval is impacted (see `BasePaginator.ingestItem`), so a `state`-derived latest would + * active* interval is impacted (see `BasePaginator.ingestItem`), so a `state`-derived latest would * go stale in that case. */ lastMessage: LocalMessage | null; diff --git a/src/pagination/paginators/PinnedMessagePaginator.ts b/src/pagination/paginators/PinnedMessagePaginator.ts index 484521b735..6335e1bca8 100644 --- a/src/pagination/paginators/PinnedMessagePaginator.ts +++ b/src/pagination/paginators/PinnedMessagePaginator.ts @@ -86,7 +86,7 @@ export class PinnedMessagePaginator extends MessageIntervalPaginator { ): Promise<{ cursor?: PaginatorCursor; items: LocalMessage[] }> => { const { messages } = await this.channel.getPinnedMessages( options as PinnedMessagePaginationOptions, - [{ pinned_at: 1 }], + [{ direction: 1, field: 'pinned_at' }], ); const items = messages.map(formatMessage); return { cursor: this.getCursorFromQueryResults({ items }), items }; diff --git a/src/pagination/paginators/ReminderPaginator.ts b/src/pagination/paginators/ReminderPaginator.ts index 7a5480ee8c..1f108e00b2 100644 --- a/src/pagination/paginators/ReminderPaginator.ts +++ b/src/pagination/paginators/ReminderPaginator.ts @@ -7,7 +7,7 @@ import type { import type { QueryRemindersOptions, ReminderFilters, - ReminderResponse, + ReminderResponseData, ReminderSort, } from '../../types'; import type { StreamChat } from '../../client'; @@ -16,15 +16,15 @@ import { makeComparator } from '../sortCompiler'; import { resolveDotPathValue } from '../utility.normalization'; // Reminders are keyed by the message they belong to; used for interval dedup and index addressing. -const getReminderId = (reminder: ReminderResponse) => reminder.message_id; +const getReminderId = (reminder: ReminderResponseData) => reminder.message_id; // Fallback order for interval placement when no explicit sort is set. Order is not a pinned contract // (ReminderManager stores reminders in a message_id-keyed Map), but interval storage needs a total // order, so default to a deterministic one. -const DEFAULT_SORT: ReminderSort = { created_at: 1 }; +const DEFAULT_SORT: ReminderSort = [{ direction: 1, field: 'created_at' }]; export class ReminderPaginator extends BasePaginator< - ReminderResponse, + ReminderResponseData, QueryRemindersOptions > { private client: StreamChat; @@ -52,25 +52,25 @@ export class ReminderPaginator extends BasePaginator< constructor( client: StreamChat, - options?: PaginatorOptions, + options?: PaginatorOptions, ) { super({ initialCursor: ZERO_PAGE_CURSOR, - itemIndex: new ItemIndex({ getId: getReminderId }), + itemIndex: new ItemIndex({ getId: getReminderId }), ...options, }); this.client = client; this.sortComparator = this.buildSortComparator(); } - getItemId(item: ReminderResponse): string { + getItemId(item: ReminderResponseData): string { return getReminderId(item); } // Interval storage needs a total order. Derive it from the requested sort (rebuilt when `sort` // changes, which also resets the accumulated pages), with a message_id tiebreaker. private buildSortComparator() { - return makeComparator({ + return makeComparator({ sort: this._sort ?? DEFAULT_SORT, resolvePathValue: resolveDotPathValue, tiebreaker: (l, r) => @@ -95,11 +95,11 @@ export class ReminderPaginator extends BasePaginator< query = async ({ queryShape, }: PaginationQueryParams): Promise< - PaginationQueryReturnValue + PaginationQueryReturnValue > => { const { reminders: items, next, prev } = await this.client.queryReminders(queryShape); return { items, headward: prev, tailward: next }; }; - filterQueryResults = (items: ReminderResponse[]) => items; + filterQueryResults = (items: ReminderResponseData[]) => items; } diff --git a/src/pagination/paginators/UserGroupPaginator.ts b/src/pagination/paginators/UserGroupPaginator.ts index bc19ee1f76..89776e85f1 100644 --- a/src/pagination/paginators/UserGroupPaginator.ts +++ b/src/pagination/paginators/UserGroupPaginator.ts @@ -5,7 +5,7 @@ import type { PaginatorOptions, PaginatorState, } from './BasePaginator'; -import type { QueryUserGroupsOptions, UserGroupResponse } from '../../types'; +import type { ListUserGroupsOptions, UserGroupResponse } from '../../types'; import type { StreamChat } from '../../client'; import { ItemIndex } from '../ItemIndex'; @@ -38,14 +38,14 @@ const decodeCursor = (cursor: string | null | undefined) */ export class UserGroupPaginator extends BasePaginator< UserGroupResponse, - QueryUserGroupsOptions + ListUserGroupsOptions > { private client: StreamChat; protected _teamId: string | undefined; constructor( client: StreamChat, - options?: PaginatorOptions, + options?: PaginatorOptions, ) { super({ initialCursor: { ...ZERO_PAGE_CURSOR, headward: null }, @@ -85,7 +85,7 @@ export class UserGroupPaginator extends BasePaginator< if (!lastItem) return undefined; return JSON.stringify({ - created_at_gt: lastItem.created_at, + created_at_gt: lastItem.created_at.toISOString(), id_gt: lastItem.id, } satisfies UserGroupListCursor); }; @@ -93,7 +93,7 @@ export class UserGroupPaginator extends BasePaginator< // The query shape must stay stable across pages: the paginator resets its // accumulated list when the query shape changes ('auto' reset policy), so the // forward cursor is NOT part of the shape — it is applied per request in `query`. - protected getNextQueryShape(): QueryUserGroupsOptions { + protected getNextQueryShape(): ListUserGroupsOptions { return { limit: this.pageSize, ...(this.teamId ? { team_id: this.teamId } : {}), @@ -103,7 +103,7 @@ export class UserGroupPaginator extends BasePaginator< query = async ({ direction, queryShape, - }: PaginationQueryParams): Promise< + }: PaginationQueryParams): Promise< PaginationQueryReturnValue > => { if (direction === 'headward') { @@ -111,13 +111,13 @@ export class UserGroupPaginator extends BasePaginator< } const cursor = decodeCursor(this.cursor?.tailward); - const options: QueryUserGroupsOptions = { + const options: ListUserGroupsOptions = { ...(queryShape ?? this.getNextQueryShape()), ...(cursor?.id_gt ? { id_gt: cursor.id_gt } : {}), ...(cursor?.created_at_gt ? { created_at_gt: cursor.created_at_gt } : {}), }; - const { user_groups: items } = await this.client.queryUserGroups(options); + const { user_groups: items } = await this.client.listUserGroups(options); return { items, tailward: this.buildNextCursor(items) }; }; diff --git a/src/pagination/sortCompiler.ts b/src/pagination/sortCompiler.ts index e775dbcb19..b9abd5d87f 100644 --- a/src/pagination/sortCompiler.ts +++ b/src/pagination/sortCompiler.ts @@ -1,12 +1,10 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - import { compare, resolveDotPathValue as defaultResolvePathValue, normalizeComparedValues, } from './utility.normalization'; import { normalizeQuerySort } from '../utils'; -import type { AscDesc } from '../types'; +import type { AscDesc, SortParamRequest } from '../types'; import type { Comparator, PathResolver } from './types.normalization'; export type ItemLocation = { @@ -124,13 +122,15 @@ export function binarySearch({ * (but they can still move relative to others — sort in JS is not guaranteed stable in older engines, though modern V8/Node/Chrome/Firefox make it stable) * * Positive number (> 0) → a comes after b - * @param sort - * @param resolvePathValue - * @param tiebreaker + * + * @param params - Comparator configuration. + * @param params.sort - The sort specification defining fields and directions. + * @param params.resolvePathValue - Resolver used to read a field value from an item. + * @param params.tiebreaker - Comparator applied when all sort terms are equal. */ export function makeComparator< T, - S extends Record | Record[], + S extends Record | Record[] | SortParamRequest[], >({ sort, resolvePathValue = defaultResolvePathValue, diff --git a/src/pagination/utility.normalization.ts b/src/pagination/utility.normalization.ts index c7df10aef9..fda6dace8c 100644 --- a/src/pagination/utility.normalization.ts +++ b/src/pagination/utility.normalization.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - export function asArray(v: any): any[] { return Array.isArray(v) ? v : [v]; } diff --git a/src/pagination/utility.queryChannel.ts b/src/pagination/utility.queryChannel.ts index 2a2fedd9b0..ea33d5a1c2 100644 --- a/src/pagination/utility.queryChannel.ts +++ b/src/pagination/utility.queryChannel.ts @@ -1,4 +1,4 @@ -import type { ChannelQueryOptions, QueryChannelAPIResponse } from '../types'; +import type { ChannelGetOrCreateRequest, ChannelStateResponse } from '../types'; import type { StreamChat } from '../client'; import type { Channel } from '../channel'; import { generateChannelTempCid } from '../utils'; @@ -9,7 +9,7 @@ import { generateChannelTempCid } from '../utils'; */ const WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL: Record< string, - Promise | undefined + Promise | undefined > = {}; type GetChannelParams = { @@ -17,19 +17,21 @@ type GetChannelParams = { channel?: Channel; id?: string; members?: string[]; - options?: ChannelQueryOptions; + options?: ChannelGetOrCreateRequest; type?: string; }; /** * Watches a channel, coalescing concurrent invocations for the same CID. * If a watch is already in flight, this call waits for it to settle instead of * issuing another network request. - * @param client - * @param members - * @param options - * @param type - * @param id - * @param channel + * + * @param params - The channel query parameters. + * @param params.client - The chat client instance. + * @param params.members - Member user ids used to construct or identify the channel. + * @param params.options - Options forwarded to the underlying channel watch request. + * @param params.type - The channel type. + * @param params.id - The channel id. + * @param params.channel - An existing channel to watch (skips construction from type/id/members). */ export const getChannel = async ({ channel, @@ -44,8 +46,13 @@ export const getChannel = async ({ } // unfortunately typescript is not able to infer that if (!channel && !type) === false, then channel or type has to be truthy - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const theChannel = channel || client.channel(type!, id, { members }); + + const theChannel = + channel || + // `members` are member IDs; the OpenAPI `ChannelData.members` expects member objects. + client.channel(type as string, id, { + members: members?.map((user_id) => ({ user_id })), + }); // need to keep as with call to channel.watch the id can be changed from undefined to an actual ID generated server-side const originalCid = theChannel?.id diff --git a/src/permissions.ts b/src/permissions.ts index 042ec46990..776bfb7ed8 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -56,8 +56,7 @@ export const DenyAll = new Permission( Deny, ); -// Fixme: rename to RoleName with next major release -export type Role = +export type RoleName = | 'admin' | 'user' | 'guest' @@ -79,25 +78,25 @@ export const BuiltinPermissions = { AddLinks: 'Add Links', BanUser: 'Ban User', CreateChannel: 'Create Channel', - CreateMessage: 'Create Message', + CreateMessage: 'Create MessageRequest', CreateReaction: 'Create Reaction', DeleteAnyAttachment: 'Delete Any Attachment', DeleteAnyChannel: 'Delete Any Channel', - DeleteAnyMessage: 'Delete Any Message', + DeleteAnyMessage: 'Delete Any MessageRequest', DeleteAnyReaction: 'Delete Any Reaction', DeleteOwnAttachment: 'Delete Own Attachment', DeleteOwnChannel: 'Delete Own Channel', - DeleteOwnMessage: 'Delete Own Message', + DeleteOwnMessage: 'Delete Own MessageRequest', DeleteOwnReaction: 'Delete Own Reaction', ReadAnyChannel: 'Read Any Channel', ReadOwnChannel: 'Read Own Channel', - RunMessageAction: 'Run Message Action', + RunMessageAction: 'Run MessageRequest Action', UpdateAnyChannel: 'Update Any Channel', - UpdateAnyMessage: 'Update Any Message', + UpdateAnyMessage: 'Update Any MessageRequest', UpdateMembersAnyChannel: 'Update Members Any Channel', UpdateMembersOwnChannel: 'Update Members Own Channel', UpdateOwnChannel: 'Update Own Channel', - UpdateOwnMessage: 'Update Own Message', + UpdateOwnMessage: 'Update Own MessageRequest', UploadAttachment: 'Upload Attachment', UseFrozenChannel: 'Send messages and reactions to frozen channels', }; diff --git a/src/poll.ts b/src/poll.ts index b14d6ab9c6..34cf113b03 100644 --- a/src/poll.ts +++ b/src/poll.ts @@ -1,64 +1,36 @@ import { StateStore } from './store'; import type { StreamChat } from './client'; import type { - Event, + EventPayload, PartialPollUpdate, - PollAnswer, - PollData, PollEnrichData, PollOptionData, - PollResponse, - PollVote, + PollResponse_old, + PollVoteResponseData, QueryVotesFilters, QueryVotesOptions, + RequireLiteral, + UpdatePollRequest, VoteSort, + VotingVisibility, } from './types'; +import type { PollResponseData as Gen_PollResponseData, WSEvent } from './gen/models'; -type PollEvent = { - cid: string; - created_at: string; - poll: PollResponse; -}; - -type PollUpdatedEvent = PollEvent & { - type: 'poll.updated'; -}; - -type PollClosedEvent = PollEvent & { - type: 'poll.closed'; -}; - -type PollVoteEvent = { - cid: string; - created_at: string; - poll: PollResponse; - poll_vote: PollVote | PollAnswer; -}; - -type PollVoteCastedEvent = PollVoteEvent & { - type: 'poll.vote_casted'; -}; - -type PollVoteCastedChanged = PollVoteEvent & { - type: 'poll.vote_removed'; -}; - -type PollVoteCastedRemoved = PollVoteEvent & { - type: 'poll.vote_removed'; -}; - -const isPollUpdatedEvent = (e: Event): e is PollUpdatedEvent => e.type === 'poll.updated'; -const isPollClosedEventEvent = (e: Event): e is PollClosedEvent => +const isPollUpdatedEvent = (e: WSEvent): e is EventPayload<'poll.updated'> => + e.type === 'poll.updated'; +const isPollClosedEventEvent = (e: WSEvent): e is EventPayload<'poll.closed'> => e.type === 'poll.closed'; -const isPollVoteCastedEvent = (e: Event): e is PollVoteCastedEvent => +const isPollVoteCastedEvent = (e: WSEvent): e is EventPayload<'poll.vote_casted'> => e.type === 'poll.vote_casted'; -const isPollVoteChangedEvent = (e: Event): e is PollVoteCastedChanged => +const isPollVoteChangedEvent = (e: WSEvent): e is EventPayload<'poll.vote_changed'> => e.type === 'poll.vote_changed'; -const isPollVoteRemovedEvent = (e: Event): e is PollVoteCastedRemoved => +const isPollVoteRemovedEvent = (e: WSEvent): e is EventPayload<'poll.vote_removed'> => e.type === 'poll.vote_removed'; -export const isVoteAnswer = (vote: PollVote | PollAnswer): vote is PollAnswer => - !!(vote as PollAnswer)?.answer_text; +export const isVoteAnswer = ( + vote: any | undefined, +): vote is RequireLiteral => + !!vote?.answer_text; export type PollAnswersQueryParams = { filter?: QueryVotesFilters; @@ -74,16 +46,16 @@ export type PollOptionVotesQueryParams = { type OptionId = string; -export type PollState = Omit & { +export type PollState = Omit & { lastActivityAt: Date; // todo: would be ideal to get this from the BE maxVotedOptionIds: OptionId[]; - ownVotesByOptionId: Record; - ownAnswer?: PollAnswer; // each user can have only one answer + ownVotesByOptionId: Record; + ownAnswer?: PollVoteResponseData; // each user can have only one answer }; type PollInitOptions = { client: StreamChat; - poll: PollResponse; + poll: Gen_PollResponseData; }; export class Poll { @@ -99,11 +71,10 @@ export class Poll { } private getInitialStateFromPollResponse = (poll: PollInitOptions['poll']) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { own_votes, id, ...pollResponseForState } = poll; + const { own_votes, id: _id, ...pollResponseForState } = poll; const { ownAnswer, ownVotes } = own_votes?.reduce<{ - ownVotes: PollVote[]; - ownAnswer?: PollAnswer; + ownVotes: PollVoteResponseData[]; + ownAnswer?: PollVoteResponseData; }>( (acc, voteOrAnswer) => { if (isVoteAnswer(voteOrAnswer)) { @@ -119,9 +90,7 @@ export class Poll { return { ...pollResponseForState, lastActivityAt: new Date(), - maxVotedOptionIds: getMaxVotedOptionIds( - pollResponseForState.vote_counts_by_option as PollResponse['vote_counts_by_option'], - ), + maxVotedOptionIds: getMaxVotedOptionIds(pollResponseForState.vote_counts_by_option), ownAnswer, ownVotesByOptionId: getOwnVotesByOptionId(ownVotes), }; @@ -142,17 +111,17 @@ export class Poll { return this.state.getLatestValue(); } - public handlePollUpdated = (event: Event) => { + public handlePollUpdated = (event: EventPayload<'poll.updated'>) => { if (event.poll?.id && event.poll.id !== this.id) return; if (!isPollUpdatedEvent(event)) return; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { id, ...pollData } = extractPollData(event.poll); + + const { id: _id, ...pollData } = extractPollData(event.poll); // @ts-expect-error type mismatch this.state.partialNext({ ...pollData, lastActivityAt: new Date(event.created_at) }); this.upsertOfflineDb(); }; - public handlePollClosed = (event: Event) => { + public handlePollClosed = (event: EventPayload<'poll.closed'>) => { if (event.poll?.id && event.poll.id !== this.id) return; if (!isPollClosedEventEvent(event)) return; this.state.partialNext({ @@ -162,12 +131,12 @@ export class Poll { this.upsertOfflineDb(); }; - public handleVoteCasted = (event: Event) => { + public handleVoteCasted = (event: EventPayload<'poll.vote_casted'>) => { if (event.poll?.id && event.poll.id !== this.id) return; if (!isPollVoteCastedEvent(event)) return; const currentState = this.data; - const isOwnVote = event.poll_vote.user_id === this.client.userID; - let latestAnswers = [...(currentState.latest_answers as PollAnswer[])]; + const isOwnVote = event.poll_vote.user_id === this.client.userId; + let latestAnswers = [...(currentState.latest_answers as PollVoteResponseData[])]; let ownAnswer = currentState.ownAnswer; const ownVotesByOptionId = currentState.ownVotesByOptionId; let maxVotedOptionIds = currentState.maxVotedOptionIds; @@ -198,13 +167,13 @@ export class Poll { this.upsertOfflineDb(); }; - public handleVoteChanged = (event: Event) => { + public handleVoteChanged = (event: EventPayload<'poll.vote_changed'>) => { // this event is triggered only when event.poll.enforce_unique_vote === true if (event.poll?.id && event.poll.id !== this.id) return; if (!isPollVoteChangedEvent(event)) return; const currentState = this.data; - const isOwnVote = event.poll_vote.user_id === this.client.userID; - let latestAnswers = [...(currentState.latest_answers as PollAnswer[])]; + const isOwnVote = event.poll_vote.user_id === this.client.userId; + let latestAnswers = [...(currentState.latest_answers as PollVoteResponseData[])]; let ownAnswer = currentState.ownAnswer; let ownVotesByOptionId = currentState.ownVotesByOptionId; let maxVotedOptionIds = currentState.maxVotedOptionIds; @@ -221,7 +190,7 @@ export class Poll { ownVotesByOptionId = { [event.poll_vote.option_id]: event.poll_vote }; } else { ownVotesByOptionId = Object.entries(ownVotesByOptionId).reduce< - Record + Record >((acc, [optionId, vote]) => { if ( optionId !== event.poll_vote.option_id && @@ -258,12 +227,12 @@ export class Poll { this.upsertOfflineDb(); }; - public handleVoteRemoved = (event: Event) => { + public handleVoteRemoved = (event: EventPayload<'poll.vote_removed'>) => { if (event.poll?.id && event.poll.id !== this.id) return; if (!isPollVoteRemovedEvent(event)) return; const currentState = this.data; - const isOwnVote = event.poll_vote.user_id === this.client.userID; - let latestAnswers = [...(currentState.latest_answers as PollAnswer[])]; + const isOwnVote = event.poll_vote.user_id === this.client.userId; + let latestAnswers = [...(currentState.latest_answers as PollVoteResponseData[])]; let ownAnswer = currentState.ownAnswer; const ownVotesByOptionId = { ...currentState.ownVotesByOptionId }; let maxVotedOptionIds = currentState.maxVotedOptionIds; @@ -293,29 +262,36 @@ export class Poll { }; query = async (id: string) => { - const { poll } = await this.client.getPoll(id); + const { poll } = await this.client.getPoll({ poll_id: id }); this.state.partialNext({ ...poll, lastActivityAt: new Date() }); return poll; }; - update = async (data: Exclude) => - await this.client.updatePoll({ ...data, id: this.id }); + update = async (data: Exclude) => + await this.client.updatePoll({ ...data, id: this.id as string }); partialUpdate = async (partialPollObject: PartialPollUpdate) => - await this.client.partialUpdatePoll(this.id as string, partialPollObject); + await this.client.updatePollPartial({ + poll_id: this.id as string, + ...partialPollObject, + }); - close = async () => await this.client.closePoll(this.id as string); + close = async () => + await this.client.updatePollPartial({ + poll_id: this.id as string, + set: { is_closed: true }, + }); - delete = async () => await this.client.deletePoll(this.id as string); + delete = async () => await this.client.deletePoll({ poll_id: this.id as string }); createOption = async (option: PollOptionData) => - await this.client.createPollOption(this.id as string, option); + await this.client.createPollOption({ poll_id: this.id as string, ...option }); updateOption = async (option: PollOptionData) => - await this.client.updatePollOption(this.id as string, option); + await this.client.updatePollOption({ poll_id: this.id as string, ...option }); - deleteOption = async (optionId: string) => - await this.client.deletePollOption(this.id as string, optionId); + deleteOption = async (option_id: string) => + await this.client.deletePollOption({ poll_id: this.id as string, option_id }); castVote = async (optionId: string, messageId: string) => { const { max_votes_allowed, ownVotesByOptionId } = this.data; @@ -336,38 +312,54 @@ export class Poll { }); return; } - return await this.client.castPollVote(messageId, this.id as string, { - option_id: optionId, + return await this.client.castPollVote({ + message_id: messageId, + poll_id: this.id as string, + vote: { option_id: optionId }, }); }; removeVote = async (voteId: string, messageId: string) => - await this.client.removePollVote(messageId, this.id as string, voteId); + await this.client.deletePollVote({ + message_id: messageId, + poll_id: this.id as string, + vote_id: voteId, + }); addAnswer = async (answerText: string, messageId: string) => - await this.client.addPollAnswer(messageId, this.id as string, answerText); + await this.client.castPollVote({ + message_id: messageId, + poll_id: this.id as string, + vote: { answer_text: answerText }, + }); removeAnswer = async (answerId: string, messageId: string) => - await this.client.removePollVote(messageId, this.id as string, answerId); + await this.client.deletePollVote({ + message_id: messageId, + poll_id: this.id as string, + vote_id: answerId, + }); queryAnswers = async (params: PollAnswersQueryParams) => - await this.client.queryPollAnswers( - this.id as string, - params.filter, - params.sort, - params.options, - ); + await this.client.queryPollVotes({ + poll_id: this.id as string, + sort: params.sort, + filter: { ...(params.filter ?? {}), is_answer: true }, + ...(params.options ?? {}), + }); queryOptionVotes = async (params: PollOptionVotesQueryParams) => - await this.client.queryPollVotes( - this.id as string, - params.filter, - params.sort, - params.options, - ); + await this.client.queryPollVotes({ + poll_id: this.id as string, + sort: params.sort, + filter: params.filter, + ...(params.options ?? {}), + }); } -function getMaxVotedOptionIds(voteCountsByOption: PollResponse['vote_counts_by_option']) { +function getMaxVotedOptionIds( + voteCountsByOption: PollResponse_old['vote_counts_by_option'], +) { let maxVotes = 0; let winningOptions: string[] = []; for (const [id, count] of Object.entries(voteCountsByOption ?? {})) { @@ -381,17 +373,17 @@ function getMaxVotedOptionIds(voteCountsByOption: PollResponse['vote_counts_by_o return winningOptions; } -function getOwnVotesByOptionId(ownVotes: PollVote[]) { +function getOwnVotesByOptionId(ownVotes: PollVoteResponseData[]) { return !ownVotes - ? ({} as Record) - : ownVotes.reduce>((acc, vote) => { + ? ({} as Record) + : ownVotes.reduce>((acc, vote) => { if (isVoteAnswer(vote) || !vote.option_id) return acc; acc[vote.option_id] = vote; return acc; }, {}); } -export function extractPollData(pollResponse: PollResponse): PollData { +export function extractPollData(pollResponse: Gen_PollResponseData): UpdatePollRequest { return { allow_answers: pollResponse.allow_answers, allow_user_suggested_options: pollResponse.allow_user_suggested_options, @@ -402,16 +394,15 @@ export function extractPollData(pollResponse: PollResponse): PollData { max_votes_allowed: pollResponse.max_votes_allowed, name: pollResponse.name, options: pollResponse.options, - voting_visibility: pollResponse.voting_visibility, + voting_visibility: pollResponse.voting_visibility as VotingVisibility, }; } -export function mapPollStateToResponse(poll: Poll): PollResponse { +export function mapPollStateToResponse(poll: Poll): PollResponse_old { const { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - lastActivityAt, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - maxVotedOptionIds, + lastActivityAt: _lastActivityAt, + + maxVotedOptionIds: _maxVotedOptionIds, ownVotesByOptionId, ownAnswer, ...restState @@ -419,7 +410,7 @@ export function mapPollStateToResponse(poll: Poll): PollResponse { const ownVotes = [ ...Object.values(ownVotesByOptionId), ...(ownAnswer ? [ownAnswer] : []), - ].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at)); + ].sort((a, b) => a.created_at.getTime() - b.created_at.getTime()); return { ...restState, @@ -429,7 +420,7 @@ export function mapPollStateToResponse(poll: Poll): PollResponse { } export function extractPollEnrichedData( - pollResponse: PollResponse, + pollResponse: Gen_PollResponseData, ): Omit { return { answers_count: pollResponse.answers_count, diff --git a/src/poll_manager.ts b/src/poll_manager.ts index 7b91c6ad51..97c0f313b0 100644 --- a/src/poll_manager.ts +++ b/src/poll_manager.ts @@ -1,9 +1,9 @@ import type { StreamChat } from './client'; import type { - CreatePollData, + CreatePollRequest, LocalMessage, MessageResponse, - PollResponse, + PollResponse_old, PollSort, QueryPollsFilters, QueryPollsOptions, @@ -46,7 +46,7 @@ export class PollManager extends WithSubscriptions { this.addUnsubscribeFunction(this.subscribeVoteRemoved()); }; - public createPoll = async (poll: CreatePollData) => { + public createPoll = async (poll: CreatePollRequest) => { const { poll: createdPoll } = await this.client.createPoll(poll); if (!createdPoll.vote_counts_by_option) { @@ -63,11 +63,13 @@ export class PollManager extends WithSubscriptions { // optimistically return the cached poll if it exists and update in the background if (cachedPoll) { - this.client.getPoll(id).then(({ poll }) => this.setOrOverwriteInCache(poll, true)); + this.client + .getPoll({ poll_id: id }) + .then(({ poll }) => this.setOrOverwriteInCache(poll, true)); return cachedPoll; } // fetch it, write to the cache and return otherwise - const { poll } = await this.client.getPoll(id); + const { poll } = await this.client.getPoll({ poll_id: id }); this.setOrOverwriteInCache(poll); @@ -79,7 +81,11 @@ export class PollManager extends WithSubscriptions { sort: PollSort = [], options: QueryPollsOptions = {}, ) => { - const { polls, next } = await this.client.queryPolls(filter, sort, options); + const { polls, next } = await this.client.queryPolls({ + filter, + sort, + ...options, + }); const pollInstances = polls.map((poll) => { this.setOrOverwriteInCache(poll, true); @@ -101,13 +107,13 @@ export class PollManager extends WithSubscriptions { if (!message.poll) { continue; } - const pollResponse = message.poll as PollResponse; + const pollResponse = message.poll as PollResponse_old; this.setOrOverwriteInCache(pollResponse, overwriteState); } }; private setOrOverwriteInCache = ( - pollResponse: PollResponse, + pollResponse: PollResponse_old, overwriteState?: boolean, ) => { if (!this.client._cacheEnabled()) { diff --git a/src/reminders/Reminder.ts b/src/reminders/Reminder.ts index b41f2b6844..669e02bb3c 100644 --- a/src/reminders/Reminder.ts +++ b/src/reminders/Reminder.ts @@ -1,14 +1,11 @@ import { ReminderTimer } from './ReminderTimer'; import { StateStore } from '../store'; import type { ReminderTimerConfig } from './ReminderTimer'; -import type { MessageResponse, ReminderResponseBase, UserResponse } from '../types'; +import type { MessageResponse, ReminderResponseData, UserResponse } from '../types'; export const timeLeftMs = (remindAt: number) => remindAt - new Date().getTime(); -export type ReminderResponseBaseOrResponse = ReminderResponseBase & { - user?: UserResponse; - message?: MessageResponse; -}; +export type ReminderResponseBaseOrResponse = ReminderResponseData; export type ReminderState = { channel_cid: string; diff --git a/src/reminders/ReminderManager.ts b/src/reminders/ReminderManager.ts index 95b12fefa5..90f6bdb34c 100644 --- a/src/reminders/ReminderManager.ts +++ b/src/reminders/ReminderManager.ts @@ -8,10 +8,9 @@ import type { StreamChat } from '../client'; import type { CreateReminderOptions, Event, - EventTypes, + EventPayload, LocalMessage, MessageResponse, - ReminderResponse, } from '../types'; const oneMinute = 60 * 1000; @@ -38,14 +37,9 @@ const isReminderDoesNotExistError = (error: Error) => type MessageId = string; -export type ReminderEvent = { - cid: string; - created_at: string; - message_id: MessageId; - reminder: ReminderResponse; - type: EventTypes; - user_id: string; -}; +export type ReminderEvent = EventPayload< + `reminder.${string}` | 'notification.reminder_due' +>; export type ReminderManagerState = { reminders: Map; @@ -167,6 +161,7 @@ export class ReminderManager extends WithSubscriptions { // WS event handling START // static isReminderWsEventPayload = (event: Event): event is ReminderEvent => + 'reminder' in event && !!event.reminder && (event.type.startsWith('reminder.') || event.type === 'notification.reminder_due'); @@ -186,14 +181,17 @@ export class ReminderManager extends WithSubscriptions { this.client.on('reminder.created', (event) => { if (!ReminderManager.isReminderWsEventPayload(event)) return; const { reminder } = event; - this.upsertToState({ data: reminder }); + // TODO: OAPI discrepancy? + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.upsertToState({ data: reminder! }); }).unsubscribe; private subscribeReminderUpdated = () => this.client.on('reminder.updated', (event) => { if (!ReminderManager.isReminderWsEventPayload(event)) return; const { reminder } = event; - this.upsertToState({ data: reminder }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.upsertToState({ data: reminder! }); }).unsubscribe; private subscribeReminderDeleted = () => @@ -249,8 +247,8 @@ export class ReminderManager extends WithSubscriptions { // API calls START // upsertReminder = async (options: CreateReminderOptions) => { - const { messageId } = options; - if (this.getFromState(messageId)) { + const { message_id } = options; + if (this.getFromState(message_id)) { try { return await this.updateReminder(options); } catch (error) { @@ -272,17 +270,17 @@ export class ReminderManager extends WithSubscriptions { }; createReminder = async (options: CreateReminderOptions) => { - const { reminder } = await this.client.createReminder(options); - return this.upsertToState({ data: reminder, overwrite: false }); + const response = await this.client.createReminder(options); + return this.upsertToState({ data: response, overwrite: false }); }; updateReminder = async (options: CreateReminderOptions) => { - const { reminder } = await this.client.updateReminder(options); - return this.upsertToState({ data: reminder }); + const response = await this.client.updateReminder(options); + return this.upsertToState({ data: response.reminder }); }; deleteReminder = async (messageId: MessageId) => { - await this.client.deleteReminder(messageId); + await this.client.deleteReminder({ message_id: messageId }); this.removeFromState(messageId); }; diff --git a/src/search/BaseSearchSource.ts b/src/search/BaseSearchSource.ts index 6b5f9a28d1..044da90cd4 100644 --- a/src/search/BaseSearchSource.ts +++ b/src/search/BaseSearchSource.ts @@ -14,7 +14,6 @@ export type DebounceOptions = { }; type DebouncedExecQueryFunction = DebouncedFunc<(searchString?: string) => Promise>; -// eslint-disable-next-line @typescript-eslint/no-explicit-any interface ISearchSource { activate(): void; @@ -40,14 +39,12 @@ interface ISearchSource { readonly type: SearchSourceType; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any export interface SearchSource extends ISearchSource { cancelScheduledQuery(): void; setDebounceOptions(options: DebounceOptions): void; search(text?: string): Promise | undefined; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any export interface SearchSourceSync extends ISearchSource { cancelScheduledQuery(): void; setDebounceOptions(options: DebounceOptions): void; diff --git a/src/search/ChannelMemberSearchSource.ts b/src/search/ChannelMemberSearchSource.ts index f851d8c9c4..0d21138e65 100644 --- a/src/search/ChannelMemberSearchSource.ts +++ b/src/search/ChannelMemberSearchSource.ts @@ -75,7 +75,13 @@ export class ChannelMemberSearchSource< }); const sort = this.sort ?? []; const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const { members } = await this.channel.queryMembers(filters ?? {}, sort, options); + const { members } = await this.channel.queryMembers({ + payload: { + filter_conditions: filters ?? {}, + sort, + ...options, + }, + }); return { items: members }; } diff --git a/src/search/ChannelSearchSource.ts b/src/search/ChannelSearchSource.ts index 8bf1729338..e2373b8fb5 100644 --- a/src/search/ChannelSearchSource.ts +++ b/src/search/ChannelSearchSource.ts @@ -54,16 +54,23 @@ export class ChannelSearchSource< protected async query(searchQuery: string) { const filters = this.filterBuilder.buildFilters({ baseFilters: { - ...(this.client.userID ? { members: { $in: [this.client.userID] } } : {}), + ...(this.client.userId ? { members: { $in: [this.client.userId] } } : {}), ...this.filters, }, context: { searchQuery } as Partial< ChannelSearchSourceFilterBuilderContext >, }); - const sort = this.sort ?? {}; + const sort = this.sort; const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const items = await this.client.queryChannels(filters, sort, options); + const items = await this.client.queryChannelsAndHydrate( + { + filter_conditions: filters, + sort, + ...options, + }, + { withResponse: false }, + ); return { items }; } diff --git a/src/search/MessageSearchSource.ts b/src/search/MessageSearchSource.ts index 63c9c33774..a2dbd4c175 100644 --- a/src/search/MessageSearchSource.ts +++ b/src/search/MessageSearchSource.ts @@ -6,7 +6,7 @@ import type { MessageFilters, MessageResponse, SearchMessageSort, - SearchOptions, + SearchPayload, } from '../types'; import type { StreamChat } from '../client'; import type { SearchSourceOptions } from './types'; @@ -60,7 +60,7 @@ export class MessageSearchSource< readonly type = 'messages'; private client: StreamChat; - messageSearchChannelFilters: ChannelFilters | undefined; + messageSearchChannelFilters: SearchPayload['filter_conditions'] | undefined; messageSearchFilters: MessageFilters | undefined; messageSearchSort: SearchMessageSort | undefined; @@ -69,7 +69,7 @@ export class MessageSearchSource< channelQueryOptions: Omit | undefined; messageSearchChannelFilterBuilder: FilterBuilder< - ChannelFilters, + SearchPayload['filter_conditions'], MergeContext< BuiltInContexts['messageSearchChannel'], TContexts['messageSearchChannelContext'] @@ -130,11 +130,11 @@ export class MessageSearchSource< } protected async query(searchQuery: string) { - if (!this.client.userID || this.next === null) return { items: [] }; + if (!this.client.userId || this.next === null) return { items: [] }; const channelFilters = this.messageSearchChannelFilterBuilder.buildFilters({ baseFilters: { - ...(this.client.userID ? { members: { $in: [this.client.userID] } } : {}), + ...(this.client.userId ? { members: { $in: [this.client.userId] } } : {}), ...this.messageSearchChannelFilters, }, context: { searchQuery } as Partial< @@ -155,23 +155,25 @@ export class MessageSearchSource< >, }); - const sort: SearchMessageSort = { - created_at: -1, - ...this.messageSearchSort, - }; - - const options: SearchOptions = { - limit: this.pageSize, - next: this.next, - sort, - }; - - const { next, results } = await this.client.search( - channelFilters, - messageFilters, - options, - ); - const items = results.map(({ message }) => message); + const { next, results } = await this.client.search({ + payload: { + filter_conditions: channelFilters, + message_filter_conditions: messageFilters, + limit: this.pageSize, + next: this.next, + sort: [ + { + field: 'created_at', + direction: -1, + }, + ...(this.messageSearchSort ?? []), + ], + }, + }); + + const items = results + .map(({ message }) => message) + .filter((m): m is NonNullable => Boolean(m)); const cids = Array.from( items.reduce((acc, message) => { @@ -187,14 +189,11 @@ export class MessageSearchSource< MergeContext >, }); - await this.client.queryChannels( - channelQueryFilters, - { - last_message_at: -1, - ...this.channelQuerySort, - }, - this.channelQueryOptions, - ); + await this.client.queryChannelsAndHydrate({ + filter_conditions: channelQueryFilters, + sort: [{ direction: -1, field: 'last_message_at' }], + ...this.channelQueryOptions, + }); } return { items, next }; diff --git a/src/search/UserSearchSource.ts b/src/search/UserSearchSource.ts index 335073c8ad..d47b5d7310 100644 --- a/src/search/UserSearchSource.ts +++ b/src/search/UserSearchSource.ts @@ -60,15 +60,19 @@ export class UserSearchSource< baseFilters: this.filters, context: { searchQuery } as UserSearchSourceFilterBuilderContext, }); - let sort: UserSort; - if (Array.isArray(this.sort)) { - const hasIdSort = this.sort.some((entry) => 'id' in entry); - sort = hasIdSort ? this.sort : [...this.sort, { id: 1 }]; - } else { - sort = { id: 1, ...this.sort }; - } + const baseSort = this.sort ?? []; + const hasIdSort = baseSort.some((entry) => entry.field === 'id'); + const sort: UserSort = hasIdSort + ? baseSort + : [...baseSort, { field: 'id', direction: 1 }]; const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const { users } = await this.client.queryUsers(filters, sort, options); + const { users } = await this.client.queryUsers({ + payload: { + filter_conditions: filters, + sort, + ...options, + }, + }); return { items: users }; } diff --git a/src/search/types.ts b/src/search/types.ts index f1708df177..41adaf360e 100644 --- a/src/search/types.ts +++ b/src/search/types.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line @typescript-eslint/no-explicit-any export type SearchSourceState = { hasNext: boolean; isActive: boolean; @@ -11,12 +10,12 @@ export type SearchSourceState = { }; export type SearchSourceOptions = { - /** The number of milliseconds to debounce the search query. The default interval is 300ms. */ + /** The number of milliseconds to debounce the search query (defaults to `300`). */ debounceMs?: number; pageSize?: number; - /** When true, the source can execute queries with an empty search string. Defaults to false. */ + /** When `true`, the source can execute queries with an empty search string (defaults to `false`). */ allowEmptySearchString?: boolean; - /** When true, previously loaded items are cleared at the start of a new search query. Defaults to true. */ + /** When `true`, previously loaded items are cleared at the start of a new search query (defaults to `true`). */ resetOnNewSearchQuery?: boolean; }; diff --git a/src/segment.ts b/src/segment.ts index a3091fd361..0e99cb9593 100644 --- a/src/segment.ts +++ b/src/segment.ts @@ -1,95 +1 @@ -import type { StreamChat } from './client'; -import type { - QuerySegmentTargetsFilter, - SegmentData, - SegmentResponse, - SortParam, -} from './types'; - -type SegmentType = 'user' | 'channel'; - -type SegmentUpdatableFields = { - description?: string; - filter?: {}; - name?: string; -}; - -export class Segment { - type: SegmentType; - id: string | null; - client: StreamChat; - data?: SegmentData | SegmentResponse; - - constructor( - client: StreamChat, - type: SegmentType, - id: string | null, - data?: SegmentData, - ) { - this.client = client; - this.type = type; - this.id = id; - this.data = data; - } - - create() { - const body = { - name: this.data?.name, - filter: this.data?.filter, - description: this.data?.description, - all_sender_channels: this.data?.all_sender_channels, - all_users: this.data?.all_users, - }; - - return this.client.createSegment(this.type, this.id, body); - } - - verifySegmentId() { - if (!this.id) { - throw new Error( - 'Segment id is missing. Either create the segment using segment.create() or set the id during instantiation - const segment = client.segment(id)', - ); - } - } - - get() { - this.verifySegmentId(); - return this.client.getSegment(this.id as string); - } - - update(data: Partial) { - this.verifySegmentId(); - - return this.client.updateSegment(this.id as string, data); - } - - addTargets(targets: string[]) { - this.verifySegmentId(); - return this.client.addSegmentTargets(this.id as string, targets); - } - - removeTargets(targets: string[]) { - this.verifySegmentId(); - return this.client.removeSegmentTargets(this.id as string, targets); - } - - delete() { - this.verifySegmentId(); - return this.client.deleteSegment(this.id as string); - } - - targetExists(targetId: string) { - this.verifySegmentId(); - return this.client.segmentTargetExists(this.id as string, targetId); - } - - queryTargets( - filter: QuerySegmentTargetsFilter | null = {}, - sort: SortParam[] | null | [] = [], - options = {}, - ) { - this.verifySegmentId(); - - return this.client.querySegmentTargets(this.id as string, filter, sort, options); - } -} +// Segment functionality has been moved to the server-side SDK. diff --git a/src/signing.ts b/src/signing.ts index 648e585cae..35fc296d52 100644 --- a/src/signing.ts +++ b/src/signing.ts @@ -2,18 +2,17 @@ import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import zlib from 'zlib'; import { decodeBase64, encodeBase64 } from './base64'; -import type { Event, UR } from './types'; +import type { UR } from './types'; +import type { WSEvent } from './gen/models'; /** - * Creates the JWT token that can be used for a UserSession - * @method JWTUserToken - * @memberof signing - * @private - * @param {Secret} apiSecret - API Secret key - * @param {string} userId - The user_id key in the JWT payload - * @param {UR} [extraData] - Extra that should be part of the JWT token - * @param {SignOptions} [jwtOptions] - Options that can be past to jwt.sign - * @return {string} JWT Token + * Creates the JWT token that can be used for a user session. + * + * @param apiSecret - API secret key. + * @param userId - The `user_id` key in the JWT payload. + * @param extraData - Extra data that should be part of the JWT token (optional, defaults to `{}`). + * @param jwtOptions - Options that can be passed to `jwt.sign` (optional, defaults to `{}`). + * @returns The signed JWT token. */ export function JWTUserToken( apiSecret: jwt.Secret, @@ -30,7 +29,7 @@ export function JWTUserToken( ...extraData, }; - // make sure we return a clear error when jwt is shimmed (ie. browser build) + // make sure we return a clear error when the JWT module is shimmed (i.e. browser build) if (jwt == null || jwt.sign == null) { throw Error( `Unable to find jwt crypto, if you are getting this error is probably because you are trying to generate tokens on browser or React Native (or other environment where crypto functions are not available). Please Note: token should only be generated server-side.`, @@ -48,6 +47,13 @@ export function JWTUserToken( return jwt.sign(payload, apiSecret, opts); } +/** + * Creates the JWT token that can be used for a server-side session. + * + * @param apiSecret - API secret key. + * @param jwtOptions - Options that can be passed to `jwt.sign` (optional, defaults to `{}`). + * @returns The signed JWT token. + */ export function JWTServerToken(apiSecret: jwt.Secret, jwtOptions: jwt.SignOptions = {}) { const payload = { server: true, @@ -60,6 +66,12 @@ export function JWTServerToken(apiSecret: jwt.Secret, jwtOptions: jwt.SignOption return jwt.sign(payload, apiSecret, opts); } +/** + * Decodes a JWT token and returns the embedded `user_id`. + * + * @param token - The JWT token to decode. + * @returns The `user_id` extracted from the token's payload, or an empty string when the token is malformed. + */ export function UserFromToken(token: string) { const fragments = token.split('.'); if (fragments.length !== 3) { @@ -72,9 +84,12 @@ export function UserFromToken(token: string) { } /** + * Generates a development token for the given user. + * + * Development tokens are unsigned and must only be used in environments where token validation is disabled. * - * @param {string} userId the id of the user - * @return {string} + * @param userId - The ID of the user. + * @returns The development token. */ export function DevToken(userId: string) { return [ @@ -85,16 +100,18 @@ export function DevToken(userId: string) { } /** - * Constant-time HMAC-SHA256 verification of `signature` against the - * digest of `body` using `secret` as the key. The signature is always - * computed over the **uncompressed** JSON bytes, so callers that - * decoded a gzipped or base64-wrapped payload must pass the inflated - * bytes here. + * Constant-time HMAC-SHA256 verification of `signature` against the digest of `body` using `secret` + * as the key. The signature is always computed over the **uncompressed** JSON bytes, so callers that + * decoded a gzipped or base64-wrapped payload must pass the inflated bytes here. * - * The legacy `client.verifyWebhook` helper wraps this function, so - * callers that have already migrated to `verifyAndParseWebhook`, - * `parseSqs`, or `parseSns` rarely need to invoke this - * directly. + * The legacy `client.verifyWebhook` helper wraps this function, so callers that have already + * migrated to {@link verifyAndParseWebhook}, {@link parseSqs}, or {@link parseSns} rarely need to + * invoke this directly. + * + * @param body - The uncompressed payload bytes that Stream signed. + * @param signature - The HMAC-SHA256 signature delivered alongside the payload. + * @param secret - Your app's API secret used as the HMAC key. + * @returns `true` when the signature matches the digest of `body`, otherwise `false`. */ export function verifySignature( body: string | Buffer, @@ -111,9 +128,14 @@ export function verifySignature( } /** - * @deprecated Use {@link verifySignature} - same logic, parameters - * reordered to match the cross-SDK contract - * (`verifySignature(body, signature, secret)`). + * Verifies an HMAC-SHA256 signature with the legacy parameter order. + * + * @param body - The uncompressed payload bytes that Stream signed. + * @param secret - Your app's API secret used as the HMAC key. + * @param signature - The HMAC-SHA256 signature delivered alongside the payload. + * @returns `true` when the signature matches the digest of `body`, otherwise `false`. + * @deprecated Use {@link verifySignature} instead — same logic, parameters reordered to match the + * cross-SDK contract (`verifySignature(body, signature, secret)`). */ export function CheckSignature(body: string | Buffer, secret: string, signature: string) { return verifySignature(body, signature, secret); @@ -150,14 +172,16 @@ export class InvalidWebhookError extends Error { } /** - * Returns `body` as a `Buffer`, gzip-decompressed when its first two - * bytes match the gzip magic (`1f 8b`, per RFC 1952). When the body is - * plain JSON (no compression, or middleware already decompressed), the - * bytes are returned unchanged. + * Returns `body` as a `Buffer`, gzip-decompressed when its first two bytes match the gzip magic + * (`1f 8b`, per RFC 1952). When the body is plain JSON (no compression, or middleware already + * decompressed), the bytes are returned unchanged. * - * Magic-byte detection (rather than relying on a header) keeps the - * same handler correct when middleware - Express, Next.js, AWS Lambda - * - auto-decompresses the request before your code sees it. + * Magic-byte detection (rather than relying on a header) keeps the same handler correct when + * middleware — Express, Next.js, AWS Lambda — auto-decompresses the request before your code sees it. + * + * @param rawBody - The raw HTTP request body, either as a string or a `Buffer`. + * @returns The uncompressed payload bytes. + * @throws {@link InvalidWebhookError} when the gzip envelope is malformed. */ export function gunzipPayload(rawBody: string | Buffer): Buffer { const GZIP_MAGIC = Buffer.from([0x1f, 0x8b]); @@ -174,13 +198,15 @@ export function gunzipPayload(rawBody: string | Buffer): Buffer { } /** - * Reverses the SQS firehose envelope: the message `Body` is - * base64-decoded, then the result is gzip-decompressed when it begins - * with the gzip magic. Returns the raw JSON `Buffer` Stream signed. + * Reverses the SQS firehose envelope: the message `Body` is base64-decoded, then the result is + * gzip-decompressed when it begins with the gzip magic. Returns the raw JSON `Buffer` Stream signed. + * + * SQS bodies are always base64-encoded so they remain valid UTF-8 over the queue. The same call + * works whether or not Stream is currently compressing payloads for this app. * - * SQS bodies are always base64-encoded so they remain valid UTF-8 over - * the queue. The same call works whether or not Stream is currently - * compressing payloads for this app. + * @param body - The base64-encoded SQS message body. + * @returns The decoded (and decompressed, when gzipped) payload bytes. + * @throws {@link InvalidWebhookError} when the body is not canonical base64 or the gzip envelope is malformed. */ export function decodeSqsPayload(body: string): Buffer { // Reject anything that isn't canonical base64 up front. Node's base64 @@ -199,12 +225,15 @@ export function decodeSqsPayload(body: string): Buffer { } /** - * Reverses an SNS HTTP notification envelope. When `notificationBody` - * is a JSON envelope (`{"Type":"Notification","Message":"..."}`), the - * inner `Message` field is extracted and run through the SQS pipeline - * (base64-decode, then gzip-if-magic). When the input is not a JSON - * envelope it is treated as the already-extracted `Message` string, - * so call sites that pre-unwrap continue to work. + * Reverses an SNS HTTP notification envelope. When `notificationBody` is a JSON envelope + * (`{"Type":"Notification","MessageRequest":"..."}`), the inner `MessageRequest` field is extracted and run + * through the SQS pipeline (base64-decode, then gzip-if-magic). When the input is not a JSON + * envelope it is treated as the already-extracted `MessageRequest` string, so call sites that pre-unwrap + * continue to work. + * + * @param notificationBody - The raw SNS notification body, or a pre-extracted `MessageRequest` string. + * @returns The decoded (and decompressed, when gzipped) payload bytes. + * @throws {@link InvalidWebhookError} when the body is not canonical base64 or the gzip envelope is malformed. */ export function decodeSnsPayload(notificationBody: string): Buffer { const inner = extractSnsMessage(notificationBody); @@ -226,28 +255,32 @@ function extractSnsMessage(notificationBody: string): string | null { parsed === null || typeof parsed !== 'object' || Array.isArray(parsed) || - typeof (parsed as { Message?: unknown }).Message !== 'string' + typeof (parsed as { MessageRequest?: unknown }).MessageRequest !== 'string' ) { return null; } - return (parsed as { Message: string }).Message; + return (parsed as { MessageRequest: string }).MessageRequest; } /** - * Parse a JSON-encoded webhook event into a typed {@link Event}. New - * event types Stream introduces still parse successfully - the runtime - * shape is the JSON Stream sent and the `type` field stays preserved. + * Parses a JSON-encoded webhook event into a typed {@link WSEvent}. New event types Stream + * introduces still parse successfully — the runtime shape is the JSON Stream sent and the `type` + * field stays preserved. + * + * @param payload - The raw event payload bytes or string. + * @returns The parsed WebSocket event. + * @throws {@link InvalidWebhookError} when the payload is not valid JSON. */ -export function parseEvent(payload: Buffer | string): Event { +export function parseEvent(payload: Buffer | string): WSEvent { const text = Buffer.isBuffer(payload) ? payload.toString('utf8') : payload; try { - return JSON.parse(text) as Event; + return JSON.parse(text) as WSEvent; } catch { throw new InvalidWebhookError(InvalidWebhookErrorMessages.invalidJson); } } -function verifyAndParse(payload: Buffer, signature: string, secret: string): Event { +function verifyAndParse(payload: Buffer, signature: string, secret: string): WSEvent { if (!verifySignature(payload, signature, secret)) { throw new InvalidWebhookError(InvalidWebhookErrorMessages.signatureMismatch); } @@ -255,36 +288,43 @@ function verifyAndParse(payload: Buffer, signature: string, secret: string): Eve } /** - * Decompress (when gzipped), verify the HMAC `signature`, and return - * the parsed {@link Event}. + * Decompress (when gzipped), verify the HMAC `signature`, and return the parsed {@link WSEvent}. * - * @param rawBody Raw HTTP request body bytes Stream signed - * @param signature Value of the `X-Signature` header - * @param secret Your app's API secret - * @throws {InvalidWebhookError} When the signature does not match or - * the gzip envelope is malformed. + * @param rawBody - Raw HTTP request body bytes Stream signed. + * @param signature - Value of the `X-Signature` header. + * @param secret - Your app's API secret. + * @returns The parsed WebSocket event. + * @throws {@link InvalidWebhookError} when the signature does not match or the gzip envelope is malformed. */ export function verifyAndParseWebhook( rawBody: string | Buffer, signature: string, secret: string, -): Event { +): WSEvent { return verifyAndParse(gunzipPayload(rawBody), signature, secret); } /** - * Decode the SQS message `Body` (base64, then gzip-if-magic) and return - * the parsed {@link Event}. Stream does not attach an application-level HMAC - * to SQS deliveries — use {@link verifyAndParseWebhook} for HTTP webhooks. + * Decodes the SQS message `Body` (base64, then gzip-if-magic) and returns the parsed {@link WSEvent}. + * Stream does not attach an application-level HMAC to SQS deliveries — use + * {@link verifyAndParseWebhook} for HTTP webhooks. + * + * @param messageBody - The base64-encoded SQS message body. + * @returns The parsed WebSocket event. + * @throws {@link InvalidWebhookError} when the body is malformed. */ -export function parseSqs(messageBody: string): Event { +export function parseSqs(messageBody: string): WSEvent { return parseEvent(decodeSqsPayload(messageBody)); } /** - * Decode an SNS notification (unwrap the JSON envelope when needed; same - * inner format as SQS). No application-level HMAC verification. + * Decodes an SNS notification (unwraps the JSON envelope when needed; same inner format as SQS). + * No application-level HMAC verification. + * + * @param notificationBody - The raw SNS notification body, or a pre-extracted `MessageRequest` string. + * @returns The parsed WebSocket event. + * @throws {@link InvalidWebhookError} when the body is malformed. */ -export function parseSns(notificationBody: string): Event { +export function parseSns(notificationBody: string): WSEvent { return parseEvent(decodeSnsPayload(notificationBody)); } diff --git a/src/store.ts b/src/store.ts index 70a018dd04..a786ea6f80 100644 --- a/src/store.ts +++ b/src/store.ts @@ -20,10 +20,11 @@ export class StateStore> { /** * Allows merging two stores only if their keys differ otherwise there's no way to ensure the data type stability. + * * @experimental * This method is experimental and may change in future versions. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any + public merge>( stateStore: Q extends StateStore ? Extract extends never diff --git a/src/thread.ts b/src/thread.ts index a1461da346..61201f06bc 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -1,15 +1,15 @@ import { StateStore } from './store'; -import { formatMessage } from './utils'; +import { formatMessage, localMessageToNewMessagePayload } from './utils'; import type { AscDesc, DraftResponse, EventAPIResponse, - EventTypes, + EventType, LocalMessage, - MarkReadOptions, + MarkReadRequest, MessageResponse, - ReadResponse, - ThreadResponse, + ReadStateResponse, + ThreadStateResponse, UserResponse, } from './types'; import type { @@ -24,6 +24,7 @@ import { MessageComposer } from './messageComposer'; import { MessageOperations } from './messageOperations'; import { WithSubscriptions } from './utils/WithSubscriptions'; import { MessagePaginator } from './pagination'; +import type { PipelineEvent } from './EventHandlerPipeline'; export type ThreadState = { /** @@ -42,7 +43,7 @@ export type ThreadState = { * We use parent message id as a thread id. */ parentMessage: LocalMessage; - participants: ThreadResponse['thread_participants']; + participants: ThreadStateResponse['thread_participants']; read: ThreadReadState; replyCount: number; title: string; @@ -62,48 +63,10 @@ export type ThreadReadState = Record; const DEFAULT_PAGE_LIMIT = 50; const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; const DEFAULT_ITEM_ORDER: { created_at: AscDesc } = { created_at: 1 }; -// TODO: remove this once we move to API v2 -export const THREAD_RESPONSE_RESERVED_KEYS: Record = { - active_participant_count: true, - channel: true, - channel_cid: true, - created_at: true, - created_by: true, - created_by_user_id: true, - deleted_at: true, - draft: true, - last_message_at: true, - latest_replies: true, - parent_message: true, - parent_message_id: true, - participant_count: true, - read: true, - reply_count: true, - thread_participants: true, - title: true, - updated_at: true, -}; - -// TODO: remove this once we move to API v2 -const constructCustomDataObject = (threadData: T) => { - const custom: CustomThreadData = {}; - - for (const key in threadData) { - if (THREAD_RESPONSE_RESERVED_KEYS[key as keyof ThreadResponse]) { - continue; - } - - const customKey = key as keyof CustomThreadData; - - custom[customKey] = threadData[customKey]; - } - - return custom; -}; export type CustomThreadMarkReadRequestFn = (params: { thread: Thread; - options?: MarkReadOptions; + options?: MarkReadRequest; }) => Promise | void; export type ThreadInstanceConfig = { @@ -131,20 +94,23 @@ export class Thread extends WithSubscriptions { draft, }: { client: StreamChat; - threadData?: ThreadResponse; + threadData?: ThreadStateResponse; channel?: Channel; parentMessage?: MessageResponse | LocalMessage; draft?: DraftResponse; }) { super(); if (threadData) { + if (!threadData.channel) { + throw new Error('Thread channel is required when threadData is provided'); + } + if (!threadData.parent_message) { + throw new Error('Thread parent_message is required when threadData is provided'); + } const threadChannel = client.channel( threadData.channel.type, threadData.channel.id, - { - // @ts-expect-error name is a "custom" property - name: threadData.channel.name, - }, + { custom: threadData.channel.custom }, ); threadChannel._hydrateMembers({ members: threadData.channel.members ?? [], @@ -176,7 +142,7 @@ export class Thread extends WithSubscriptions { replyCount: threadData.parent_message.reply_count ?? 0, updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null, title: threadData.title, - custom: constructCustomDataObject(threadData), + custom: threadData.custom ?? {}, }); this.id = threadData.parent_message_id; @@ -201,7 +167,7 @@ export class Thread extends WithSubscriptions { channel, createdAt, custom: {}, - deletedAt: formattedParentMessage.deleted_at, + deletedAt: formattedParentMessage.deleted_at ?? null, isLoading: false, isStateStale: false, parentMessage: formattedParentMessage, @@ -300,15 +266,19 @@ export class Thread extends WithSubscriptions { }, defaults: { delete: async (id, o) => { - const result = await this.channel.getClient().deleteMessage(id, o); + const result = await this.channel.getClient().deleteMessage({ id, ...o }); return { message: result.message }; }, send: async (m, o) => { - const result = await this.channel.sendMessage(m, o); + const result = await this.channel.sendMessage({ message: m, ...o }); return { message: result.message }; }, update: async (m, o) => { - const result = await this.channel.getClient().updateMessage(m, undefined, o); + const result = await this.channel.getClient().updateMessage({ + id: m.id, + message: localMessageToNewMessagePayload(m), + ...o, + }); return { message: result.message }; }, }, @@ -343,9 +313,8 @@ export class Thread extends WithSubscriptions { this.state.partialNext({ isLoading: true }); try { - const loadedReplyCount = - this.messagePaginator.state.getLatestValue().items?.length ?? 0; - const thread = await this.client.getThread(this.id, { + const loadedReplyCount = this.messagePaginator.items?.length ?? 0; + const thread = await this.client.getThreadAndHydrate(this.id, { watch: true, reply_limit: loadedReplyCount || this.messagePaginator.pageSize, }); @@ -397,9 +366,7 @@ export class Thread extends WithSubscriptions { isStateStale: false, }); - this.messagePaginator.mergeNewestPage( - thread.messagePaginator.state.getLatestValue().items ?? [], - ); + this.messagePaginator.mergeNewestPage(thread.messagePaginator.items ?? []); pendingReplies.forEach((reply) => this.messagePaginator.ingestItem(reply)); // Carry the re-queried thread's last-activity floor so lastMessageAt stays fresh even when the // merged page does not include the newest reply. Monotonic, so an older value is a no-op. @@ -436,8 +403,7 @@ export class Thread extends WithSubscriptions { title: threadData.title, updatedAt: new Date(threadData.updated_at), deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null, - // TODO: use threadData.custom once we move to API v2 - custom: constructCustomDataObject(threadData), + custom: threadData.custom ?? {}, }); }).unsubscribe; @@ -464,7 +430,7 @@ export class Thread extends WithSubscriptions { ); private subscribeMarkThreadStale = () => - this.client.on('user.watching.stop', (event) => { + this.client.on('user.watching.stop', (event: PipelineEvent) => { const { channel } = this.state.getLatestValue(); if ( @@ -516,7 +482,7 @@ export class Thread extends WithSubscriptions { this.upsertReplyLocally({ message: event.message, - // Message from current user could have been added optimistically, + // MessageRequest from current user could have been added optimistically, // so the actual timestamp might differ in the event timestampChanged: isOwnMessage, }); @@ -621,8 +587,8 @@ export class Thread extends WithSubscriptions { }).unsubscribe; private subscribeMessageUpdated = () => { - const messageUpdateTypes: EventTypes[] = ['message.updated', 'message.undeleted']; - const reactionTypes: EventTypes[] = [ + const messageUpdateTypes: EventType[] = ['message.updated', 'message.undeleted']; + const reactionTypes: EventType[] = [ 'reaction.new', 'reaction.deleted', 'reaction.updated', @@ -630,7 +596,7 @@ export class Thread extends WithSubscriptions { const unsubscribeMessageUpdated = messageUpdateTypes.map( (eventType) => - this.client.on(eventType, (event) => { + this.client.on(eventType, (event: PipelineEvent) => { if (!event.message) return; // A `message.updated` WS event carries `own_reactions: []`; upserting it verbatim would // wipe the current user's reactions on a reply edit. The reply paginator is this thread's @@ -652,7 +618,7 @@ export class Thread extends WithSubscriptions { const unsubscribeReactions = reactionTypes.map( (eventType) => - this.client.on(eventType, (event) => { + this.client.on(eventType, (event: PipelineEvent) => { if (!event.message || !event.reaction) return; const { message, reaction } = event; if (message.parent_id === this.id) { @@ -680,11 +646,11 @@ export class Thread extends WithSubscriptions { // Apply a user ban / deletion to this thread's own reply list. Previously // channel.state.deleteUserMessages marked banned-user replies deleted in the (now removed) // channel.state.threads shadow; the reply paginator is the thread's source of truth now. - const eventTypes: EventTypes[] = ['user.messages.deleted', 'user.deleted']; + const eventTypes: EventType[] = ['user.messages.deleted', 'user.deleted']; const unsubscribeFunctions = eventTypes.map( (eventType) => - this.client.on(eventType, (event) => { + this.client.on(eventType, (event: PipelineEvent) => { if (!event.user) return; // user.deleted carries the deletion time on the user; user.messages.deleted on the event. const deletedAtSource = @@ -729,7 +695,7 @@ export class Thread extends WithSubscriptions { const formattedMessage = formatMessage(message); // todo: do we really need to keep the failedRepliesMap? - if (message.status === 'failed') { + if (formattedMessage.status === 'failed') { // store failed reply so that it's not lost when reloading or hydrating this.failedRepliesMap.set(formattedMessage.id, formattedMessage); } else if (this.failedRepliesMap.has(message.id)) { @@ -743,7 +709,7 @@ export class Thread extends WithSubscriptions { // todo: can be removed with the next breaking change and use MessagePaginator only public updateParentMessageLocally = ({ message }: { message: MessageResponse }) => { if (message.id !== this.id) { - throw new Error('Message does not belong to this thread'); + throw new Error('MessageRequest does not belong to this thread'); } this.state.next((current) => { @@ -751,7 +717,7 @@ export class Thread extends WithSubscriptions { return { ...current, - deletedAt: formattedMessage.deleted_at, + deletedAt: formattedMessage.deleted_at ?? null, parentMessage: formattedMessage, participants: normalizeThreadParticipants(message.thread_participants, current.channel.cid) ?? @@ -856,28 +822,29 @@ export class Thread extends WithSubscriptions { type MessageThreadParticipant = NonNullable< MessageResponse['thread_participants'] >[number]; -type ThreadParticipant = NonNullable[number]; +type ThreadParticipant = NonNullable[number]; const normalizeThreadParticipants = ( participants: MessageResponse['thread_participants'] | undefined, channelCid: string, -): ThreadResponse['thread_participants'] | undefined => { +): ThreadStateResponse['thread_participants'] | undefined => { if (!participants) return undefined; - const nowIso = new Date().toISOString(); + const now = new Date(); return participants.map( - (participant: MessageThreadParticipant): ThreadParticipant => ({ - channel_cid: channelCid, - created_at: nowIso, - last_read_at: nowIso, - user: participant, - user_id: participant.id, - }), + (participant: MessageThreadParticipant) => + ({ + channel_cid: channelCid, + created_at: now, + last_read_at: now, + user: participant as UserResponse, + user_id: participant.id, + }) as ThreadParticipant, ); }; -const formatReadState = (read: ReadResponse[]): ThreadReadState => +const formatReadState = (read: ReadStateResponse[]): ThreadReadState => read.reduce((state, userRead) => { state[userRead.user.id] = { user: userRead.user, @@ -888,13 +855,13 @@ const formatReadState = (read: ReadResponse[]): ThreadReadState => return state; }, {}); -const getPlaceholderReadResponse = (currentUserId?: string): ReadResponse[] => +const getPlaceholderReadResponse = (currentUserId?: string): ReadStateResponse[] => currentUserId ? [ { - user: { id: currentUserId }, + user: { id: currentUserId } as UserResponse, unread_messages: 0, - last_read: new Date().toISOString(), + last_read: new Date(), }, ] : []; diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 702d6720d5..0b7d5bb194 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -1,15 +1,26 @@ +import { chatLoggerSystem } from './logger'; import { StateStore } from './store'; import { throttle } from './utils'; import type { StreamChat } from './client'; import type { Thread } from './thread'; -import type { Event, OwnUserResponse, QueryThreadsOptions } from './types'; +import type { + Event, + EventPayload, + EventType, + OwnUserResponse, + QueryThreadsRequest, +} from './types'; import { WithSubscriptions } from './utils/WithSubscriptions'; +const eventIsHealthCheck = (event: Event): event is EventPayload<'health.check'> => + Object.hasOwn(event, 'me'); + const DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION = 1000; const MAX_QUERY_THREADS_LIMIT = 25; export const THREAD_MANAGER_INITIAL_STATE = { active: false, + wasActivatedAtLeastOnce: false, isThreadOrderStale: false, threads: [], unreadThreadCount: 0, @@ -25,6 +36,12 @@ export const THREAD_MANAGER_INITIAL_STATE = { export type ThreadManagerState = { active: boolean; + /** + * Whether the thread manager has been activated at least once in the current + * session (i.e. `activate()` was called). Used to avoid requerying threads + * on connection recovery for consumers that never actually activate the manager. + */ + wasActivatedAtLeastOnce: boolean; isThreadOrderStale: boolean; lastConnectionDropAt: Date | null; pagination: ThreadManagerPagination; @@ -44,6 +61,8 @@ export type ThreadManagerPagination = { nextCursor: string | null; }; +const logger = chatLoggerSystem.getLogger('thread-manager'); + export class ThreadManager extends WithSubscriptions { public readonly state: StateStore; private client: StreamChat; @@ -90,7 +109,7 @@ export class ThreadManager extends WithSubscriptions { }; public activate = () => { - this.state.partialNext({ active: true }); + this.state.partialNext({ active: true, wasActivatedAtLeastOnce: true }); }; public deactivate = () => { @@ -114,16 +133,21 @@ export class ThreadManager extends WithSubscriptions { (this.client.user as OwnUserResponse) ?? {}; this.state.partialNext({ unreadThreadCount }); - const unsubscribeFunctions = [ - 'health.check', - 'notification.mark_read', - 'notification.mark_unread', - 'notification.thread_message_new', - 'notification.channel_deleted', - ].map( + const unsubscribeFunctions = ( + [ + 'health.check', + 'notification.mark_read', + 'notification.mark_unread', + 'notification.thread_message_new', + 'notification.channel_deleted', + ] as const satisfies EventType[] + ).map( (eventType) => this.client.on(eventType, (event) => { - const { unread_threads: unreadThreadCount } = event.me ?? event; + const { unread_threads: unreadThreadCount } = + (eventIsHealthCheck(event) && event.me) || + (event as Extract); + if (typeof unreadThreadCount === 'number') { this.state.partialNext({ unreadThreadCount }); } @@ -167,7 +191,7 @@ export class ThreadManager extends WithSubscriptions { ); private subscribeNewReplies = () => - this.client.on('notification.thread_message_new', (event: Event) => { + this.client.on('notification.thread_message_new', (event) => { const parentId = event.message?.parent_id; if (!parentId) return; @@ -197,8 +221,9 @@ export class ThreadManager extends WithSubscriptions { const throttledHandleConnectionRecovered = throttle( () => { - const { lastConnectionDropAt } = this.state.getLatestValue(); - if (!lastConnectionDropAt) return; + const { lastConnectionDropAt, wasActivatedAtLeastOnce } = + this.state.getLatestValue(); + if (!lastConnectionDropAt || !wasActivatedAtLeastOnce) return; this.reload({ force: true }); }, DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION, @@ -272,7 +297,9 @@ export class ThreadManager extends WithSubscriptions { ready: true, })); } catch (error) { - this.client.logger('error', (error as Error).message); + logger + .withExtraTags('reload') + .error('Failed to reload the thread list.', { error }); this.state.next((current) => ({ ...current, pagination: { @@ -283,8 +310,8 @@ export class ThreadManager extends WithSubscriptions { } }; - public queryThreads = (options: QueryThreadsOptions = {}) => - this.client.queryThreads({ + public queryThreads = (options: QueryThreadsRequest = {}) => + this.client.queryThreadsAndHydrate({ limit: 25, participant_limit: 10, reply_limit: 10, @@ -292,7 +319,7 @@ export class ThreadManager extends WithSubscriptions { ...options, }); - public loadNextPage = async (options: Omit = {}) => { + public loadNextPage = async (options: Omit = {}) => { const { pagination } = this.state.getLatestValue(); if (pagination.isLoadingNext || !pagination.nextCursor) return; @@ -317,7 +344,9 @@ export class ThreadManager extends WithSubscriptions { }, })); } catch (error) { - this.client.logger('error', (error as Error).message); + logger + .withExtraTags('loadNextPage') + .error('Failed to load the next page of threads.', { error }); this.state.next((current) => ({ ...current, pagination: { diff --git a/src/token_manager.ts b/src/token_manager.ts index 617e7ce2ba..9af2fb74e7 100644 --- a/src/token_manager.ts +++ b/src/token_manager.ts @@ -1,9 +1,13 @@ import type jwt from 'jsonwebtoken'; +import { chatLoggerSystem } from './logger'; import { JWTServerToken, JWTUserToken, UserFromToken } from './signing'; import { isFunction } from './utils'; -import type { TokenOrProvider, UserResponse } from './types'; +import type { TokenOrProvider } from './types'; +const logger = chatLoggerSystem.getLogger('token-manager'); + +export type TokenManagerMinimalUser = { id: string; anon?: boolean }; /** * TokenManager * @@ -15,11 +19,12 @@ export class TokenManager { secret?: jwt.Secret; token?: string; tokenProvider?: TokenOrProvider; - user?: UserResponse; + user?: TokenManagerMinimalUser; /** - * Constructor + * Initializes the token manager, optionally with a server-side API secret used to mint tokens + * locally. * - * @param {Secret} secret + * @param secret - Optional API secret. When provided, the manager will sign server tokens locally. */ constructor(secret?: jwt.Secret) { this.loadTokenPromise = null; @@ -35,13 +40,16 @@ export class TokenManager { } /** - * Set the static string token or token provider. - * Token provider should return a token string or a promise which resolves to string token. + * Sets the static string token or token provider. A token provider should return a token string + * or a promise that resolves to a token string. * - * @param {TokenOrProvider} tokenOrProvider - * @param {UserResponse} user + * @param tokenOrProvider - A token string or an async provider that returns one. + * @param user - The user the token belongs to. */ - setTokenOrProvider = async (tokenOrProvider: TokenOrProvider, user: UserResponse) => { + setTokenOrProvider = async ( + tokenOrProvider: TokenOrProvider, + user: TokenManagerMinimalUser, + ) => { this.validateToken(tokenOrProvider, user); this.user = user; @@ -76,7 +84,7 @@ export class TokenManager { }; // Validates the user token. - validateToken = (tokenOrProvider: TokenOrProvider, user: UserResponse) => { + validateToken = (tokenOrProvider: TokenOrProvider, user: TokenManagerMinimalUser) => { // allow empty token for anon user if (user && user.anon && !tokenOrProvider) return; @@ -126,6 +134,9 @@ export class TokenManager { try { this.token = await this.tokenProvider(); } catch (e) { + logger + .withExtraTags('loadToken') + .error('The token provider threw an error.', { error: e }); return reject( new Error(`Call to tokenProvider failed with message: ${e}`, { cause: e }), ); diff --git a/src/types.ts b/src/types.ts index 5b77f5448f..7dff83763f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,65 +1,74 @@ -import type { EVENT_MAP } from './events'; import type { Channel } from './channel'; import type { AxiosRequestConfig, AxiosResponse } from 'axios'; import type { StableWSConnection } from './connection'; -import type { Role } from './permissions'; import type { - CustomAttachmentData, CustomChannelData, CustomCommandData, - CustomEventData, CustomEventTypes, - CustomMemberData, - CustomMessageData, - CustomPollData, - CustomPollOptionData, - CustomReactionData, - CustomThreadData, - CustomUserData, } from './custom_types'; import type { NotificationManager } from './notifications'; import type { RESERVED_UPDATED_MESSAGE_FIELDS } from './constants'; +import type { + APIError, + Attachment, + AutomodDetailsResponse, + ChannelConfigWithInfo, + ChannelInput, + ChannelMemberResponse, + ChannelMute, + ChannelOwnCapability, + ChannelResponse, + ChannelStateResponseFields, + CreateDeviceRequest, + DraftPayloadResponse, + Images, + MessageResponse, + ModerationPayload, + OwnUserResponse, + PollResponseData, + PollVoteResponseData, + PrivacySettingsResponse, + PushPreferencesResponse, + QueryChannelsRequest, + QueryFilters, + QueryMembersPayload, + QueryThreadsRequest, + QueryUsersPayload, + ReactionResponse, + ReminderResponseData, + RequireAtLeastOne, + SearchPayload, + SearchWarning, + SendMessageRequest, + SendMessageResponse, + SharedLocation, + SharedLocationResponseData, + SortParamRequest, + TranslateMessageRequest, + UpdateChannelRequest, + UpdateMessageRequest, + UpdateMessageResponse, + UpdatePollOptionRequest, + UpdatePollRequest, + UserMuteResponse, + UserResponse, + WSEvent, +} from './gen/models'; + +import type { ChatApi } from './gen-imports'; /** * Utility Types */ - -export type Readable = { - [key in keyof T]: T[key]; -} & {}; - -export type ArrayOneOrMore = { - 0: T; -} & Array; - -export type ArrayTwoOrMore = { - 0: T; - 1: T; -} & Array; - -export type KnownKeys = { - [K in keyof T]: string extends K ? never : number extends K ? never : K; -} extends { [_ in keyof T]: infer U } - ? U - : never; - -export type RequireAtLeastOne = { - [K in keyof T]-?: Required> & Partial>; -}[keyof T]; - export type RequireOnlyOne = Omit & { [K in Keys]-?: Required> & Partial, undefined>>; }[Keys]; -export type PartializeKeys = Partial> & Omit; - -/* Unknown Record */ export type UR = Record; -export type UnknownType = UR; //alias to avoid breaking change export type Unpacked = T extends (infer U)[] - ? U // eslint-disable-next-line @typescript-eslint/no-explicit-any + ? U : T extends (...args: any[]) => infer U ? U : T extends Promise @@ -72,166 +81,10 @@ export type Unpacked = T extends (infer U)[] export type APIResponse = { duration: string; - blocklist?: BlockListResponse; -}; - -export type TranslateResponse = { - language: string; - translated_text: string; -}; - -export type AppSettingsAPIResponse = APIResponse & { - app?: { - id?: string | number; - allow_multi_user_devices?: boolean; - feed_audit_logs_enabled?: boolean; - moderation_onboarding_complete?: boolean | null; - // TODO - // eslint-disable-next-line @typescript-eslint/no-explicit-any - call_types: any; - channel_configs: Record< - string, - { - reminders: boolean; - automod?: ChannelConfigAutomod; - automod_behavior?: ChannelConfigAutomodBehavior; - automod_thresholds?: ChannelConfigAutomodThresholds; - blocklist_behavior?: ChannelConfigAutomodBehavior; - commands?: CommandVariants[]; - connect_events?: boolean; - created_at?: string; - custom_events?: boolean; - delivery_events?: boolean; - mark_messages_pending?: boolean; - max_message_length?: number; - message_retention?: string; - mutes?: boolean; - name?: string; - polls?: boolean; - push_notifications?: boolean; - quotes?: boolean; - reactions?: boolean; - read_events?: boolean; - replies?: boolean; - search?: boolean; - shared_locations?: boolean; - skip_last_msg_update_for_system_msgs?: boolean; - count_messages?: boolean; - typing_events?: boolean; - updated_at?: string; - uploads?: boolean; - url_enrichment?: boolean; - user_message_reminders?: boolean; - push_level?: - | 'all' - | 'all_mentions' - | 'direct_mentions' - | 'mentions' - | 'none' - | ''; - } - >; - reminders_interval: number; - async_moderation_config?: AsyncModerationOptions; - async_url_enrich_enabled?: boolean; - auto_translation_enabled?: boolean; - before_message_send_hook_url?: string; - before_message_send_hook_attempt_timeout_ms?: number; - campaign_enabled?: boolean; - cdn_expiration_seconds?: number; - custom_action_handler_url?: string; - datadog_info?: { - api_key: string; - site: string; - enabled?: boolean; - }; - disable_auth_checks?: boolean; - disable_permissions_checks?: boolean; - enforce_unique_usernames?: 'no' | 'app' | 'team'; - event_hooks?: Array; - file_upload_config?: FileUploadConfig; - geofences?: Array<{ - country_codes: Array; - description: string; - name: string; - type: string; - }>; - grants?: Record; - guest_user_creation_disabled?: boolean; - image_moderation_enabled?: boolean; - image_moderation_labels?: string[]; - image_upload_config?: FileUploadConfig; - allowed_flag_reasons?: string[]; - max_aggregated_activities_length?: number; - moderation_bulk_submit_action_enabled?: boolean; - moderation_dashboard_preferences?: Record | null; - moderation_audio_call_moderation_enabled?: boolean; - moderation_enabled?: boolean; - moderation_llm_configurability_enabled?: boolean; - moderation_multitenant_blocklist_enabled?: boolean; - moderation_video_call_moderation_enabled?: boolean; - moderation_webhook_url?: string; - multi_tenant_enabled?: boolean; - name?: string; - organization?: string; - permission_version?: string; - /** - * The placement of the app in the form of `${region}.${shard}`. - * Examples: "us-east.c1", "dublin.c3", "singapore.c2" - * Note: The backend may add/remove regions or shards occasionally. - */ - placement?: string; - policies?: Record; - poll_enabled?: boolean; - push_notifications?: { - offline_only: boolean; - version: string; - apn?: APNConfig; - firebase?: FirebaseConfig; - huawei?: HuaweiConfig; - providers?: PushProviderConfig[]; - xiaomi?: XiaomiConfig; - }; - revoke_tokens_issued_before?: string | null; - search_backend?: 'disabled' | 'elasticsearch' | 'postgres'; - sns_key?: string; - sns_secret?: string; - sns_topic_arn?: string; - sqs_key?: string; - sqs_secret?: string; - sqs_url?: string; - suspended?: boolean; - suspended_explanation?: string; - use_hook_v2?: boolean; - user_response_time_enabled?: boolean; - user_search_disallowed_roles?: string[] | null; - video_provider?: string; - webhook_events?: Array; - webhook_url?: string; - }; -}; - -export type ModerationResult = { - action: string; - created_at: string; - message_id: string; - updated_at: string; - user_bad_karma: boolean; - user_karma: number; - blocked_word?: string; - blocklist_name?: string; - moderated_by?: string; -}; - -export type AutomodDetails = { - action?: string; - image_labels?: Array; - original_message_type?: string; - result?: ModerationResult; }; export type FlagDetails = { - automod?: AutomodDetails; + automod?: AutomodDetailsResponse; }; export type Flag = { @@ -244,264 +97,10 @@ export type Flag = { user?: UserResponse; }; -export type FlagsResponse = APIResponse & { - flags?: Array; -}; - -export type MessageFlagsResponse = APIResponse & { - flags?: Array<{ - message: MessageResponse; - user: UserResponse; - approved_at?: string; - created_at?: string; - created_by_automod?: boolean; - moderation_result?: ModerationResult; - rejected_at?: string; - reviewed_at?: string; - reviewed_by?: UserResponse; - updated_at?: string; - }>; -}; - -export type FlagReport = { - flags_count: number; - id: string; - message: MessageResponse; - user: UserResponse; - created_at?: string; - details?: FlagDetails; - first_reporter?: UserResponse; - review_result?: string; - reviewed_at?: string; - reviewed_by?: UserResponse; - updated_at?: string; -}; - -export type FlagReportsResponse = APIResponse & { - flag_reports: Array; -}; - -export type ReviewFlagReportResponse = APIResponse & { - flag_report: FlagReport; -}; - -export type BannedUsersResponse = APIResponse & { - bans?: Array<{ - user: UserResponse; - banned_by?: UserResponse; - channel?: ChannelResponse; - expires?: string; - ip_ban?: boolean; - reason?: string; - timeout?: number; - }>; -}; - -export type FutureChannelBan = { - user: UserResponse; - expires?: string; - reason?: string; - shadow?: boolean; - created_at: string; -}; - -export type FutureChannelBansResponse = APIResponse & { - bans: FutureChannelBan[]; -}; - -export type QueryFutureChannelBansOptions = { - user_id?: string; - target_user_id?: string; - exclude_expired_bans?: boolean; - limit?: number; - offset?: number; -}; - -export type BlockListResponse = BlockList & { - created_at?: string; - type?: string; - updated_at?: string; -}; - -export type ChannelResponse = CustomChannelData & { - cid: string; - disabled: boolean; - frozen: boolean; - id: string; - type: string; - blocked?: boolean; - auto_translation_enabled?: boolean; - auto_translation_language?: TranslationLanguages; - hide_messages_before?: string; - config?: ChannelConfigWithInfo; - cooldown?: number; - created_at?: string; - created_by?: UserResponse | null; - created_by_id?: string; - deleted_at?: string; - filter_tags?: string[]; - hidden?: boolean; - invites?: string[]; - joined?: boolean; - last_message_at?: string; - member_count?: number; - members?: ChannelMemberResponse[]; - message_count?: number; - muted?: boolean; - mute_expires_at?: string; - own_capabilities?: string[]; - team?: string; - truncated_at?: string; - truncated_by?: UserResponse; - truncated_by_id?: string; - updated_at?: string; -}; - -export type QueryReactionsOptions = Pager; - -export type QueryReactionsAPIResponse = APIResponse & { - reactions: ReactionResponse[]; - next?: string; -}; - -export type QueryChannelsAPIResponse = APIResponse & { - channels: Omit[]; - predefined_filter?: ParsedPredefinedFilterResponse; -}; - -export type QueryChannelAPIResponse = APIResponse & ChannelAPIResponse; - -export type ChannelAPIResponse = { - channel: ChannelResponse; - members: ChannelMemberResponse[]; - messages: MessageResponse[]; - pinned_messages: MessageResponse[]; - draft?: DraftResponse; - hidden?: boolean; - membership?: ChannelMemberResponse | null; - pending_messages?: PendingMessageResponse[]; - push_preferences?: ChannelPushPreference; - read?: ReadResponse[]; - threads?: ThreadResponse[]; - watcher_count?: number; - watchers?: UserResponse[]; - active_live_locations?: SharedLocationResponse[]; -}; - -export type ChannelUpdateOptions = { - hide_history?: boolean; - hide_history_before?: string | Date; - skip_push?: boolean; -}; - -export type ChannelMemberAPIResponse = APIResponse & { - members: ChannelMemberResponse[]; -}; - -export type ChannelMemberUpdates = CustomMemberData & { - archived?: boolean; - channel_role?: Role; - pinned?: boolean; -}; - -export type ChannelMemberResponse = CustomMemberData & { - archived_at?: string | null; - ban_expires?: string; - banned?: boolean; - channel_role?: Role; - created_at?: string; - invite_accepted_at?: string; - invite_rejected_at?: string; - invited?: boolean; - is_moderator?: boolean; - notifications_muted?: boolean; - pinned_at?: string | null; - role?: string; - shadow_banned?: boolean; - status?: InviteStatus; - updated_at?: string; - user?: UserResponse; - user_id?: string; -}; - -export type PartialUpdateMemberAPIResponse = APIResponse & { - channel_member: ChannelMemberResponse; -}; - -export type CheckPushResponse = APIResponse & { - device_errors?: { - [deviceID: string]: { - error_message?: string; - provider?: PushProvider; - provider_name?: string; - }; - }; - general_errors?: string[]; - rendered_apn_template?: string; - rendered_firebase_template?: string; - rendered_message?: {}; - skip_devices?: boolean; -}; - -export type CheckSQSResponse = APIResponse & { - status: string; - data?: {}; - error?: string; -}; - -export type CheckSNSResponse = APIResponse & { - status: string; - data?: {}; - error?: string; -}; - -export type CommandResponse = Partial & { - args?: string; - description?: string; - name?: CommandVariants; - set?: CommandVariants; -}; +export type ChannelUpdateOptions = Omit; export type ConnectAPIResponse = Promise; -export type CreateChannelResponse = APIResponse & - Omit & { - created_at: string; - updated_at: string; - grants?: Record; - }; - -export type CreateCommandResponse = APIResponse & { - command: CreateCommandOptions & CreatedAtUpdatedAt; -}; - -export type DeleteChannelAPIResponse = APIResponse & { - channel: ChannelResponse; -}; - -export type DeleteCommandResponse = APIResponse & { - name?: CommandVariants; -}; - -export type EventAPIResponse = APIResponse & { - event: Event; -}; - -export type ExportChannelResponse = { - task_id: string; -}; - -export type ExportUsersResponse = { - task_id: string; -}; - -export type ExportChannelStatusResponse = { - created_at?: string; - error?: {}; - result?: {}; - updated_at?: string; -}; - export type FlagMessageResponse = APIResponse & { flag: { created_at: string; @@ -536,107 +135,19 @@ export type FlagUserResponse = APIResponse & { review_queue_item_id?: string; }; -export type LocalMessageBase = Omit< - MessageResponseBase, - 'created_at' | 'deleted_at' | 'pinned_at' | 'status' | 'updated_at' -> & { - created_at: Date; - deleted_at: Date | null; - pinned_at: Date | null; +export type LocalMessage = MessageResponse & { status: string; - updated_at: Date; -}; - -export type LocalMessage = LocalMessageBase & { - error?: ErrorFromResponse | null; - quoted_message?: LocalMessageBase | null; + error?: StreamAPIError; + user_id?: string; }; -/** - * @deprecated in favor of LocalMessage - */ -export type FormatMessageResponse = LocalMessage; - -export type GetCommandResponse = APIResponse & CreateCommandOptions & CreatedAtUpdatedAt; - -export type GetMessageAPIResponse = SendMessageAPIResponse; - -export interface ThreadResponse extends CustomThreadData { - // FIXME: according to OpenAPI, `channel` could be undefined but since cid is provided I'll asume that it's wrong - channel: ChannelResponse; - channel_cid: string; - created_at: string; - created_by_user_id: string; - latest_replies: Array; - parent_message: MessageResponse; - parent_message_id: string; - title: string; - updated_at: string; - active_participant_count?: number; - created_by?: UserResponse; - deleted_at?: string; - draft?: DraftResponse; - last_message_at?: string; - participant_count?: number; - read?: Array; - reply_count?: number; - thread_participants?: Array<{ - channel_cid: string; - created_at: string; - last_read_at: string; - last_thread_message_at?: string; - left_thread_at?: string; - thread_id?: string; - user?: UserResponse; - user_id?: string; - }>; - // TODO: when moving to API v2 we should do this instead - // custom: CustomThreadType; -} - // TODO: Figure out a way to strongly type set and unset. export type PartialThreadUpdate = { set?: Partial>; unset?: Array; }; -export type QueryThreadsOptions = { - filter?: ThreadFilters; - limit?: number; - member_limit?: number; - next?: string; - participant_limit?: number; - reply_limit?: number; - sort?: ThreadSort; - watch?: boolean; -}; - -export type QueryThreadsAPIResponse = APIResponse & { - threads: ThreadResponse[]; - next?: string; -}; - -export type GetThreadOptions = { - member_limit?: number; - participant_limit?: number; - reply_limit?: number; - watch?: boolean; -}; - -export type GetThreadAPIResponse = APIResponse & { - thread: ThreadResponse; -}; - -export type GetMultipleMessagesAPIResponse = APIResponse & { - messages: MessageResponse[]; -}; - -export type GetRateLimitsResponse = APIResponse & { - android?: RateLimitsMap; - ios?: RateLimitsMap; - server_side?: RateLimitsMap; - web?: RateLimitsMap; -}; +export type GetThreadOptions = Omit[0], 'message_id'>; export enum Product { Chat = 'chat', @@ -645,229 +156,13 @@ export enum Product { Feeds = 'feeds', } -export type HookEvent = { - name: string; - description: string; - products: Product[]; -}; - -export type GetHookEventsResponse = APIResponse & { - events: HookEvent[]; -}; - -export type GetReactionsAPIResponse = APIResponse & { - reactions: ReactionResponse[]; -}; - export type GetRepliesAPIResponse = APIResponse & { messages: MessageResponse[]; }; -export type GetUnreadCountAPIResponse = APIResponse & { - channel_type: { - channel_count: number; - channel_type: string; - unread_count: number; - }[]; - channels: { - channel_id: string; - last_read: string; - unread_count: number; - }[]; - threads: { - last_read: string; - last_read_message_id: string; - parent_message_id: string; - unread_count: number; - }[]; - total_unread_count: number; - total_unread_threads_count: number; - total_unread_count_by_team?: Record; -}; - -export type ChatLevelPushPreference = - | 'all' - | 'mentions' // deprecated by the API in favor of 'direct_mentions' - | 'direct_mentions' - | 'all_mentions' - | 'none' - | 'default' - | (string & {}); - -export type CallLevelPushPreference = 'all' | 'none' | 'default' | (string & {}); - -/** Granular all/none toggle used by the chat sub-preferences. */ -export type PushPreferenceLevel = 'all' | 'none' | (string & {}); - -/** Per-mention-type chat push preferences (matches OpenAPI `ChatPreferencesInput`). */ -export type ChatPreferences = { - channel_mentions?: PushPreferenceLevel; - default_preference?: PushPreferenceLevel; - direct_mentions?: PushPreferenceLevel; - group_mentions?: PushPreferenceLevel; - here_mentions?: PushPreferenceLevel; - role_mentions?: PushPreferenceLevel; - thread_replies?: PushPreferenceLevel; -}; - -/** - * Input accepted by {@link StreamChat.setPushPreferences} (matches OpenAPI `PushPreferenceInput`). - * - * Set `channel_cid` to scope the preference to a single channel; leave it empty to - * set the user-level default. `user_id` is required for server-side auth and - * defaults to the connected user for client-side auth. - */ -export type PushPreference = { - call_level?: CallLevelPushPreference; - channel_cid?: string; - chat_level?: ChatLevelPushPreference; - chat_preferences?: ChatPreferences; - disabled_until?: string; // snooze until this time - remove_disable?: boolean; // stop snoozing (clears disabled_until) - user_id?: string; -}; - -/** Per-user push preferences returned by the API (matches OpenAPI `PushPreferencesResponse`). */ -export type PushPreferencesResponse = { - call_level?: CallLevelPushPreference; - chat_level?: ChatLevelPushPreference; - chat_preferences?: ChatPreferences; - disabled_until?: string; -}; - -/** Per-channel push preferences returned by the API (matches OpenAPI `ChannelPushPreferencesResponse`). */ -export type ChannelPushPreference = { - chat_level?: ChatLevelPushPreference; // "all", "mentions", "direct_mentions", "all_mentions", "none", "default" or other custom strings - disabled_until?: string; -}; - -export type UpsertPushPreferencesResponse = APIResponse & { - // Mapping of user id -> channel cid -> channel push preferences - user_channel_preferences: Record>; - // Mapping of user id -> user push preferences - user_preferences: Record; -}; - -export type GetUnreadCountBatchAPIResponse = APIResponse & { - counts_by_user: { [userId: string]: GetUnreadCountAPIResponse }; -}; - -export type ListChannelResponse = APIResponse & { - channel_types: Record< - string, - Omit & { - commands: CommandResponse[]; - created_at: string; - updated_at: string; - grants?: Record; - } - >; -}; - -export type ListChannelTypesAPIResponse = ListChannelResponse; - -export type ListCommandsResponse = APIResponse & { - commands: Array>; -}; - -export type MuteChannelAPIResponse = APIResponse & { - channel_mute: ChannelMute; - own_user: OwnUserResponse; - channel_mutes?: ChannelMute[]; - mute?: MuteResponse; -}; - -export type MessageResponse = MessageResponseBase & { - quoted_message?: MessageResponseBase; -}; - -export type MessageResponseBase = MessageBase & { - type: MessageLabel; - args?: string; - before_message_send_failed?: boolean; - channel?: ChannelResponse; - cid?: string; - command?: string; - command_info?: { name?: string }; - created_at?: string; - deleted_at?: string; - deleted_reply_count?: number; - i18n?: RequireAtLeastOne> & { - language: TranslationLanguages; - }; - latest_reactions?: ReactionResponse[]; - member?: ChannelMemberResponse; - mentioned_users?: UserResponse[]; - mentioned_channel?: boolean; - mentioned_here?: boolean; - mentioned_group_ids?: string[]; - mentioned_groups?: UserGroupResponse[]; - mentioned_roles?: string[]; - message_text_updated_at?: string; - moderation?: ModerationResponse; // present only with Moderation v2 - moderation_details?: ModerationDetailsResponse; // present only with Moderation v1 - own_reactions?: ReactionResponse[] | null; - pin_expires?: string | null; - pinned_at?: string | null; - pinned_by?: UserResponse | null; - poll?: PollResponse; - reaction_counts?: { [key: string]: number } | null; - reaction_groups?: { [key: string]: ReactionGroupResponse } | null; - reaction_scores?: { [key: string]: number } | null; - reminder?: ReminderResponseBase; - reply_count?: number; - shadowed?: boolean; - shared_location?: SharedLocationResponse; - status?: string; - thread_participants?: UserResponse[]; - updated_at?: string; - deleted_for_me?: boolean; -}; - -export type ReactionGroupResponse = { - count: number; - sum_scores: number; - first_reaction_at?: string; - last_reaction_at?: string; - latest_reactions_by?: ReactionGroupUserResponse[]; -}; - -export type ReactionGroupUserResponse = { - created_at: string; - user_id: string; - user?: UserResponse; -}; - -export type ModerationDetailsResponse = { - action: 'MESSAGE_RESPONSE_ACTION_BOUNCE' | (string & {}); - error_msg: string; - harms: ModerationHarmResponse[]; - original_text: string; -}; - -export type ModerationHarmResponse = { - name: string; - phrase_list_ids: number[]; -}; - -export type ModerationAction = 'bounce' | 'flag' | 'remove' | 'shadow'; - -export type ModerationResponse = { - action: ModerationAction; - original_text: string; -}; - -export type MuteResponse = { - user: UserResponse; - created_at?: string; - expires?: string; - target?: UserResponse; - updated_at?: string; -}; - export type MuteUserResponse = APIResponse & { - mute?: MuteResponse; - mutes?: Array; + mute?: UserMuteResponse; + mutes?: Array; own_user?: OwnUserResponse; non_existing_users?: string[]; }; @@ -876,74 +171,26 @@ export type UnmuteUserResponse = APIResponse & { non_existing_users?: string[]; }; -export type BlockUserAPIResponse = APIResponse & { - blocked_at: string; - blocked_by_user_id: string; - blocked_user_id: string; -}; - -export type GetBlockedUsersAPIResponse = APIResponse & { - blocks: BlockedUserDetails[]; -}; - -export type BlockedUserDetails = APIResponse & { - blocked_user: UserResponse; - blocked_user_id: string; - created_at: string; - user: UserResponse; - user_id: string; -}; - export type OwnUserBase = { channel_mutes: ChannelMute[]; devices: Device[]; - mutes: Mute[]; + mutes: UserMuteResponse[]; total_unread_count: number; unread_channels: number; unread_count: number; unread_threads: number; invisible?: boolean; - privacy_settings?: PrivacySettings; + privacy_settings?: PrivacySettingsResponse; push_preferences?: PushPreferencesResponse; roles?: string[]; total_unread_count_by_team?: Record | null; }; -export type OwnUserResponse = UserResponse & OwnUserBase; - -export type PartialUpdateChannelAPIResponse = APIResponse & { - channel: ChannelResponse; - members: ChannelMemberResponse[]; -}; - -export type PermissionAPIResponse = APIResponse & { - permission?: PermissionAPIObject; -}; - -export type PermissionsAPIResponse = APIResponse & { - permissions?: PermissionAPIObject[]; -}; - export type ReactionAPIResponse = APIResponse & { message: MessageResponse; reaction: ReactionResponse; }; -export type ReactionResponse = Reaction & { - created_at: string; - message_id: string; - updated_at: string; -}; - -export type ReadResponse = { - last_read: string; - user: UserResponse; - last_read_message_id?: string; - unread_messages?: number; - last_delivered_at?: string; - last_delivered_message_id?: string; -}; - export type SearchAPIResponse = APIResponse & { results: { message: MessageResponse; @@ -953,169 +200,20 @@ export type SearchAPIResponse = APIResponse & { results_warning?: SearchWarning | null; }; -export type RoleResponse = { - name: Role; - custom: boolean; - scopes: string[]; - created_at: string; - updated_at: string; -}; - -export type CreateRoleAPIResponse = APIResponse & { - role: RoleResponse; -}; - -export type ListRolesAPIResponse = APIResponse & { - roles: RoleResponse[]; -}; - -export type SearchRolesAPIResponse = APIResponse & { - roles: RoleResponse[]; -}; - -export type SearchRolesOptions = { - query: string; - include_global_roles?: boolean; - limit?: number; - name_gt?: string; - // If not provided, the default is search performed both in user-assignable + channel-assignable roles - role_type?: 'user' | 'channel'; -}; - -export type SearchWarning = { - channel_search_cids: string[]; - channel_search_count: number; - warning_code: number; - warning_description: string; -}; - // Thumb URL(thumb_url) is added considering video attachments as the backend will return the thumbnail in the response. export type SendFileAPIResponse = APIResponse & { file: string; thumb_url?: string }; -export type SendMessageAPIResponse = APIResponse & { - message: MessageResponse; - pending_message_metadata?: Record | null; -}; - -export type SyncResponse = APIResponse & { - events: Event[]; - inaccessible_cids?: string[]; -}; - -export type TruncateChannelAPIResponse = APIResponse & { - channel: ChannelResponse; - message?: MessageResponse; -}; - export type UpdateChannelAPIResponse = APIResponse & { channel: ChannelResponse; members: ChannelMemberResponse[]; message?: MessageResponse; }; -export type UpdateChannelResponse = APIResponse & - Omit & { - created_at: string; - updated_at: string; - }; - -export type UpdateCommandResponse = APIResponse & { - command: UpdateCommandOptions & - CreatedAtUpdatedAt & { - name: CommandVariants; - }; -}; - -export type UpdateMessageAPIResponse = APIResponse & { - message: MessageResponse; -}; - export type UsersAPIResponse = APIResponse & { users: Array; membership_deletion_task_id?: string; }; -export type UpdateUsersAPIResponse = APIResponse & { - users: { [key: string]: UserResponse }; - membership_deletion_task_id?: string; -}; - -export type UserResponse = CustomUserData & { - id: string; - anon?: boolean; - banned?: boolean; - blocked_user_ids?: string[]; - created_at?: string; - deactivated_at?: string; - deleted_at?: string; - image?: string; - language?: TranslationLanguages | ''; - last_active?: string; - name?: string; - notifications_muted?: boolean; - online?: boolean; - privacy_settings?: PrivacySettings; - push_notifications?: PushNotificationSettings; - revoke_tokens_issued_before?: string; - role?: string; - shadow_banned?: boolean; - teams?: string[]; - teams_role?: TeamsRole | null; - updated_at?: string; - username?: string; - avg_response_time?: number; -}; - -export type TeamsRole = { [team: string]: string }; - -export type PrivacySettings = { - read_receipts?: { - enabled?: boolean; - }; - typing_indicators?: { - enabled?: boolean; - }; - delivery_receipts?: { - enabled?: boolean; - }; -}; - -export type PushNotificationSettings = { - disabled?: boolean; - disabled_until?: string | null; -}; - -/** - * Option Types - */ - -export type MessageFlagsPaginationOptions = { - limit?: number; - offset?: number; -}; - -export type FlagsPaginationOptions = { - limit?: number; - offset?: number; -}; - -export type FlagReportsPaginationOptions = { - limit?: number; - offset?: number; -}; - -export type ReviewFlagReportOptions = { - review_details?: object; - user_id?: string; -}; - -export type BannedUsersPaginationOptions = Omit< - PaginationOptions, - 'id_gt' | 'id_gte' | 'id_lt' | 'id_lte' -> & { - exclude_expired_bans?: boolean; -}; - export type BanUserOptions = UnBanUserOptions & { ban_from_future_channels?: boolean; banned_by?: UserResponse; @@ -1169,21 +267,6 @@ export type ChannelOptions = { sort_values?: Record; }; -export type ChannelQueryOptions = { - client_id?: string; - connection_id?: string; - created_by?: UserResponse | null; - created_by_id?: UserResponse['id']; - data?: ChannelResponse; - hide_for_creator?: boolean; - members?: PaginationOptions; - messages?: MessagePaginationOptions; - presence?: boolean; - state?: boolean; - watch?: boolean; - watchers?: PaginationOptions; -}; - export type ChannelStateOptions = { offlineMode?: boolean; skipInitialization?: string[]; @@ -1199,77 +282,6 @@ export type ChannelStateOptions = { withResponse?: boolean; }; -export type CreateChannelOptions = { - automod?: ChannelConfigAutomod; - automod_behavior?: ChannelConfigAutomodBehavior; - automod_thresholds?: ChannelConfigAutomodThresholds; - blocklist?: string; - blocklist_behavior?: ChannelConfigAutomodBehavior; - client_id?: string; - commands?: CommandVariants[]; - connect_events?: boolean; - connection_id?: string; - custom_events?: boolean; - delivery_events?: boolean; - grants?: Record; - mark_messages_pending?: boolean; - max_message_length?: number; - message_retention?: string; - mutes?: boolean; - name?: string; - permissions?: PermissionObject[]; - polls?: boolean; - push_notifications?: boolean; - quotes?: boolean; - reactions?: boolean; - read_events?: boolean; - reminders?: boolean; - replies?: boolean; - search?: boolean; - shared_locations?: boolean; - skip_last_msg_update_for_system_msgs?: boolean; - typing_events?: boolean; - uploads?: boolean; - url_enrichment?: boolean; - user_message_reminders?: boolean; - count_messages?: boolean; - push_level?: 'all' | 'all_mentions' | 'direct_mentions' | 'mentions' | 'none'; -}; - -export type CreateCommandOptions = { - description: string; - name: CommandVariants; - args?: string; - set?: CommandVariants; -}; - -export type CustomPermissionOptions = { - action: string; - condition: object; - id: string; - name: string; - description?: string; - owner?: boolean; - same_team?: boolean; -}; - -export type DeactivateUsersOptions = { - created_by_id?: string; - mark_messages_deleted?: boolean; -}; - -export type NewMemberPayload = CustomMemberData & - Pick; - -export type Thresholds = Partial< - Record<'explicit' | 'spam' | 'toxic', Partial<{ block: number; flag: number }>> ->; - -export type BlockListOptions = { - behavior: BlocklistBehavior; - blocklist: string; -}; - export type PolicyRequest = { action: 'Deny' | 'Allow' | (string & {}); /** @@ -1293,194 +305,6 @@ export type PolicyRequest = { export type Automod = 'disabled' | 'simple' | 'AI' | (string & {}); export type AutomodBehavior = 'flag' | 'block' | 'shadow_block' | (string & {}); -export type BlocklistBehavior = AutomodBehavior; -export type Command = { - args: string; - description: string; - name: string; - set: string; - created_at?: string; - updated_at?: string; -}; - -export type UpdateChannelTypeRequest = - // these three properties are required in OpenAPI spec but omitted in some QA tests - Partial<{ - automod: Automod; - automod_behavior: AutomodBehavior; - max_message_length: number; - }> & { - allowed_flag_reasons?: string[]; - automod_thresholds?: Thresholds; - blocklist?: string; - blocklist_behavior?: BlocklistBehavior; - blocklists?: BlockListOptions[]; - commands?: CommandVariants[]; - connect_events?: boolean; - custom_events?: boolean; - delivery_events?: boolean; - grants?: Record; - mark_messages_pending?: boolean; - mutes?: boolean; - partition_size?: number; - /** - * @example 24h - */ - partition_ttl?: string | null; - permissions?: PolicyRequest[]; - polls?: boolean; - push_notifications?: boolean; - quotes?: boolean; - reactions?: boolean; - read_events?: boolean; - reminders?: boolean; - replies?: boolean; - search?: boolean; - skip_last_msg_update_for_system_msgs?: boolean; - typing_events?: boolean; - uploads?: boolean; - url_enrichment?: boolean; - count_messages?: boolean; - push_level?: 'all' | 'all_mentions' | 'direct_mentions' | 'mentions' | 'none'; - }; - -export type UpdateChannelTypeResponse = { - automod: Automod; - automod_behavior: AutomodBehavior; - commands: CommandVariants[]; - connect_events: boolean; - created_at: string; - custom_events: boolean; - delivery_events: boolean; - duration: string; - grants: Record; - mark_messages_pending: boolean; - max_message_length: number; - mutes: boolean; - name: string; - permissions: PolicyRequest[]; - polls: boolean; - push_notifications: boolean; - quotes: boolean; - reactions: boolean; - read_events: boolean; - reminders: boolean; - replies: boolean; - search: boolean; - shared_locations: boolean; - skip_last_msg_update_for_system_msgs: boolean; - typing_events: boolean; - updated_at: string; - uploads: boolean; - url_enrichment: boolean; - allowed_flag_reasons?: string[]; - automod_thresholds?: Thresholds; - blocklist?: string; - blocklist_behavior?: BlocklistBehavior; - blocklists?: BlockListOptions[]; - message_retention?: string; - partition_size?: number; - partition_ttl?: string; - count_messages?: boolean; - user_message_reminders?: boolean; - push_level?: string; -}; - -export type GetChannelTypeResponse = { - automod: Automod; - automod_behavior: AutomodBehavior; - commands: Command[]; - connect_events: boolean; - created_at: string; - custom_events: boolean; - delivery_events: boolean; - duration: string; - grants: Record; - mark_messages_pending: boolean; - max_message_length: number; - mutes: boolean; - name: string; - permissions: PolicyRequest[]; - polls: boolean; - push_notifications: boolean; - quotes: boolean; - reactions: boolean; - read_events: boolean; - reminders: boolean; - replies: boolean; - search: boolean; - shared_locations: boolean; - skip_last_msg_update_for_system_msgs: boolean; - typing_events: boolean; - updated_at: string; - uploads: boolean; - url_enrichment: boolean; - allowed_flag_reasons?: string[]; - automod_thresholds?: Thresholds; - blocklist?: string; - blocklist_behavior?: BlocklistBehavior; - blocklists?: BlockListOptions[]; - message_retention?: string; - partition_size?: number; - partition_ttl?: string; - count_messages?: boolean; - user_message_reminders?: boolean; - push_level?: string; -}; - -export type UpdateChannelOptions = Partial<{ - accept_invite: boolean; - add_members: string[]; - add_moderators: string[]; - client_id: string; - connection_id: string; - data: Omit; - demote_moderators: string[]; - invites: string[]; - message: MessageResponse; - reject_invite: boolean; - remove_members: string[]; - user: UserResponse; - user_id: string; -}>; - -export type MarkChannelsReadOptions = { - client_id?: string; - connection_id?: string; - read_by_channel?: Record; - user?: UserResponse; - user_id?: string; -}; - -export type MarkReadOptions = { - client_id?: string; - connection_id?: string; - thread_id?: string; - user?: UserResponse; - user_id?: string; -}; - -export type MarkUnreadOptions = { - client_id?: string; - connection_id?: string; - message_id?: string; - thread_id?: string; - message_timestamp?: string | Date; - user?: UserResponse; - user_id?: string; -}; - -export type DeliveredMessageConfirmation = { - cid: string; - id: string; - parent_id?: string; // todo: should we include parent_id if thread delivery receipts are not yet supported? -}; - -export type MarkDeliveredOptions = { - latest_delivered_messages: DeliveredMessageConfirmation[]; - user?: UserResponse; - user_id?: string; -}; export type MuteUserOptions = { client_id?: string; @@ -1527,48 +351,10 @@ export type PinnedMessagePaginationOptions = { pinned_at_before_or_equal?: string | Date; }; -export type QueryMembersOptions = { - // Pagination option: select members created after the date (RFC399) - created_at_after?: string; - // Pagination option: select members created after or equal the date (RFC399) - created_at_after_or_equal?: string; - // Pagination option: select members created before the date (RFC399) - created_at_before?: string; - // Pagination option: select members created before or equal the date (RFC399) - created_at_before_or_equal?: string; - // Number of members to return, default 100 - limit?: number; - // Offset (max is 1000) - offset?: number; - // Pagination option: excludes members with ID less or equal the value - user_id_gt?: string; - // Pagination option: excludes members with ID less than the value - user_id_gte?: string; - // Pagination option: excludes members with ID greater or equal the value - user_id_lt?: string; - // Pagination option: excludes members with ID greater than the value - user_id_lte?: string; -}; - -export type ReactivateUserOptions = { - created_by_id?: string; - name?: string; - restore_messages?: boolean; -}; - -export type ReactivateUsersOptions = { - created_by_id?: string; - restore_messages?: boolean; -}; - -export type SearchOptions = { - limit?: number; - next?: string; - offset?: number; - sort?: SearchMessageSort; -}; +export type GetRepliesRequest = Parameters[0]; +export type QueryMembersOptions = Partial>; -export type StreamChatOptions = AxiosRequestConfig & { +export type StreamChatOptions = { /** * Used to disable warnings that are triggered by using connectUser or connectAnonymousUser server-side. */ @@ -1599,7 +385,6 @@ export type StreamChatOptions = AxiosRequestConfig & { isLocalUnreadCountEnabled?: boolean; /** experimental feature, please contact support if you want this feature enabled for you */ enableWSFallback?: boolean; - logger?: Logger; /** * Custom notification manager service to use for the client. * If not provided, a default notification manager will be created. @@ -1625,8 +410,8 @@ export type StreamChatOptions = AxiosRequestConfig & { recoverStateOnReconnect?: boolean; warmUp?: boolean; /** - * Set the instance of StableWSConnection on chat client. Its purely for testing purpose and should - * not be used in production apps. + * Sets the instance of `StableWSConnection` on the chat client. Intended purely for testing and + * should not be used in production apps. */ wsConnection?: StableWSConnection; /** @@ -1636,19 +421,6 @@ export type StreamChatOptions = AxiosRequestConfig & { wsUrlParams?: URLSearchParams; }; -export type SyncOptions = { - /** - * This will behave as queryChannels option. - */ - watch?: boolean; - /** - * Return channels from request that user does not have access to in a separate - * field in the response called 'inaccessible_cids' instead of - * adding them as 'notification.removed_from_channel' events. - */ - with_inaccessible_cids?: boolean; -}; - export type UnBanUserOptions = { client_id?: string; connection_id?: string; @@ -1659,12 +431,6 @@ export type UnBanUserOptions = { type?: string; }; -export type UpdateCommandOptions = { - description: string; - args?: string; - set?: CommandVariants; -}; - export type UserOptions = { include_deactivated_users?: boolean; limit?: number; @@ -1672,440 +438,139 @@ export type UserOptions = { presence?: boolean; }; -/** - * Event Types - */ - -export type ConnectionChangeEvent = { - type: EventTypes; - online?: boolean; -}; - -export type Event = CustomEventData & { - type: EventTypes; - ai_message?: string; - ai_state?: AIState; - channel?: ChannelResponse; - channel_custom?: CustomChannelData; - channel_id?: string; - channel_member_count?: number; - channel_type?: string; - cid?: string; - clear_history?: boolean; - connection_id?: string; - // event creation timestamp, format Date ISO string - created_at?: string; - deleted_for_me?: boolean; - draft?: DraftResponse; - // id of the message that was marked as unread - all the following messages are considered unread. (notification.mark_unread) - first_unread_message_id?: string; - hard_delete?: boolean; - last_delivered_at?: string; - last_delivered_message_id?: string; - // creation date of a message with last_read_message_id, formatted as Date ISO string - last_read_at?: string; - last_read_message_id?: string; - live_location?: SharedLocationResponse; - mark_messages_deleted?: boolean; - me?: OwnUserResponse; - member?: ChannelMemberResponse; - message?: MessageResponse; - message_id?: string; - mode?: string; - online?: boolean; - own_capabilities?: string[]; - parent_id?: string; - poll?: PollResponse; - poll_vote?: PollVote | PollAnswer; - queriedChannels?: { - channels: ChannelAPIResponse[]; - isLatestMessageSet?: boolean; - }; - offlineReactions?: ReactionResponse[]; - reaction?: ReactionResponse; - received_at?: string | Date; - reminder?: ReminderResponse; - shadow?: boolean; - team?: string; - thread?: ThreadResponse; - // @deprecated number of all unread messages across all current user's unread channels, equals unread_count - total_unread_count?: number; - // number of all current user's channels with at least one unread message including the channel in this event - unread_channels?: number; - // number of all unread messages across all current user's unread channels - unread_count?: number; - // number of unread messages in the channel from this event (notification.mark_unread) - unread_messages?: number; - unread_thread_messages?: number; - unread_threads?: number; - user?: UserResponse; - user_id?: string; - watcher_count?: number; - channel_last_message_at?: string; - app?: Record; // TODO: further specify type - thread_id?: string; -}; - -export type UserCustomEvent = CustomEventData & { - type: string; -}; +type LocalEvent = ( + | ({ type: 'live_location_sharing.started' } & { message: MessageResponse }) + | ({ type: 'live_location_sharing.stopped' } & { + live_location?: SharedLocationResponseData; + }) + | ({ type: 'channels.queried' } & { + queriedChannels: { + channels: ChannelStateResponseFields[]; + isLatestMessageSet: boolean; + }; + }) + | ({ type: 'transport.changed' } & { mode: string }) + | ({ type: 'connection.changed' } & { online: boolean }) + | { type: 'connection.recovered' } + | ({ type: 'offline_reactions.queried' } & { + offlineReactions: ReactionResponse[]; + }) + | ({ type: 'capabilities.changed' } & { + cid: string; + own_capabilities: ChannelOwnCapability[]; + }) + | ({ type: 'message.read_locally' } & { + channel_type: string; + cid: string; + created_at: Date; + channel_id?: string; + last_read_message_id?: string; + team?: string; + user?: UserResponse; + }) +) & { received_at?: Date }; -export type EventHandler = (event: Event) => void; +export type Event = WSEvent | LocalEvent | keyof CustomEventTypes; +export type EventType = Event['type'] | 'all'; -export type EventTypes = 'all' | keyof typeof EVENT_MAP | keyof CustomEventTypes; +export type EventHandler = (event: Extract) => void; /** * Filter Types */ -export type AscDesc = 1 | -1; +export type ReactionFilters = NonNullable; -export type MessageFlagsFiltersOptions = { - channel_cid?: string; - is_reviewed?: boolean; - team?: string; +export type QueryReactionsRequestWithId = Parameters[0]; + +export type ChannelFilters = NonNullable; + +export type QueryPollsOptions = Pager; + +export type VotesFiltersOptions = { + is_answer?: boolean; + option_id?: string; user_id?: string; }; -export type MessageFlagsFilters = QueryFilters< +export type QueryVotesOptions = Pager; + +export type QueryPollsFilters = QueryFilters< { - channel_cid?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - team?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; + id?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; } & { user_id?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; + } & { + is_closed?: + | RequireOnlyOne, '$eq'>> + | PrimitiveFilter; + } & { + max_votes_allowed?: | RequireOnlyOne< - Pick, '$eq' | '$in'> + Pick< + QueryFilter, + '$eq' | '$gt' | '$lt' | '$gte' | '$lte' + > > - | PrimitiveFilter; + | PrimitiveFilter; } & { - [Key in keyof Omit< - MessageFlagsFiltersOptions, - 'channel_cid' | 'user_id' | 'is_reviewed' - >]: - | RequireOnlyOne> - | PrimitiveFilter; - } ->; - -export type FlagsFiltersOptions = { - channel_cid?: string; - message_id?: string; - message_user_id?: string; - reporter_id?: string; - team?: string; - user_id?: string; -}; - -export type FlagsFilters = QueryFilters< - { - user_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; + allow_answers?: + | RequireOnlyOne, '$eq'>> + | PrimitiveFilter; } & { - message_id?: + allow_user_suggested_options?: | RequireOnlyOne< - Pick, '$eq' | '$in'> + Pick, '$eq'> > - | PrimitiveFilter; + | PrimitiveFilter; + } & { + voting_visibility?: + | RequireOnlyOne, '$eq'>> + | PrimitiveFilter; } & { - message_user_id?: + created_at?: | RequireOnlyOne< - Pick, '$eq' | '$in'> + Pick< + QueryFilter, + '$eq' | '$gt' | '$lt' | '$gte' | '$lte' + > > - | PrimitiveFilter; + | PrimitiveFilter; } & { - channel_cid?: + created_by_id?: | RequireOnlyOne< - Pick, '$eq' | '$in'> + Pick, '$eq' | '$in'> > - | PrimitiveFilter; + | PrimitiveFilter; } & { - reporter_id?: + updated_at?: | RequireOnlyOne< - Pick, '$eq' | '$in'> + Pick< + QueryFilter, + '$eq' | '$gt' | '$lt' | '$gte' | '$lte' + > > - | PrimitiveFilter; + | PrimitiveFilter; } & { - team?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; + name?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; } >; -export type FlagReportsFiltersOptions = { - channel_cid?: string; - is_reviewed?: boolean; - message_id?: string; - message_user_id?: string; - report_id?: string; - review_result?: string; - reviewed_by?: string; - team?: string; - user_id?: string; -}; - -export type FlagReportsFilters = QueryFilters< +export type QueryVotesFilters = QueryFilters< { - report_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; + id?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; } & { - review_result?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - reviewed_by?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - user_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - message_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - message_user_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - channel_cid?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - team?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - [Key in keyof Omit< - FlagReportsFiltersOptions, - 'report_id' | 'user_id' | 'message_id' | 'review_result' | 'reviewed_by' - >]: - | RequireOnlyOne> - | PrimitiveFilter; - } ->; - -export type BannedUsersFilterOptions = { - banned_by_id?: string; - channel_cid?: string; - created_at?: string; - reason?: string; - user_id?: string; -}; - -export type BannedUsersFilters = QueryFilters< - { - channel_cid?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - reason?: - | RequireOnlyOne< - { - $autocomplete?: BannedUsersFilterOptions['reason']; - } & QueryFilter - > - | PrimitiveFilter; - } & { - [Key in keyof Omit]: - | RequireOnlyOne> - | PrimitiveFilter; - } ->; - -export type ReactionFilters = QueryFilters< - { - user_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - type?: - | RequireOnlyOne, '$eq'>> - | PrimitiveFilter; - } & { - created_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } ->; - -export type ChannelFilters = QueryFilters< - ContainsOperator> & { - app_banned?: 'only' | 'excluded'; - has_unread?: boolean; - archived?: boolean; - 'member.user.name'?: - | RequireOnlyOne<{ - $autocomplete?: string; - $eq?: string; - }> - | string; - - members?: - | RequireOnlyOne, '$in'>> - | RequireOnlyOne, '$eq'>> - | PrimitiveFilter; - name?: - | RequireOnlyOne< - { - $autocomplete?: string; - } & QueryFilter - > - | PrimitiveFilter; - pinned?: boolean; - last_updated?: - | RequireOnlyOne, '$eq' | '$gt' | '$gte' | '$lt' | '$lte'>> - | PrimitiveFilter; - } & { - [Key in keyof Omit]: - | RequireOnlyOne> - | PrimitiveFilter; - } ->; - -export type DraftFilters = { - channel_cid?: - | RequireOnlyOne, '$in' | '$eq'>> - | PrimitiveFilter; - created_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - parent_id?: - | RequireOnlyOne< - Pick, '$in' | '$eq' | '$exists'> - > - | PrimitiveFilter; -}; - -export type QueryPollsParams = { - filter?: QueryPollsFilters; - options?: QueryPollsOptions; - sort?: PollSort; -}; - -export type QueryPollsOptions = Pager; - -export type VotesFiltersOptions = { - is_answer?: boolean; - option_id?: string; - user_id?: string; -}; - -export type QueryVotesOptions = Pager; - -export type QueryPollsFilters = QueryFilters< - { - id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - user_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - is_closed?: - | RequireOnlyOne, '$eq'>> - | PrimitiveFilter; - } & { - max_votes_allowed?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - allow_answers?: - | RequireOnlyOne, '$eq'>> - | PrimitiveFilter; - } & { - allow_user_suggested_options?: - | RequireOnlyOne< - Pick, '$eq'> - > - | PrimitiveFilter; - } & { - voting_visibility?: - | RequireOnlyOne, '$eq'>> - | PrimitiveFilter; - } & { - created_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - created_by_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - updated_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - name?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } ->; - -export type QueryVotesFilters = QueryFilters< - { - id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - option_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; + option_id?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; } & { is_answer?: | RequireOnlyOne, '$eq'>> @@ -2118,82 +583,35 @@ export type QueryVotesFilters = QueryFilters< created_at?: | RequireOnlyOne< Pick< - QueryFilter, + QueryFilter, '$eq' | '$gt' | '$lt' | '$gte' | '$lte' > > - | PrimitiveFilter; + | PrimitiveFilter; } & { created_by_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; + | RequireOnlyOne< + Pick, '$eq' | '$in'> + > + | PrimitiveFilter; } & { updated_at?: | RequireOnlyOne< Pick< - QueryFilter, + QueryFilter, '$eq' | '$gt' | '$lt' | '$gte' | '$lte' > > - | PrimitiveFilter; - } ->; - -export type ContainsOperator = { - [Key in keyof CustomType]?: CustomType[Key] extends (infer ContainType)[] - ? - | RequireOnlyOne< - { - $contains?: ContainType extends object - ? PrimitiveFilter> - : PrimitiveFilter; - } & QueryFilter[]> - > - | PrimitiveFilter[]> - : RequireOnlyOne> | PrimitiveFilter; -}; - -export type MessageFilters = QueryFilters< - ContainsOperator & { - 'attachments.type'?: - | RequireOnlyOne<{ - $eq: PrimitiveFilter; - $in: PrimitiveFilter[]; - }> - | PrimitiveFilter; - 'mentioned_users.id'?: RequireOnlyOne<{ - $contains: PrimitiveFilter; - }>; - text?: - | RequireOnlyOne< - { - $autocomplete?: MessageResponse['text']; - $q?: MessageResponse['text']; - } & QueryFilter - > - | PrimitiveFilter; - 'user.id'?: - | RequireOnlyOne< - { - $autocomplete?: UserResponse['id']; - } & QueryFilter - > - | PrimitiveFilter; - } & { - [Key in keyof Omit]?: - | RequireOnlyOne> - | PrimitiveFilter; + | PrimitiveFilter; } >; -export type MessageOptions = { - include_thread_participants?: boolean; -}; +export type MessageFilters = NonNullable; export type PrimitiveFilter = ObjectType | null; export type QueryFilter = - NonNullable extends string | number | boolean + NonNullable extends string | number | boolean | Date ? { $eq?: PrimitiveFilter; $exists?: boolean; @@ -2209,246 +627,38 @@ export type QueryFilter = $in?: PrimitiveFilter>[]; }; -export type QueryFilters = { - [Key in keyof Operators]?: Operators[Key]; -} & QueryLogicalOperators; - -export type QueryLogicalOperators = { - $and?: ArrayOneOrMore>; - $nor?: ArrayOneOrMore>; - $or?: ArrayTwoOrMore>; -}; - -export type UserFilters = QueryFilters< - ContainsOperator & { - id?: - | RequireOnlyOne< - { $autocomplete?: UserResponse['id'] } & QueryFilter - > - | PrimitiveFilter; - name?: - | RequireOnlyOne< - { $autocomplete?: UserResponse['name'] } & QueryFilter - > - | PrimitiveFilter; - notifications_muted?: - | RequireOnlyOne<{ - $eq?: PrimitiveFilter; - }> - | boolean; - teams?: - | RequireOnlyOne<{ - $contains?: PrimitiveFilter; - $eq?: PrimitiveFilter; - $in?: PrimitiveFilter; - }> - | PrimitiveFilter; - username?: - | RequireOnlyOne< - { $autocomplete?: UserResponse['username'] } & QueryFilter< - UserResponse['username'] - > - > - | PrimitiveFilter; - } & { - [Key in keyof Omit< - UserResponse, - 'id' | 'name' | 'teams' | 'username' | keyof CustomUserData - >]?: - | RequireOnlyOne> - | PrimitiveFilter; - } ->; - -export type InviteStatus = 'pending' | 'accepted' | 'rejected' | 'member'; +export type UserFilters = QueryUsersPayload['filter_conditions']; -// https://getstream.io/chat/docs/react/channel_member/#update-channel-members -export type MemberFilters = QueryFilters< - { - banned?: { $eq?: ChannelMemberResponse['banned'] } | ChannelMemberResponse['banned']; - channel_role?: - | { $eq?: ChannelMemberResponse['channel_role'] } - | ChannelMemberResponse['channel_role']; - cid?: { $eq?: ChannelResponse['cid'] } | ChannelResponse['cid']; - created_at?: - | { - $eq?: ChannelMemberResponse['created_at']; - $gt?: ChannelMemberResponse['created_at']; - $gte?: ChannelMemberResponse['created_at']; - $lt?: ChannelMemberResponse['created_at']; - $lte?: ChannelMemberResponse['created_at']; - } - | ChannelMemberResponse['created_at']; - id?: - | RequireOnlyOne<{ - $eq?: UserResponse['id']; - $in?: UserResponse['id'][]; - }> - | UserResponse['id']; - invite?: { $eq?: ChannelMemberResponse['status'] } | ChannelMemberResponse['status']; - is_moderator?: - | RequireOnlyOne<{ $eq?: ChannelMemberResponse['is_moderator'] }> - | ChannelMemberResponse['is_moderator']; - joined?: { $eq?: boolean } | boolean; - last_active?: - | { - $eq?: UserResponse['last_active']; - $gt?: UserResponse['last_active']; - $gte?: UserResponse['last_active']; - $lt?: UserResponse['last_active']; - $lte?: UserResponse['last_active']; - } - | UserResponse['last_active']; - name?: - | RequireOnlyOne<{ - $autocomplete?: NonNullable['name']; - $eq?: NonNullable['name']; - $in?: NonNullable['name'][]; - $q?: NonNullable['name']; - }> - | PrimitiveFilter['name']>; - notifications_muted?: - | RequireOnlyOne<{ $eq?: ChannelMemberResponse['notifications_muted'] }> - | ChannelMemberResponse['notifications_muted']; - updated_at?: - | { - $eq?: ChannelMemberResponse['updated_at']; - $gt?: ChannelMemberResponse['updated_at']; - $gte?: ChannelMemberResponse['updated_at']; - $lt?: ChannelMemberResponse['updated_at']; - $lte?: ChannelMemberResponse['updated_at']; - } - | ChannelMemberResponse['updated_at']; - 'user.email'?: - | RequireOnlyOne<{ - $autocomplete?: string; - $eq?: string; - $in?: string; - }> - | string; - user_id?: - | RequireOnlyOne<{ - $eq?: ChannelMemberResponse['user_id']; - $in?: ChannelMemberResponse['user_id'][]; - }> - | PrimitiveFilter; - } & { - [Key in keyof ContainsOperator]?: - | RequireOnlyOne[Key]>> - | PrimitiveFilter[Key]>; - } ->; +export type MemberFilters = QueryMembersPayload['filter_conditions']; /** * Sort Types */ -export type BannedUsersSort = BannedUsersSortBase | Array; - -export type BannedUsersSortBase = { created_at?: AscDesc }; - -export type ReactionSort = ReactionSortBase | Array; - -export type ReactionSortBase = Sort & { - created_at?: AscDesc; -}; - -export type ChannelSort = ChannelSortBase | Array; - -export type ChannelSortBase = Sort & { - created_at?: AscDesc; - has_unread?: AscDesc; - last_message_at?: AscDesc; - last_updated?: AscDesc; - member_count?: AscDesc; - pinned_at?: AscDesc; - unread_count?: AscDesc; - updated_at?: AscDesc; -}; - -export type PinnedMessagesSort = PinnedMessagesSortBase | Array; -export type PinnedMessagesSortBase = { pinned_at?: AscDesc }; - -export type Sort = { - [P in keyof T]?: AscDesc; -}; - -export type UserSort = Sort | Array>; +export type BannedUsersSort = SortParamRequest[]; -export type MemberSort = - | Sort< - Pick & { - user_id?: string; - } - > - | Array< - Sort< - Pick & { - user_id?: string; - } - > - >; - -export type SearchMessageSortBase = Sort & { - attachments?: AscDesc; - 'attachments.type'?: AscDesc; - created_at?: AscDesc; - id?: AscDesc; - 'mentioned_users.id'?: AscDesc; - parent_id?: AscDesc; - pinned?: AscDesc; - relevance?: AscDesc; - reply_count?: AscDesc; - text?: AscDesc; - type?: AscDesc; - updated_at?: AscDesc; - 'user.id'?: AscDesc; -}; +export type ReactionSort = SortParamRequest[]; -export type SearchMessageSort = SearchMessageSortBase | Array; +export type ChannelSort = SortParamRequest[]; -export type QuerySort = BannedUsersSort | ChannelSort | SearchMessageSort | UserSort; +export type PinnedMessagesSort = SortParamRequest[]; -export type DraftSortBase = { - created_at?: AscDesc; -}; +export type UserSort = SortParamRequest[]; -export type DraftSort = DraftSortBase | Array; +export type MemberSort = SortParamRequest[]; -export type PollSort = PollSortBase | Array; +export type SearchMessageSort = SortParamRequest[]; -export type PollSortBase = { - created_at?: AscDesc; - id?: AscDesc; - is_closed?: AscDesc; - name?: AscDesc; - updated_at?: AscDesc; -}; +export type DraftSort = SortParamRequest[]; -export type VoteSort = VoteSortBase | Array; +export type PollSort = SortParamRequest[]; -export type VoteSortBase = { - created_at?: AscDesc; - id?: AscDesc; - is_closed?: AscDesc; - name?: AscDesc; - updated_at?: AscDesc; -}; +export type VoteSort = SortParamRequest[]; /** * Base Types */ -export type Action = { - name?: string; - style?: string; - text?: string; - type?: string; - value?: string; -}; - -export type AnonUserType = {}; - export type APNConfig = { auth_key?: string; auth_type?: string; @@ -2470,114 +680,12 @@ export type AsyncModerationOptions = { timeout_ms?: number; }; -export type AppSettings = { - allowed_flag_reasons?: string[]; - apn_config?: { - auth_key?: string; - auth_type?: string; - bundle_id?: string; - development?: boolean; - host?: string; - key_id?: string; - notification_template?: string; - p12_cert?: string; - team_id?: string; - }; - async_moderation_config?: AsyncModerationOptions; - async_url_enrich_enabled?: boolean; - auto_translation_enabled?: boolean; - before_message_send_hook_url?: string; - before_message_send_hook_attempt_timeout_ms?: number; - cdn_expiration_seconds?: number; - custom_action_handler_url?: string; - disable_auth_checks?: boolean; - disable_permissions_checks?: boolean; - enforce_unique_usernames?: 'no' | 'app' | 'team'; - event_hooks?: Array | null; - explicit_event_hooks_deletion?: boolean; - // all possible file mime types are https://www.iana.org/assignments/media-types/media-types.xhtml - file_upload_config?: FileUploadConfig; - firebase_config?: { - apn_template?: string; - credentials_json?: string; - data_template?: string; - notification_template?: string; - server_key?: string; - }; - grants?: Record; - huawei_config?: { - id: string; - secret: string; - }; - image_moderation_enabled?: boolean; - image_upload_config?: FileUploadConfig; - migrate_permissions_to_v2?: boolean; - multi_tenant_enabled?: boolean; - permission_version?: 'v1' | 'v2'; - push_config?: { - offline_only?: boolean; - version?: string; - }; - reminders_interval?: number; - revoke_tokens_issued_before?: string | null; - sns_key?: string; - sns_secret?: string; - sns_topic_arn?: string; - sqs_key?: string; - sqs_secret?: string; - sqs_url?: string; - user_response_time_enabled?: boolean; - video_provider?: string; - webhook_events?: Array | null; - webhook_url?: string; - xiaomi_config?: { - package_name: string; - secret: string; - }; -}; - -export type Attachment = CustomAttachmentData & { - actions?: Action[]; - asset_url?: string; - author_icon?: string; - author_link?: string; - author_name?: string; - color?: string; - duration?: number; - fallback?: string; - fields?: Field[]; - file_size?: number | string; - footer?: string; - footer_icon?: string; - giphy?: GiphyData; - image_url?: string; - latitude?: number; - longitude?: number; - mime_type?: string; - og_scrape_url?: string; - original_height?: number; - original_width?: number; - pretext?: string; - text?: string; - thumb_url?: string; - title?: string; - title_link?: string; - type?: string; - waveform_data?: Array; -}; +// export type Attachment = ReplacePropertyTypes< +// Attachment, +// { custom: CustomAttachmentData & { file_size?: number; mime_type?: string } } +// >; -export type OGAttachment = { - og_scrape_url: string; - asset_url?: string; // og:video | og:audio - author_link?: string; // og:site - author_name?: string; // og:site_name - image_url?: string; // og:image - text?: string; // og:description - thumb_url?: string; // og:image - title?: string; // og:title - title_link?: string; // og:url - type?: string | 'video' | 'audio' | 'image'; -}; +export type OGAttachment = RequireLiteral; export type BlockList = { name: string; @@ -2590,93 +698,12 @@ export type BlockList = { is_plural_check_enabled?: boolean; }; -export type ChannelConfig = ChannelConfigFields & - CreatedAtUpdatedAt & { - commands?: CommandVariants[]; - }; - -export type ChannelConfigAutomod = Automod; - -export type ChannelConfigAutomodBehavior = AutomodBehavior; - -export type ChannelConfigAutomodThresholds = null | Thresholds; - -export type ChannelConfigFields = { - reminders: boolean; - automod?: ChannelConfigAutomod; - automod_behavior?: ChannelConfigAutomodBehavior; - automod_thresholds?: ChannelConfigAutomodThresholds; - blocklist_behavior?: ChannelConfigAutomodBehavior; - connect_events?: boolean; - custom_events?: boolean; - delivery_events?: boolean; - mark_messages_pending?: boolean; - max_message_length?: number; - message_retention?: string; - mutes?: boolean; - name?: string; - polls?: boolean; - push_notifications?: boolean; - quotes?: boolean; - reactions?: boolean; - read_events?: boolean; - replies?: boolean; - search?: boolean; - shared_locations?: boolean; - skip_last_msg_update_for_system_msgs?: boolean; - count_messages?: boolean; - typing_events?: boolean; - uploads?: boolean; - url_enrichment?: boolean; - user_message_reminders?: boolean; // Feature flag for user message reminders - push_level?: 'all' | 'all_mentions' | 'direct_mentions' | 'mentions' | 'none' | ''; -}; - -export type ChannelConfigWithInfo = ChannelConfigFields & - CreatedAtUpdatedAt & { - commands?: CommandResponse[]; - }; - -export type ChannelData = CustomChannelData & - Partial<{ - blocked: boolean; - created_by: UserResponse | null; - created_by_id: UserResponse['id']; - members: string[] | Array; - blocklist_behavior: AutomodBehavior; - automod: Automod; - filter_tags: string[]; - team?: string; - }>; - -export type ChannelMute = { - user: UserResponse; - channel?: ChannelResponse; - created_at?: string; - expires?: string; - updated_at?: string; -}; - -export type ChannelRole = { - custom?: boolean; - name?: string; - owner?: boolean; - resource?: string; - same_team?: boolean; -}; - -export type CheckPushInput = { - apn_template?: string; - client_id?: string; - connection_id?: string; - firebase_data_template?: string; - firebase_template?: string; - message_id?: string; - user?: UserResponse; - user_id?: string; -}; +export type ChannelData = ReplacePropertyTypes< + ChannelInput, + { custom: CustomChannelData } +>; -export type PushProvider = 'apn' | 'firebase' | 'huawei' | 'xiaomi'; +export type PushProvider = CreateDeviceRequest['push_provider']; export type PushProviderConfig = PushProviderCommon & PushProviderID & @@ -2741,19 +768,7 @@ export type CommandVariants = export type Configs = Record; -export type ConnectionOpen = { - connection_id: string; - cid?: string; - created_at?: string; - received_at?: string; - me?: OwnUserResponse; - type?: string; -}; - -export type CreatedAtUpdatedAt = { - created_at: string; - updated_at: string; -}; +export type ConnectionOpen = EventPayload<'health.check'>; export type Device = DeviceFields & { provider?: string; @@ -2773,164 +788,6 @@ export type DeviceFields = BaseDeviceFields & { disabled_reason?: string; }; -export type EndpointName = - | 'Connect' - | 'LongPoll' - | 'DeleteFile' - | 'DeleteImage' - | 'DeleteMessage' - | 'DeleteUser' - | 'DeleteUsers' - | 'DeactivateUser' - | 'ExportUser' - | 'DeleteReaction' - | 'UpdateChannel' - | 'UpdateChannelPartial' - | 'UpdateMessage' - | 'UpdateMessagePartial' - | 'GetMessage' - | 'GetManyMessages' - | 'UpdateUsers' - | 'UpdateUsersPartial' - | 'CreateGuest' - | 'GetOrCreateChannel' - | 'StopWatchingChannel' - | 'QueryChannels' - | 'Search' - | 'QueryUsers' - | 'QueryMembers' - | 'QueryBannedUsers' - | 'QueryFlags' - | 'QueryMessageFlags' - | 'GetReactions' - | 'GetReplies' - | 'GetPinnedMessages' - | 'Ban' - | 'Unban' - | 'MuteUser' - | 'MuteChannel' - | 'UnmuteChannel' - | 'UnmuteUser' - | 'RunMessageAction' - | 'SendEvent' - | 'SendUserCustomEvent' - | 'MarkRead' - | 'MarkChannelsRead' - | 'SendMessage' - | 'ImportChannelMessages' - | 'UploadFile' - | 'UploadImage' - | 'UpdateApp' - | 'GetApp' - | 'CreateDevice' - | 'DeleteDevice' - | 'SendReaction' - | 'Flag' - | 'Unflag' - | 'Unblock' - | 'QueryFlagReports' - | 'FlagReportReview' - | 'CreateChannelType' - | 'DeleteChannel' - | 'DeleteChannels' - | 'DBDeleteChannelType' - | 'GetChannelType' - | 'ListChannelTypes' - | 'ListDevices' - | 'TruncateChannel' - | 'UpdateChannelType' - | 'CheckPush' - | 'PrivateSubmitModeration' - | 'ReactivateUser' - | 'HideChannel' - | 'ShowChannel' - | 'CreatePermission' - | 'UpdatePermission' - | 'GetPermission' - | 'DeletePermission' - | 'ListPermissions' - | 'CreateRole' - | 'DeleteRole' - | 'ListRoles' - | 'ListCustomRoles' - | 'Sync' - | 'TranslateMessage' - | 'CreateCommand' - | 'GetCommand' - | 'UpdateCommand' - | 'DeleteCommand' - | 'ListCommands' - | 'CreateBlockList' - | 'UpdateBlockList' - | 'GetBlockList' - | 'ListBlockLists' - | 'DeleteBlockList' - | 'ExportChannels' - | 'GetExportChannelsStatus' - | 'CheckSQS' - | 'GetRateLimits' - | 'CreateSegment' - | 'GetSegment' - | 'QuerySegments' - | 'UpdateSegment' - | 'DeleteSegment' - | 'CreateCampaign' - | 'GetCampaign' - | 'ListCampaigns' - | 'UpdateCampaign' - | 'DeleteCampaign' - | 'ScheduleCampaign' - | 'StopCampaign' - | 'ResumeCampaign' - | 'TestCampaign' - | 'GetOG' - | 'GetTask' - | 'ExportUsers' - | 'CreateImport' - | 'CreateImportURL' - | 'GetImport' - | 'ListImports' - | 'UpsertPushProvider' - | 'DeletePushProvider' - | 'ListPushProviders' - | 'CreatePoll'; - -export type ExportChannelRequest = ( - | { - id: string; - type: string; - } - | { - cid: string; - } -) & { messages_since?: Date; messages_until?: Date }; - -export type ExportChannelOptions = { - clear_deleted_message_text?: boolean; - export_users?: boolean; - include_soft_deleted_channels?: boolean; - include_truncated_messages?: boolean; - version?: string; -}; - -export type ExportUsersRequest = { - user_ids: string[]; -}; - -export type Field = { - short?: boolean; - title?: string; - value?: string; -}; - -export type FileUploadConfig = { - allowed_file_extensions?: string[] | null; - allowed_mime_types?: string[] | null; - blocked_file_extensions?: string[] | null; - blocked_mime_types?: string[] | null; - size_limit?: number | null; -}; - export type FirebaseConfig = { apn_template?: string; credentials_json?: string; @@ -2940,27 +797,6 @@ export type FirebaseConfig = { server_key?: string; }; -type GiphyVersionInfo = { - height: string; - url: string; - width: string; - frames?: string; - size?: string; -}; - -export type GiphyVersions = - | 'original' - | 'fixed_height' - | 'fixed_height_still' - | 'fixed_height_downsampled' - | 'fixed_width' - | 'fixed_width_still' - | 'fixed_width_downsampled'; - -export type GiphyData = { - [key in GiphyVersions]: GiphyVersionInfo; -}; - export type HuaweiConfig = { enabled?: boolean; id?: string; @@ -2973,134 +809,15 @@ export type XiaomiConfig = { secret?: string; }; -export type LiteralStringForUnion = string & {}; +export type MessageLabel = + | 'deleted' + | 'ephemeral' + | 'error' + | 'regular' + | 'reply' + | 'system'; -export type LogLevel = 'info' | 'error' | 'warn'; - -export type Logger = ( - logLevel: LogLevel, - message: string, - extraData?: Record, -) => void; - -export type Message = Partial< - MessageBase & { - mentioned_users: string[]; - shared_location?: StaticLocationPayload | LiveLocationPayload; - mentioned_channel?: boolean; - mentioned_here?: boolean; - mentioned_group_ids?: string[]; - mentioned_roles?: string[]; - } ->; - -export type MessageBase = CustomMessageData & { - id: string; - attachments?: Attachment[]; - html?: string; - mml?: string; - parent_id?: string; - pin_expires?: string | null; - pinned?: boolean; - pinned_at?: string | null; - poll_id?: string; - quoted_message_id?: string; - restricted_visibility?: string[]; - show_in_channel?: boolean; - silent?: boolean; - text?: string; - type?: MessageLabel; - user?: UserResponse | null; - user_id?: string; -}; - -export type MessageLabel = - | 'deleted' - | 'ephemeral' - | 'error' - | 'regular' - | 'reply' - | 'system'; - -export type SendMessageOptions = { - force_moderation?: boolean; - // @deprecated use `pending` instead - is_pending_message?: boolean; - keep_channel_hidden?: boolean; - pending?: boolean; - pending_message_metadata?: Record; - skip_enrich_url?: boolean; - skip_push?: boolean; -}; - -export type UpdateMessageOptions = { - skip_enrich_url?: boolean; - skip_push?: boolean; -}; - -export type SendReactionOptions = { - enforce_unique?: boolean; - skip_push?: boolean; -}; - -export type GetMessageOptions = { - show_deleted_message?: boolean; -}; - -export type Mute = { - created_at: string; - target: UserResponse; - updated_at: string; - user: UserResponse; -}; - -export type PartialUpdateChannelFields = Partial & { - config_overrides?: Partial; -}; - -export type PartialUpdateChannel = { - set?: PartialUpdateChannelFields; - unset?: Array; -}; - -export type PartialUpdateMember = { - set?: ChannelMemberUpdates; - unset?: Array; -}; - -export type PartialUserUpdate = { - id: string; - set?: Partial; - unset?: Array; -}; - -export type MessageUpdatableFields = Omit< - MessageResponse, - 'cid' | 'created_at' | 'updated_at' | 'deleted_at' | 'user' | 'user_id' ->; - -export type PartialMessageUpdate = { - set?: Partial; - unset?: Array; -}; - -export type PendingMessageResponse = { - message: MessageResponse; - pending_message_metadata?: Record; -}; - -export type PermissionAPIObject = { - action?: string; - condition?: object; - custom?: boolean; - description?: string; - id?: string; - level?: string; - name?: string; - owner?: boolean; - same_team?: boolean; - tags?: string[]; -}; +export type SendMessageOptions = Omit; export type PermissionObject = { action?: 'Deny' | 'Allow'; @@ -3122,143 +839,10 @@ export type Policy = { updated_at?: string; }; -export type RateLimitsInfo = { - limit: number; - remaining: number; - reset: number; -}; - -export type RateLimitsMap = Record; - -export type Reaction = CustomReactionData & { - type: string; - message_id?: string; - score?: number; - user?: UserResponse | null; - user_id?: string; - emoji_code?: string; -}; - -export type Resource = - | 'AddLinks' - | 'BanUser' - | 'CreateChannel' - | 'CreateMessage' - | 'CreateReaction' - | 'DeleteAttachment' - | 'DeleteChannel' - | 'DeleteMessage' - | 'DeleteReaction' - | 'EditUser' - | 'MuteUser' - | 'ReadChannel' - | 'RunMessageAction' - | 'UpdateChannel' - | 'UpdateChannelMembers' - | 'UpdateMessage' - | 'UpdateUser' - | 'UploadAttachment'; - -export type SearchPayload = Omit & { - client_id?: string; - connection_id?: string; - filter_conditions?: ChannelFilters; - message_filter_conditions?: MessageFilters; - message_options?: MessageOptions; - query?: string; - sort?: Array<{ - direction: AscDesc; - field: keyof SearchMessageSortBase; - }>; -}; - -export type TestPushDataInput = { - apnTemplate?: string; - firebaseDataTemplate?: string; - firebaseTemplate?: string; - messageID?: string; - pushProviderName?: string; - pushProviderType?: PushProvider; - skipDevices?: boolean; -}; - -export type TestSQSDataInput = { - sqs_key?: string; - sqs_secret?: string; - sqs_url?: string; -}; - -export type TestSNSDataInput = { - sns_key?: string; - sns_secret?: string; - sns_topic_arn?: string; -}; - export type TokenOrProvider = null | string | TokenProvider | undefined; export type TokenProvider = () => Promise; -export type TranslationLanguages = - | 'af' - | 'am' - | 'ar' - | 'az' - | 'bg' - | 'bn' - | 'bs' - | 'cs' - | 'da' - | 'de' - | 'el' - | 'en' - | 'es' - | 'es-MX' - | 'et' - | 'fa' - | 'fa-AF' - | 'fi' - | 'fr' - | 'fr-CA' - | 'ha' - | 'he' - | 'hi' - | 'hr' - | 'hu' - | 'id' - | 'it' - | 'ja' - | 'ka' - | 'ko' - | 'lt' - | 'lv' - | 'ms' - | 'nl' - | 'no' - | 'pl' - | 'ps' - | 'pt' - | 'ro' - | 'ru' - | 'sk' - | 'sl' - | 'so' - | 'sq' - | 'sr' - | 'sv' - | 'sw' - | 'ta' - | 'th' - | 'tl' - | 'tr' - | 'uk' - | 'ur' - | 'vi' - | 'zh' - | 'zh-TW' - | (string & {}); - -export type TypingStartEvent = Event; - export type ReservedUpdatedMessageFields = keyof typeof RESERVED_UPDATED_MESSAGE_FIELDS; export type UpdatedMessage = Omit< @@ -3273,270 +857,22 @@ export type UpdatedMessage = Omit< type?: MessageLabel; }; -/** - * @description type alias for UserResponse - */ -export type User = UserResponse; - export type TaskResponse = { task_id: string; }; -export type DeleteChannelsResponse = { - result: Record; -} & Partial; - -export type DeleteType = 'soft' | 'hard' | 'pruning'; - -/* - DeleteUserOptions specifies a collection of one or more `user_ids` to be deleted. - - `user`: - - soft: marks user as deleted and retains all user data - - pruning: marks user as deleted and nullifies user information - - hard: deletes user completely - this requires hard option for messages and conversation as well - `conversations`: - - soft: marks all conversation channels as deleted (same effect as Delete Channels with 'hard' option disabled) - - hard: deletes channel and all its data completely including messages (same effect as Delete Channels with 'hard' option enabled) - `messages`: - - soft: marks all user messages as deleted without removing any related message data - - pruning: marks all user messages as deleted, nullifies message information and removes some message data such as reactions and flags - - hard: deletes messages completely with all related information - `new_channel_owner_id`: any channels owned by the hard-deleted user will be transferred to this user ID - */ -export type DeleteUserOptions = { - conversations?: Exclude; - messages?: DeleteType; - new_channel_owner_id?: string; - user?: DeleteType; -}; - -export type SegmentType = 'channel' | 'user'; - -export type SegmentData = { - all_sender_channels?: boolean; - all_users?: boolean; - description?: string; - filter?: {}; - name?: string; -}; - -export type SegmentResponse = { - created_at: string; - deleted_at: string; - id: string; - locked: boolean; - size: number; - task_id: string; - type: SegmentType; - updated_at: string; -} & SegmentData; - -export type UpdateSegmentData = { - name: string; -} & SegmentData; - -export type SegmentTargetsResponse = { - created_at: string; - segment_id: string; - target_id: string; -}; - -export type SortParam = { - field: string; - direction?: AscDesc; -}; - export type Pager = { limit?: number; next?: string; prev?: string; }; -export type QuerySegmentsOptions = Pager; - -export type QuerySegmentTargetsFilter = { - target_id?: { - $eq?: string; - $gte?: string; - $in?: string[]; - $lte?: string; - }; -}; -export type QuerySegmentTargetsOptions = Pick; - -export type GetCampaignOptions = { - users?: { limit?: number; next?: string; prev?: string }; -}; - -export type CampaignSort = { - field: string; - direction?: number; -}[]; - -export type CampaignQueryOptions = { - limit?: number; - next?: string; - prev?: string; - sort?: CampaignSort; - user_limit?: number; -}; - -export type SegmentQueryOptions = CampaignQueryOptions; - -// TODO: add better typing -export type CampaignFilters = {}; - -export type CampaignData = { - channel_template?: { - type: string; - custom?: {}; - id?: string; - members?: string[]; - members_template?: Array<{ - user_id: string; - channel_role?: string; - custom?: Record; - }>; - team?: string; - }; - create_channels?: boolean; - deleted_at?: string; - description?: string; - id?: string | null; - message_template?: { - text: string; - attachments?: Attachment[]; - custom?: {}; - poll_id?: string; - }; - name?: string; - segment_ids?: string[]; - sender_id?: string; - sender_mode?: 'exclude' | 'include' | null; - sender_visibility?: 'hidden' | 'archived' | null; - show_channels?: boolean; - skip_push?: boolean; - skip_webhook?: boolean; - user_ids?: string[]; -}; - -export type CampaignStats = { - progress?: number; - stats_channels_created?: number; - stats_completed_at?: string; - stats_messages_sent?: number; - stats_started_at?: string; - stats_users_read?: number; - stats_users_sent?: number; -}; -export type CampaignResponse = { - created_at: string; - id: string; - segments: SegmentResponse[]; - sender: UserResponse; - stats: CampaignStats; - status: 'draft' | 'scheduled' | 'in_progress' | 'completed' | 'stopped'; - updated_at: string; - users: UserResponse[]; - scheduled_for?: string; -} & CampaignData; - -export type DeleteCampaignOptions = {}; - -export type TaskStatus = { - created_at: string; - status: string; - task_id: string; - updated_at: string; - error?: { - description: string; - type: string; - }; - result?: UR; -}; - -export type TruncateOptions = { - hard_delete?: boolean; - message?: Message; - skip_push?: boolean; - truncated_at?: Date; - user?: UserResponse; - user_id?: string; -}; - -export type CreateImportURLResponse = { - path: string; - upload_url: string; -}; - -export type CreateImportResponse = { - import_task: ImportTask; -}; - -export type GetImportResponse = { - import_task: ImportTask; -}; - -export type CreateImportOptions = { - mode: 'insert' | 'upsert'; -}; - -export type ListImportsPaginationOptions = { - limit?: number; - offset?: number; -}; - -export type ListImportsResponse = { - import_tasks: ImportTask[]; -}; - -export type ImportTaskHistory = { - created_at: string; - next_state: string; - prev_state: string; -}; - -export type ImportTask = { - created_at: string; - history: ImportTaskHistory[]; - id: string; - path: string; - state: string; - updated_at: string; - result?: UR; - size?: number; -}; - export type MessageSetType = 'latest' | 'current' | 'new'; -export type PushProviderUpsertResponse = { - push_provider: PushProvider; -}; - -export type PushProviderListResponse = { - push_providers: PushProvider[]; -}; - -type ErrorResponseDetails = { - code: number; - messages: string[]; -}; - -export type APIErrorResponse = { - duration: string; - message: string; - more_info: string; - StatusCode: number; - code?: number; - details?: ErrorResponseDetails; -}; - -export class ErrorFromResponse extends Error { - public code: number | null; - public status: number; - public response: AxiosResponse; - public name = 'ErrorFromResponse'; +export class StreamAPIError extends Error { + public code: number | undefined; + public status: number | undefined; + public response: AxiosResponse | undefined; constructor( message: string, @@ -3545,9 +881,15 @@ export class ErrorFromResponse extends Error { status, response, }: { - code: ErrorFromResponse['code']; - response: ErrorFromResponse['response']; - status: ErrorFromResponse['status']; + /** + * Stream error code (`APIError.code`) + */ + code: StreamAPIError['code']; + /** + * HTTP status code + */ + status: StreamAPIError['status']; + response: StreamAPIError['response']; }, ) { super(message); @@ -3556,24 +898,35 @@ export class ErrorFromResponse extends Error { this.status = status; } - // Vitest helper (serialized errors are too large to read) - // https://github.com/vitest-dev/vitest/blob/v3.1.3/packages/utils/src/error.ts#L60-L62 - toJSON() { - const extra = [ - ['status', this.status], - ['code', this.code], - ] as const; + get name() { + let tags = StreamAPIError.withMetadata({ status: this.status, code: this.code }); + + if (tags.length) { + tags = `(${tags})`; + } + + return `StreamAPIError${tags}`; + } + + static withMetadata(metadata: Record) { + const extra = Object.entries(metadata); const joinable = []; for (const [key, value] of extra) { - if (typeof value !== 'undefined' && value !== null) { + if (typeof value !== 'undefined' && value !== null && `${value}`.length) { joinable.push(`${key}: ${value}`); } } + return `${joinable.join(', ')}`; + } + + // Vitest helper (serialized errors are too large to read) + // https://github.com/vitest-dev/vitest/blob/v3.1.3/packages/utils/src/error.ts#L60-L62 + toJSON() { return { - message: `(${joinable.join(', ')}) - ${this.message}`, + message: this.message, stack: this.stack, name: this.name, code: this.code, @@ -3582,50 +935,7 @@ export class ErrorFromResponse extends Error { } } -export type QueryPollsResponse = { - polls: PollResponse[]; - next?: string; -}; - -export type CreatePollAPIResponse = { - poll: PollResponse; -}; - -export type GetPollAPIResponse = { - poll: PollResponse; -}; - -export type UpdatePollAPIResponse = { - poll: PollResponse; -}; - -export type PollResponse = CustomPollData & - PollEnrichData & { - created_at: string; - created_by: UserResponse | null; - created_by_id: string; - enforce_unique_vote: boolean; - id: string; - max_votes_allowed: number; - name: string; - options: PollOption[]; - updated_at: string; - allow_answers?: boolean; - allow_user_suggested_options?: boolean; - description?: string; - is_closed?: boolean; - voting_visibility?: VotingVisibility; - }; - -export type PollOption = { - created_at: string; - id: string; - poll_id: string; - text: string; - updated_at: string; - vote_count: number; - votes?: PollVote[]; -}; +export type PollResponse_old = PollResponseData & PollEnrichData; export enum VotingVisibility { anonymous = 'anonymous', @@ -3634,1663 +944,214 @@ export enum VotingVisibility { export type PollEnrichData = { answers_count: number; - latest_answers: PollAnswer[]; // not updated with WS events, ordered DESC by created_at, seems like updated_at cannot be different from created_at - latest_votes_by_option: Record; // not updated with WS events; always null in anonymous polls + latest_answers: PollVoteResponseData[]; // not updated with WS events, ordered DESC by created_at, seems like updated_at cannot be different from created_at + latest_votes_by_option: Record; // not updated with WS events; always null in anonymous polls vote_count: number; vote_counts_by_option: Record; - own_votes?: (PollVote | PollAnswer)[]; // not updated with WS events -}; - -export type PollData = CustomPollData & { - id: string; - name: string; - allow_answers?: boolean; - allow_user_suggested_options?: boolean; - description?: string; - enforce_unique_vote?: boolean; - is_closed?: boolean; - max_votes_allowed?: number; - options?: PollOptionData[]; - user_id?: string; - voting_visibility?: VotingVisibility; + own_votes?: PollVoteResponseData[]; // not updated with WS events }; -export type CreatePollData = Partial & Pick; - export type PartialPollUpdate = { - set?: Partial; - unset?: Array; + set?: Partial; + unset?: Array; }; -export type PollOptionData = CustomPollOptionData & { - text: string; - id?: string; +export type PollOptionData = UpdatePollOptionRequest & { position?: number; }; -export type PartialPollOptionUpdate = { - set?: Partial; - unset?: Array; -}; - -export type PollVoteData = { - answer_text?: string; - is_answer?: boolean; - option_id?: string; -}; - -export type PollPaginationOptions = { - limit?: number; - next?: string; -}; +export type MessageDeletionStrategy = 'soft' | 'hard' | 'pruning'; +// @deprecated use type MessageDeletionStrategy instead -export type CreatePollOptionAPIResponse = { - poll_option: PollOptionResponse; +export type ModerationFlagOptions = { + custom?: Record; + moderation_payload?: ModerationPayload; + user_id?: string; }; -export type GetPollOptionAPIResponse = CreatePollOptionAPIResponse; -export type UpdatePollOptionAPIResponse = CreatePollOptionAPIResponse; +export type AIState = + | 'AI_STATE_ERROR' + | 'AI_STATE_CHECKING_SOURCES' + | 'AI_STATE_THINKING' + | 'AI_STATE_GENERATING' + | (string & {}); -export type PollOptionResponse = CustomPollData & { - created_at: string; - id: string; - poll_id: string; - position: number; - text: string; - updated_at: string; - vote_count: number; - votes?: PollVote[]; +export type PromoteChannelParams = { + channels: Array; + channelToMove: Channel; + sort: ChannelSort; + /** + * If the index of the channel within `channels` list which is being moved upwards + * (`channelToMove`) is known, you can supply it to skip extra calculation. + */ + channelToMoveIndexWithinChannels?: number; }; -export type PollVote = { - created_at: string; - id: string; - poll_id: string; - updated_at: string; - option_id?: string; - user?: UserResponse; - user_id?: string; +/** + * An identifier containing information about the downstream SDK using stream-chat. It + * is used to resolve the user agent. + */ +export type SdkIdentifier = { + name: 'react' | 'react-native' | 'expo' | 'angular'; + version: string; }; -export type PollAnswer = Exclude & { - answer_text: string; - is_answer: boolean; // this is absolutely redundant prop as answer_text indicates that a vote is an answer -}; - -export type PollVotesAPIResponse = { - votes: (PollVote | PollAnswer)[]; - next?: string; -}; - -export type PollAnswersAPIResponse = { - votes: PollAnswer[]; // todo: should be changes to answers? - next?: string; -}; - -export type CastVoteAPIResponse = { - vote: PollVote | PollAnswer; -}; - -export type QueryMessageHistoryFilters = QueryFilters< - { - message_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - user_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - created_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } ->; - -export type QueryMessageHistorySort = - | QueryMessageHistorySortBase - | Array; - -export type QueryMessageHistorySortBase = { - message_updated_at?: AscDesc; - message_updated_by_id?: AscDesc; -}; - -export type QueryMessageHistoryOptions = Pager; - -export type MessageHistoryEntry = { - message_id: string; - message_updated_at: string; - attachments?: Attachment[]; - message_updated_by_id?: string; - text?: string; -}; - -export type QueryMessageHistoryResponse = { - message_history: MessageHistoryEntry[]; - next?: string; - prev?: string; -}; - -// Moderation v2 -export type ModerationPayload = { - created_at: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - custom?: Record; - images?: string[]; - texts?: string[]; - videos?: string[]; -}; - -export type ModV2ReviewStatus = 'complete' | 'flagged' | 'partial'; - -export type ModerationFlag = { - created_at: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - custom: Record; - entity_creator_id: string; - entity_id: string; - entity_type: string; - id: string; - reason: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - result: Record[]; - review_queue_item_id: string; - updated_at: string; - user: UserResponse; - moderation_payload?: ModerationPayload; - moderation_payload_hash?: string; -}; - -export type ReviewQueueItem = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - actions_taken: any[]; - appealed_by: string; - assigned_to: string; - completed_at: string; - config_key: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: any[]; - created_at: string; - created_by: string; - entity_id: string; - entity_type: string; - entity_creator_id?: string; - flags: ModerationFlag[]; - has_image: boolean; - has_text: boolean; - has_video: boolean; - id: string; - moderation_payload: ModerationPayload; - moderation_payload_hash: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - options: any; - recommended_action: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - results: any; - reviewed_at: string; - status: string; - updated_at: string; - latest_moderator_action?: string; -}; - -export type CustomCheckFlag = { - type: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - custom?: Record[]; - labels?: string[]; - reason?: string; -}; - -export type MessageDeletionStrategy = 'soft' | 'hard' | 'pruning'; -// @deprecated use type MessageDeletionStrategy instead -export type DeleteMessagesOptions = MessageDeletionStrategy; - -export type DeleteMessageOptions = { - deleteForMe?: boolean; - hardDelete?: boolean; -}; - -export type SubmitActionOptions = { - appeal_id?: string; - ban?: { - target_user_id?: string; - shadow?: boolean; - reason?: string; - channel_ban_only?: boolean; - channel_cid?: string; - ip_ban?: boolean; - delete_messages?: MessageDeletionStrategy; - delete_reactions?: boolean; - timeout?: number; - }; - block?: { - reason?: string; - }; - custom?: { - id: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - options?: Record; - }; - delete_activity?: { - hard_delete?: boolean; - reason?: string; - entity_id?: string; - entity_type?: string; - }; - delete_comment?: { - hard_delete?: boolean; - reason?: string; - entity_id?: string; - entity_type?: string; - }; - delete_message?: { - hard_delete?: boolean; - reason?: string; - entity_id?: string; - entity_type?: string; - }; - delete_reaction?: { - hard_delete?: boolean; - reason?: string; - entity_id?: string; - entity_type?: string; - }; - delete_user?: { - hard_delete?: boolean; - reason?: string; - mark_messages_deleted?: boolean; - delete_conversation_channels?: boolean; - delete_feeds_content?: boolean; - entity_id?: string; - entity_type?: string; - }; - end_call?: Record; - escalate?: { - reason: string; - category: string; - priority: string; - }; - flag?: { - entity_type: string; - entity_id: string; - entity_creator_id?: string; - reason?: string; - moderation_payload?: ModerationPayload; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - custom?: Record; - }; - kick_user?: Record; - mark_reviewed?: { - disable_marking_content_as_reviewed?: boolean; - content_to_mark_as_reviewed_limit?: number; - decision_reason?: string; - }; - reject_appeal?: { - decision_reason: string; - }; - restore?: { - decision_reason?: string; - }; - shadow_block?: { - reason?: string; - }; - unban?: { - channel_cid?: string; - decision_reason?: string; - }; - unblock?: { - decision_reason?: string; - }; - user_id?: string; -}; - -export type SubmitActionResponse = APIResponse & { - item?: ReviewQueueItem; -}; - -export type GetUserModerationReportResponse = { - user: UserResponse; - user_blocks?: Array<{ - blocked_at: string; - blocked_by_user_id: string; - blocked_user_id: string; - }>; - user_mutes?: Mute[]; -}; - -export type CheckResponse = APIResponse & { - status: string; - task_id?: string; - recommended_action: string; - item?: ReviewQueueItem; -}; - -export type CustomCheckResponse = APIResponse & { - id: string; - item: ReviewQueueItem; - status: string; -}; - -export type QueryModerationConfigsFilters = QueryFilters< - { - key?: string; - } & { - created_at?: PrimitiveFilter; - } & { - updated_at?: PrimitiveFilter; - } & { - team?: string; - } ->; - -export type ReviewQueueFilters = QueryFilters< - { - assigned_to?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - completed_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - config_key?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - entity_type?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - created_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - entity_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - entity_creator_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - reviewed?: boolean; - } & { - reviewed_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - status?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - updated_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - has_image?: boolean; - } & { - has_text?: boolean; - } & { - has_video?: boolean; - } & { - has_media?: boolean; - } & { - language?: RequireOnlyOne<{ - $contains?: string; - $eq?: string; - $in?: string[]; - }>; - } & { - teams?: - | RequireOnlyOne<{ - $contains?: PrimitiveFilter; - $eq?: PrimitiveFilter; - $in?: PrimitiveFilter; - }> - | PrimitiveFilter; - } & { - user_report_reason?: RequireOnlyOne<{ - $eq?: string; - }>; - } & { - recommended_action?: RequireOnlyOne<{ - $eq?: string; - $in?: string[]; - }>; - } & { - flagged_user_id?: RequireOnlyOne<{ - $eq?: string; - }>; - } & { - category?: RequireOnlyOne<{ - $eq?: string; - }>; - } & { - label?: RequireOnlyOne<{ - $eq?: string; - $in?: string[]; - }>; - } & { - reporter_type?: RequireOnlyOne<{ - $eq?: 'automod' | 'user' | 'moderator' | 'admin' | 'velocity_filter'; - }>; - } & { - reporter_id?: RequireOnlyOne<{ - $eq?: string; - $in?: string[]; - }>; - } & { - date_range?: RequireOnlyOne<{ - $eq?: string; // Format: "date1_date2" - }>; - } & { - latest_moderator_action?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - flags_count?: RequireOnlyOne<{ - $eq?: number; - }>; - } & { - ai_text_severity?: RequireOnlyOne<{ - $eq?: string; - }>; - } & { - channel_cid?: RequireOnlyOne<{ - $eq?: string; - }>; - } ->; - -export type ReviewQueueSort = - | Sort> - | Array>>; - -export type QueryModerationConfigsSort = Array>; - -export type ReviewQueuePaginationOptions = Pager; - -export type FilterConfigResponse = { - llm_labels: string[]; - ai_text_labels?: string[]; -}; - -export type ModerationActionConfig = { - entity_type: string; - order: number; - action: string; - icon: string; - description: string; - custom?: Record; -}; - -export type ReviewQueueResponse = { - items: ReviewQueueItem[]; - action_config?: Record; - filter_config?: FilterConfigResponse; - stats?: Record; - next?: string; - prev?: string; -}; - -export type ModerationConfig = { - key: string; - ai_image_config?: AIImageConfig; - ai_text_config?: AITextConfig; - ai_video_config?: AIVideoConfig; - automod_platform_circumvention_config?: AutomodPlatformCircumventionConfig; - automod_semantic_filters_config?: AutomodSemanticFiltersConfig; - automod_toxicity_config?: AutomodToxicityConfig; - block_list_config?: BlockListConfig; - llm_config?: LLMConfig; - team?: string; -}; - -export type ModerationConfigResponse = ModerationConfig & { - created_at: string; - updated_at: string; -}; - -export type GetConfigResponse = { - config: ModerationConfigResponse; -}; - -export type QueryConfigsResponse = { - configs: ModerationConfigResponse[]; - next?: string; - prev?: string; -}; - -export type UpsertConfigResponse = { - config: ModerationConfigResponse; -}; - -// Moderation Rule Builder Types -export type ModerationRule = { - id: string; - name: string; - description: string; - config_keys: string[]; - team: string; - rule: RuleBuilderRule; - enabled: boolean; - created_at: string; - updated_at: string; -}; - -export type ModerationRuleRequest = { - name: string; - description: string; - config_keys: string[]; - team: string; - rule: RuleBuilderRule; - enabled: boolean; -}; - -export type RuleBuilderRule = { - id: string; - rule_type: 'user' | 'content'; - conditions?: RuleBuilderCondition[]; - logic?: 'AND' | 'OR'; - groups?: RuleBuilderConditionGroup[]; - action: RuleBuilderAction; - cooldown_period?: string; -}; - -export type RuleBuilderCondition = { - type: string; - confidence?: number; - text_rule_params?: TextRuleParameters; - image_rule_params?: ImageRuleParameters; - video_rule_params?: VideoRuleParameters; - user_rule_params?: UserRuleParameters; - content_count_rule_params?: ContentCountRuleParameters; - text_content_params?: TextContentParameters; - image_content_params?: ImageContentParameters; - video_content_params?: VideoContentParameters; - user_created_within_params?: UserCreatedWithinParameters; - user_custom_property_params?: UserCustomPropertyParameters; -}; - -export type RuleBuilderConditionGroup = { - logic: 'AND' | 'OR'; - conditions: RuleBuilderCondition[]; -}; - -export type RuleBuilderAction = { - type: string; - ban_options?: BanOptions; - flag_user_options?: FlagUserOptions; -}; - -export type TextRuleParameters = { - threshold: number; - time_window: string; - harm_labels?: string[]; - llm_harm_labels?: Record; - contains_url?: boolean; - severity?: string; - blocklist_match?: string[]; -}; - -export type ImageRuleParameters = { - threshold: number; - time_window: string; - harm_labels: string[]; -}; - -export type VideoRuleParameters = { - threshold: number; - time_window: string; - harm_labels: string[]; -}; - -export type UserRuleParameters = { - max_age: string; -}; - -export type ContentCountRuleParameters = { - threshold: number; - time_window: string; -}; - -export type TextContentParameters = { - harm_labels?: string[]; - llm_harm_labels?: Record; - contains_url?: boolean; - severity?: string; - blocklist_match?: string[]; -}; - -export type ImageContentParameters = { - harm_labels: string[]; -}; - -export type VideoContentParameters = { - harm_labels: string[]; -}; - -export type UserCreatedWithinParameters = { - max_age: string; -}; - -export type UserCustomPropertyParameters = { - property_key: string; - operator: string; - expected_value: string; -}; - -export type BanOptions = { - duration: number; - reason: string; - shadow_ban: boolean; - ip_ban: boolean; -}; - -export type FlagUserOptions = { - reason: string; -}; - -export type QueryModerationRulesFilters = QueryFilters<{ - name?: string; - team?: string; - enabled?: boolean; - rule_type?: string; - created_at?: PrimitiveFilter; - updated_at?: PrimitiveFilter; -}>; - -export type QueryModerationRulesSort = Array< - Sort<'name' | 'enabled' | 'team' | 'created_at' | 'updated_at'> ->; - -export type QueryModerationRulesResponse = { - rules: ModerationRule[]; - default_llm_labels: Record; - next?: string; - prev?: string; -}; - -export type UpsertModerationRuleResponse = { - rule: ModerationRule; -}; - -export type ModerationFlagOptions = { - custom?: Record; - moderation_payload?: ModerationPayload; - user_id?: string; -}; - -export type ModerationMuteOptions = { - timeout?: number; - user_id?: string; -}; -export type GetUserModerationReportOptions = { - create_user_if_not_exists?: boolean; - include_user_blocks?: boolean; - include_user_mutes?: boolean; -}; - -export type AIState = - | 'AI_STATE_ERROR' - | 'AI_STATE_CHECKING_SOURCES' - | 'AI_STATE_THINKING' - | 'AI_STATE_GENERATING' - | (string & {}); - -export type ModerationActionType = - | 'flag' - | 'shadow' - | 'remove' - | 'bounce' - | 'bounce_flag' - | 'bounce_remove'; - -export type ModerationSeverity = 'low' | 'medium' | 'high' | 'critical'; - -export type AutomodRule = { - action: ModerationActionType; - label: string; - threshold: number; -}; - -export type BlockListRule = { - action: ModerationActionType; - name?: string; -}; - -export type BlockListConfig = { - enabled: boolean; - rules: BlockListRule[]; - async?: boolean; -}; - -export type LLMConfig = { - rules: LLMRule[]; - severity_descriptions?: Record; - app_context?: string; -}; - -export type LLMRule = { - label: string; - description: string; - action: ModerationActionType; - severity_rules?: LLMSeverityRule[]; -}; - -export type LLMSeverityRule = { - severity: ModerationSeverity; - action: ModerationActionType; -}; - -export type AutomodToxicityConfig = { - enabled: boolean; - rules: AutomodRule[]; - async?: boolean; -}; - -export type AutomodPlatformCircumventionConfig = { - enabled: boolean; - rules: AutomodRule[]; - async?: boolean; -}; - -export type AutomodSemanticFiltersRule = { - action: ModerationActionType; - name: string; - threshold: number; -}; - -export type AutomodSemanticFiltersConfig = { - enabled: boolean; - rules: AutomodSemanticFiltersRule[]; - async?: boolean; -}; - -export type AITextSeverityRule = { - action: ModerationActionType; - severity: ModerationSeverity; -}; - -export type AITextRule = { - label: string; - action?: ModerationActionType; - severity_rules?: AITextSeverityRule[]; -}; - -export type AITextConfig = { - enabled: boolean; - rules: AITextRule[]; - async?: boolean; - profile?: string; - severity_rules?: AITextSeverityRule[]; // Deprecated: use rules instead -}; - -export type AIImageRule = { - action: ModerationActionType; - label: string; - min_confidence?: number; -}; - -export type AIImageConfig = { - enabled: boolean; - rules: AIImageRule[]; - async?: boolean; -}; - -export type AIVideoRule = { - action: ModerationActionType; - label: string; - min_confidence?: number; -}; - -export type AIVideoConfig = { - enabled: boolean; - rules: AIVideoRule[]; - async?: boolean; -}; - -export type VelocityFilterConfigRule = { - action: 'flag' | 'shadow' | 'remove' | 'ban'; - ban_duration?: number; - cascading_action?: 'flag' | 'shadow' | 'remove' | 'ban'; - cascading_threshold?: number; - check_message_context?: boolean; - fast_spam_threshold?: number; - fast_spam_ttl?: number; - ip_ban?: boolean; - shadow_ban?: boolean; - slow_spam_ban_duration?: number; - slow_spam_threshold?: number; - slow_spam_ttl?: number; -}; - -export type VelocityFilterConfig = { - cascading_actions: boolean; - enabled: boolean; - first_message_only: boolean; - rules: VelocityFilterConfigRule[]; - async?: boolean; -}; - -export type PromoteChannelParams = { - channels: Array; - channelToMove: Channel; - sort: ChannelSort; - /** - * If the index of the channel within `channels` list which is being moved upwards - * (`channelToMove`) is known, you can supply it to skip extra calculation. - */ - channelToMoveIndexWithinChannels?: number; -}; - -/** - * An identifier containing information about the downstream SDK using stream-chat. It - * is used to resolve the user agent. - */ -export type SdkIdentifier = { - name: 'react' | 'react-native' | 'expo' | 'angular'; - version: string; -}; - -/** - * An identifier containing information about the downstream device using stream-chat, if - * available. Is used by the react-native SDKs to enrich the user agent further. - */ -export type DeviceIdentifier = { os: string; model?: string }; - -/** - * An identifier containing information about the downstream application integrating - * stream-chat, if available. `name` is reported as `app` and `version` as `app_version` - * in the user agent. Distinct from the SDK ({@link SdkIdentifier}) and device - * ({@link DeviceIdentifier}) identifiers. - */ -export type AppIdentifier = { name: string; version?: string }; - -export type DraftResponse = { - channel_cid: string; - created_at: string; - message: DraftMessage; - channel?: ChannelResponse; - parent_id?: string; - parent_message?: MessageResponseBase; - quoted_message?: MessageResponseBase; -}; - -export type CreateDraftResponse = APIResponse & { - draft: DraftResponse; -}; - -export type GetDraftResponse = APIResponse & { - draft: DraftResponse; -}; - -export type QueryDraftsResponse = APIResponse & { - drafts: DraftResponse[]; -} & Omit; - -export type DraftMessagePayload = PartializeKeys< - Omit, - 'id' -> & { - user_id?: string; -}; - -export type DraftMessage = { - id: string; - text: string; - attachments?: Attachment[]; - custom?: {}; - html?: string; - mentioned_users?: string[]; - mentioned_channel?: boolean; - mentioned_here?: boolean; - mentioned_group_ids?: string[]; - mentioned_groups?: UserGroupResponse[]; - mentioned_roles?: string[]; - mml?: string; - parent_id?: string; - poll_id?: string; - quoted_message_id?: string; - shared_location?: StaticLocationPayload | LiveLocationPayload; // todo: live-location verify if possible - show_in_channel?: boolean; - silent?: boolean; - type?: MessageLabel; -}; - -export type ActiveLiveLocationsAPIResponse = APIResponse & { - active_live_locations: SharedLiveLocationResponse[]; -}; - -export type SharedLocationResponse = { - channel_cid: string; - created_at: string; - created_by_device_id: string; - end_at?: string; - latitude: number; - longitude: number; - message_id: string; - updated_at: string; - user_id: string; -}; - -export type SharedStaticLocationResponse = { - channel_cid: string; - created_at: string; - created_by_device_id: string; - latitude: number; - longitude: number; - message_id: string; - updated_at: string; - user_id: string; -}; - -export type SharedLiveLocationResponse = { - channel_cid: string; - created_at: string; - created_by_device_id: string; - end_at: string; - latitude: number; - longitude: number; - message_id: string; - updated_at: string; - user_id: string; -}; - -export type UpdateLocationPayload = { - message_id: string; - created_by_device_id?: string; - end_at?: string; - latitude?: number; - longitude?: number; - user?: { id: string }; - user_id?: string; -}; - -export type StaticLocationPayload = { - created_by_device_id: string; - latitude: number; - longitude: number; - message_id: string; -}; - -export type LiveLocationPayload = { - created_by_device_id: string; - end_at: string; - latitude: number; - longitude: number; - message_id: string; -}; - -export type ThreadSort = ThreadSortBase | Array; - -export type ThreadSortBase = { - active_participant_count?: AscDesc; - created_at?: AscDesc; - last_message_at?: AscDesc; - parent_message_id?: AscDesc; - participant_count?: AscDesc; - reply_count?: AscDesc; - updated_at?: AscDesc; -}; - -export type ThreadFilters = QueryFilters< - { - channel_cid?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - parent_message_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - created_by_user_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - created_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - updated_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - last_message_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } ->; +/** + * An identifier containing information about the downstream device using stream-chat, if + * available. Is used by the react-native SDKs to enrich the user agent further. + */ +export type DeviceIdentifier = { os: string; model?: string }; -export type ReminderResponseBase = { - channel_cid: string; - created_at: string; - message_id: string; - updated_at: string; - user_id: string; - remind_at?: string; -}; +/** + * An identifier containing information about the downstream application integrating + * stream-chat, if available. `name` is reported as `app` and `version` as `app_version` + * in the user agent. Distinct from the SDK ({@link SdkIdentifier}) and device + * ({@link DeviceIdentifier}) identifiers. + */ +export type AppIdentifier = { name: string; version?: string }; -export type ReminderResponse = ReminderResponseBase & { - user: UserResponse; - message: MessageResponse; - channel?: ChannelResponse; -}; +export type DraftMessage = DraftPayloadResponse & + Partial< + Pick< + MessageResponse, + | 'shared_location' + | 'mentioned_channel' + | 'mentioned_group_ids' + | 'mentioned_groups' + | 'mentioned_here' + | 'mentioned_roles' + > + >; -export type ReminderAPIResponse = APIResponse & { - reminder: ReminderResponse; -}; +export type SharedLiveLocationResponse = RequireLiteral< + SharedLocationResponseData, + 'end_at' +>; -export type CreateReminderOptions = { - messageId: string; - remind_at?: string | null; - user_id?: string; -}; +export type LiveLocationPayload = RequireLiteral; + +export type ThreadSort = SortParamRequest[]; -export type UpdateReminderOptions = CreateReminderOptions; +export type ThreadFilters = NonNullable; + +export type CreateReminderOptions = Parameters[0]; export type ReminderFilters = QueryFilters<{ channel_cid?: | RequireOnlyOne< - Pick, '$eq' | '$in'> + Pick, '$eq' | '$in'> > - | PrimitiveFilter; + | PrimitiveFilter; created_at?: | RequireOnlyOne< Pick< - QueryFilter, + QueryFilter, '$eq' | '$gt' | '$lt' | '$gte' | '$lte' > > - | PrimitiveFilter; + | PrimitiveFilter; message_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; remind_at?: | RequireOnlyOne< Pick< - QueryFilter, + QueryFilter, '$exists' | '$eq' | '$gt' | '$lt' | '$gte' | '$lte' > > - | PrimitiveFilter; + | PrimitiveFilter; user_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; -}>; - -export type ReminderSort = - | Sort< - Pick< - ReminderResponseBase, - 'channel_cid' | 'created_at' | 'remind_at' | 'updated_at' - > - > - | Array< - Sort< - Pick< - ReminderResponseBase, - 'channel_cid' | 'created_at' | 'remind_at' | 'updated_at' - > - > - >; - -export type QueryRemindersOptions = Pager & { - filter?: ReminderFilters; - sort?: ReminderSort; -}; - -export type QueryRemindersResponse = { - reminders: ReminderResponse[]; - prev?: string; - next?: string; -}; - -export type UserGroupMemberResponse = { - group_id: string; - user_id: string; - is_admin: boolean; - created_at: string; -}; - -export type UserGroupResponse = { - id: string; - name: string; - created_at: string; - updated_at: string; - description?: string; - team_id?: string; - members?: UserGroupMemberResponse[]; - created_by?: string; -}; - -export type CreateUserGroupOptions = { - /** Human-readable user group name */ - name: string; - /** Optional user group description shown to members */ - description?: string; - /** Optional custom user group ID. If omitted, the backend generates one */ - id?: string; - /** Optional list of user IDs to add as members when the group is created */ - member_ids?: string[]; - /** Optional team ID that scopes the user group to a specific team */ - team_id?: string; -}; - -export type CreateUserGroupResponse = APIResponse & { - user_group: UserGroupResponse; -}; - -export type GetUserGroupOptions = { - team_id?: string; -}; - -export type GetUserGroupResponse = APIResponse & { - user_group: UserGroupResponse; -}; - -export type QueryUserGroupsOptions = { - limit?: number; - id_gt?: string; - created_at_gt?: string; - team_id?: string; -}; - -export type QueryUserGroupsResponse = APIResponse & { - user_groups: UserGroupResponse[]; -}; - -export type SearchUserGroupsOptions = { - query: string; - limit?: number; - id_gt?: string; - name_gt?: string; - team_id?: string; -}; - -export type SearchUserGroupsResponse = APIResponse & { - user_groups: UserGroupResponse[]; -}; - -export type UpdateUserGroupOptions = { - description?: string; - name?: string; - team_id?: string; -}; - -export type UpdateUserGroupResponse = APIResponse & { - user_group: UserGroupResponse; -}; - -export type DeleteUserGroupOptions = { - team_id?: string; -}; - -export type AddUserGroupMembersOptions = { - member_ids: string[]; - as_admin?: boolean; - team_id?: string; -}; - -export type AddUserGroupMembersResponse = APIResponse & { - user_group: UserGroupResponse; -}; - -export type RemoveUserGroupMembersOptions = { - member_ids: string[]; - team_id?: string; -}; - -export type RemoveUserGroupMembersResponse = APIResponse & { - user_group: UserGroupResponse; -}; - -export type HookType = 'webhook' | 'sqs' | 'sns' | 'pending_message'; - -export type EventHook = { - id?: string; - hook_type?: HookType; - enabled?: boolean; - product?: Product | 'all'; // optional, default is 'all' - event_types?: Array; - webhook_url?: string; - sqs_queue_url?: string; - sqs_region?: string; - sqs_auth_type?: string; - sqs_key?: string; - sqs_secret?: string; - sqs_role_arn?: string; - sns_topic_arn?: string; - sns_region?: string; - sns_auth_type?: string; - sns_key?: string; - sns_secret?: string; - sns_role_arn?: string; - should_send_custom_events?: boolean; - - // pending message config - timeout_ms?: number; - callback?: { - mode: 'CALLBACK_MODE_NONE' | 'CALLBACK_MODE_REST' | 'CALLBACK_MODE_TWIRP'; - }; - - delete?: boolean; - created_at?: string; - updated_at?: string; -}; - -export type BatchUpdateOperation = - | 'addMembers' - | 'removeMembers' - | 'inviteMembers' - | 'assignRoles' - | 'addModerators' - | 'demoteModerators' - | 'hide' - | 'show' - | 'archive' - | 'unarchive' - | 'updateData'; - -export type BatchChannelDataUpdate = { - frozen?: boolean; - disabled?: boolean; - custom?: Record; - team?: string; - config_overrides?: Record; - auto_translation_enabled?: boolean; - auto_translation_language?: string; -}; - -export type UpdateChannelsBatchOptions = { - operation: BatchUpdateOperation; - filter: UpdateChannelsBatchFilters; - members?: string[] | Array; - data?: BatchChannelDataUpdate; -}; - -export type UpdateChannelsBatchFilters = QueryFilters<{ - cids?: - | RequireOnlyOne, '$in' | '$eq'>> - | PrimitiveFilter; - types?: - | RequireOnlyOne, '$in' | '$eq'>> - | PrimitiveFilter; + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; }>; -export type UpdateChannelsBatchResponse = { - result: Record; -} & Partial; - -/** - * Predefined Filter Types - */ - -export type PredefinedFilterOperation = 'QueryChannels'; - -export type PredefinedFilterSortParam = { - /** - * Field name to sort by. - * - * This may be a literal field name such as `created_at`, or a placeholder - * template such as `{{sort_field}}` that will be interpolated server-side. - */ - field: string; - /** - * Sort direction. `1` means ascending and `-1` means descending. - * - * The backend defaults this to `1` when omitted. - */ - direction?: AscDesc; - /** - * Optional server-side hint describing how the sort field value should be - * interpreted. - * - * This is mainly relevant for predefined-filter sort templates and is not - * part of the regular `queryChannels()` sort shape. Omitting it uses the - * backend default string behavior. Known backend values include: - * - * - `number`: cast custom-field values to numeric before sorting - * - `boolean`: cast custom-field values to boolean before sorting - * - * Other values are backend-defined. In most cases this should be omitted - * unless you are sorting by a custom field whose stored JSON value is not - * string-like. - */ - type?: string; -}; - -/** - * Stored predefined filter definition as returned by the server. - * - * `F` represents the raw filter template shape. It defaults to a generic record - * because predefined filters are server-managed templates and may include - * placeholders or app-specific structures. - */ -export type PredefinedFilter< - F extends Record = Record, -> = { - /** - * Unique predefined filter name within the app. - */ - name: string; - /** - * Operation this predefined filter is valid for. - */ - operation: PredefinedFilterOperation; - /** - * Filter template stored on the server. - * - * This is not necessarily the fully interpolated runtime filter; placeholder - * values such as `{{user_id}}` may still be present. - */ - filter: F; - /** - * Server creation timestamp in ISO-8601 format. - */ - created_at: string; - /** - * Server update timestamp in ISO-8601 format. - */ - updated_at: string; - /** - * Optional human-readable description. - */ - description?: string; - /** - * Optional sort template stored with the predefined filter. - */ - sort?: PredefinedFilterSortParam[]; - /** - * Query identifier generated by the backend for the filter/sort pattern. - * - * The exact value is backend-generated and primarily useful for correlating - * predefined filters with query analysis / query performance data. - */ - query_id?: number; -}; - -export type CreatePredefinedFilterOptions< - F extends Record = Record, -> = { - /** - * Unique predefined filter name. - */ - name: string; - /** - * Operation this predefined filter will be used with. - */ - operation: PredefinedFilterOperation; - /** - * Filter template to store on the server. - */ - filter: F; - /** - * Optional human-readable description. - */ - description?: string; - /** - * Optional sort template stored with the predefined filter. - */ - sort?: PredefinedFilterSortParam[]; -}; +export type ReminderSort = SortParamRequest[]; -export type UpdatePredefinedFilterOptions< - F extends Record = Record, -> = Omit, 'name'>; +export type ListUserGroupsOptions = NonNullable[0]>; -export type PredefinedFilterResponse< - F extends Record = Record, -> = APIResponse & { - predefined_filter: PredefinedFilter; -}; +export type SearchUserGroupsOptions = Parameters[0]; -/** - * Paginated response returned when listing predefined filters. - */ -export type ListPredefinedFiltersResponse< - F extends Record = Record, -> = APIResponse & { - predefined_filters: PredefinedFilter[]; - next?: string; - prev?: string; +export type RateLimit = { + rate_limit?: number; + rate_limit_remaining?: number; + rate_limit_reset?: Date; }; -/** - * Contains the interpolated filter and sort from a predefined filter. - * This is returned in the QueryChannels response when using a predefined filter. - */ -export type ParsedPredefinedFilterResponse< - F extends Record = Record, -> = { - /** - * Name of the predefined filter that was resolved. - */ - name: string; - /** - * Fully interpolated filter that the backend executed. - */ - filter: F; - /** - * Fully interpolated sort parameters resolved from the predefined filter. - */ - sort?: PredefinedFilterSortParam[]; +export type RequestMetadata = { + response_headers: Record; + rate_limit: RateLimit; + response_code: number; + client_request_id: string; }; -export type PredefinedFilterSort = SortParam[]; - -export type ListPredefinedFiltersOptions = Pager & { - sort?: PredefinedFilterSort; +export type StreamResponse = T & { + metadata: RequestMetadata; }; -/** - * Team Usage Stats Types - */ - -/** - * Represents a metric value for a specific date - */ -export type DailyValue = { - /** Date in YYYY-MM-DD format */ - date: string; - /** Metric value for this date */ - value: number; -}; +export type EventPayload = Extract< + Event, + { type: T } +>; -/** - * Statistics for a single metric with optional daily breakdown - */ -export type MetricStats = { - /** Per-day values (only present in daily mode) */ - daily?: DailyValue[]; - /** Aggregated total value */ - total: number; -}; +export type RequireLiteral = Omit & Required>; -/** - * Usage statistics for a single team containing all 16 metrics - */ -export type TeamUsageStats = { - /** Team identifier (empty string for users not assigned to any team) */ - team: string; - - // Daily activity metrics (total = SUM of daily values) - /** Daily active users */ - users_daily: MetricStats; - /** Daily messages sent */ - messages_daily: MetricStats; - /** Daily translations */ - translations_daily: MetricStats; - /** Daily image moderations */ - image_moderations_daily: MetricStats; - - // Peak metrics (total = MAX of daily values) - /** Peak concurrent users */ - concurrent_users: MetricStats; - /** Peak concurrent connections */ - concurrent_connections: MetricStats; - - // Rolling/cumulative metrics (total = LATEST daily value) - /** Total users */ - users_total: MetricStats; - /** Users active in last 24 hours */ - users_last_24_hours: MetricStats; - /** MAU - users active in last 30 days */ - users_last_30_days: MetricStats; - /** Users active this month */ - users_month_to_date: MetricStats; - /** Engaged MAU */ - users_engaged_last_30_days: MetricStats; - /** Engaged users this month */ - users_engaged_month_to_date: MetricStats; - /** Total messages */ - messages_total: MetricStats; - /** Messages in last 24 hours */ - messages_last_24_hours: MetricStats; - /** Messages in last 30 days */ - messages_last_30_days: MetricStats; - /** Messages this month */ - messages_month_to_date: MetricStats; -}; +export type ReplacePropertyTypes< + Base, + Replacement extends RequireAtLeastOne>, +> = keyof Replacement extends keyof Base + ? Omit & { + [K in keyof Replacement as undefined extends Base[K] ? never : K]: Replacement[K]; + } & { + [K in keyof Replacement as undefined extends Base[K] ? K : never]?: Replacement[K]; + } + : never; -/** - * Options for querying team-level usage statistics - */ -export type QueryTeamUsageStatsOptions = { - /** - * Month in YYYY-MM format (e.g., '2026-01'). - * Mutually exclusive with start_date/end_date. - * Returns aggregated monthly values. - */ - month?: string; - /** - * Start date in YYYY-MM-DD format. - * Used with end_date for custom date range. - * Returns daily breakdown. - */ - start_date?: string; - /** - * End date in YYYY-MM-DD format. - * Used with start_date for custom date range. - * Returns daily breakdown. - */ - end_date?: string; - /** Maximum number of teams to return per page (default: 30, max: 30) */ - limit?: number; - /** Cursor for pagination to fetch next page of teams */ - next?: string; -}; +export type PartializeAllBut = { + [P in K]-?: T[P]; +} & { [P in Exclude]?: T[P] }; -/** - * Response containing team-level usage statistics - */ -export type QueryTeamUsageStatsResponse = APIResponse & { - /** Array of team usage statistics */ - teams: TeamUsageStats[]; - /** Cursor for pagination to fetch next page */ - next?: string; -}; +export type DeleteMessageOptions = Omit[0], 'id'>; +export type SendMessageAPIResponse = StreamResponse; +export type UpdateMessageOptions = Omit; +export type UpdateMessageAPIResponse = StreamResponse; +export type GiphyVersions = keyof Images; +export type TranslationLanguage = TranslateMessageRequest['language']; -export type RetentionPolicyConfig = { - max_age_hours: number; -}; +export * from './gen/models'; -export type RetentionPolicy = { - app_pk: number; - policy: string; - config: RetentionPolicyConfig; - enabled_at: string; -}; +// Re-added during the OpenAPI merge: hand-written types dropped by the types.ts auto-merge but still +// referenced by the paginator/message-delivery code. See TODO(openapi-merge). +export type AscDesc = 1 | -1; -export type SetRetentionPolicyResponse = APIResponse & { - policy: RetentionPolicy; +export type EventAPIResponse = APIResponse & { + event: Event; }; -export type DeleteRetentionPolicyResponse = APIResponse; - -export type GetRetentionPolicyResponse = APIResponse & { - policies: RetentionPolicy[]; -}; +export type PartializeKeys = Partial> & Omit; -export type RetentionRunStats = { - channels_deleted?: number; - messages_deleted?: number; +type ErrorResponseDetails = { + code: number; + messages: string[]; }; -export type RetentionRunResponse = { - app_pk: number; - policy: string; - date: string; - stats: RetentionRunStats; +export type APIErrorResponse = { + duration: string; + message: string; + more_info: string; + StatusCode: number; + code?: number; + details?: ErrorResponseDetails; }; -export type GetRetentionPolicyRunsOptions = { - filter_conditions?: Record; - sort?: Array<{ field: string; direction: 1 | -1 }>; - next?: string; - prev?: string; - limit?: number; +export type DraftMessagePayload = PartializeKeys< + Omit, + 'id' +> & { + user_id?: string; }; -export type GetRetentionPolicyRunsResponse = APIResponse & { - runs: RetentionRunResponse[]; - next?: string; - prev?: string; +export type QueryRemindersOptions = Pager & { + filter?: ReminderFilters; + sort?: ReminderSort; }; diff --git a/src/uploadManager.ts b/src/uploadManager.ts index 738ee9254e..a9c0db4edd 100644 --- a/src/uploadManager.ts +++ b/src/uploadManager.ts @@ -1,8 +1,11 @@ import type { StreamChat } from './client'; +import { chatLoggerSystem } from './logger'; import type { UploadRequestOptions } from './messageComposer/configuration/types'; import { StateStore } from './store'; import type { AttachmentManager } from '.'; +const logger = chatLoggerSystem.getLogger('upload-manager'); + export type UploadRecord = { id: string; uploadProgress?: number; @@ -150,6 +153,11 @@ export class UploadManager { ); resolvePromise(response); } catch (error) { + if (!abortController.signal.aborted) { + logger + .withExtraTags('upload', channelCid) + .error(`Upload "${id}" failed.`, { error }); + } rejectPromise(error); } finally { this.inFlightUploads.delete(id); diff --git a/src/utils.ts b/src/utils.ts index d50551d9f9..dbbbc60d9a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,19 +2,17 @@ import FormData from 'form-data'; import type { AscDesc, ChannelFilters, - ChannelQueryOptions, + ChannelGetOrCreateRequest, ChannelSort, - ChannelSortBase, + ChannelStateResponse, LocalMessage, - LocalMessageBase, - Message, + MessageRequest, MessageResponse, - MessageResponseBase, OwnUserBase, OwnUserResponse, PromoteChannelParams, - QueryChannelAPIResponse, ReactionGroupResponse, + SortParamRequest, UpdatedMessage, UserResponse, } from './types'; @@ -22,14 +20,16 @@ import type { StreamChat } from './client'; import type { Channel } from './channel'; import type { AxiosRequestConfig } from 'axios'; import { LOCAL_MESSAGE_FIELDS, RESERVED_UPDATED_MESSAGE_FIELDS } from './constants'; +import { chatLoggerSystem } from './logger'; + +const logger = chatLoggerSystem.getLogger('utils'); /** * logChatPromiseExecution - utility function for logging the execution of a promise.. * use this when you want to run the promise and handle errors by logging a warning * - * @param {Promise} promise The promise you want to run and log - * @param {string} name A descriptive name of what the promise does for log output - * + * @param promise - The promise you want to run and log + * @param name - A descriptive name of what the promise does for log output */ export function logChatPromiseExecution(promise: Promise, name: string) { promise.then().catch((error) => { @@ -151,12 +151,21 @@ export function addFileToFormData( return data; } export function normalizeQuerySort>( - sort: T | T[], + sort: T | T[] | SortParamRequest[], ) { - const sortFields: Array<{ direction: AscDesc; field: keyof T }> = []; + const sortFields: Array<{ direction: AscDesc; field: string }> = []; const sortArr = Array.isArray(sort) ? sort : [sort]; for (const item of sortArr) { - const entries = Object.entries(item) as [keyof T, AscDesc][]; + // OpenAPI `SortParamRequest` (`{ field, direction }`) is already a normalized term — use it as-is. + if (item && typeof (item as SortParamRequest).field === 'string') { + const term = item as SortParamRequest; + sortFields.push({ + direction: (term.direction ?? 1) as AscDesc, + field: term.field as string, + }); + continue; + } + const entries = Object.entries(item) as [string, AscDesc][]; if (entries.length > 1) { console.warn( "client._buildSort() - multiple fields in a single sort object detected. Object's field order is not guaranteed", @@ -172,7 +181,7 @@ export function normalizeQuerySort /** * retryInterval - A retry interval which increases acc to number of failures * - * @return {number} Duration to wait in milliseconds + * @returns Duration to wait in milliseconds */ export function retryInterval(numberOfFailures: number) { // try to reconnect in 0.25-25 seconds (random to spread out the load from failures) @@ -319,68 +328,40 @@ export const axiosParamsSerializer: AxiosRequestConfig['paramsSerializer'] = (pa * Takes the message object, parses the dates, sets `__html` * and sets the status to `received` if missing; returns a new LocalMessage object. * - * @param {LocalMessage} message `LocalMessage` object + * @param message - `LocalMessage` object */ -export function formatMessage( - message: MessageResponse | MessageResponseBase | LocalMessage, -): LocalMessage { +export function formatMessage(message: MessageResponse | LocalMessage): LocalMessage { const toLocalMessageBase = ( - msg: MessageResponse | MessageResponseBase | LocalMessage | null | undefined, - ): LocalMessageBase | null => { + msg: MessageResponse | LocalMessage | null | undefined, + ): LocalMessage | null => { if (!msg) return null; return { ...msg, created_at: msg.created_at ? new Date(msg.created_at) : new Date(), - deleted_at: msg.deleted_at ? new Date(msg.deleted_at) : null, - pinned_at: msg.pinned_at ? new Date(msg.pinned_at) : null, + deleted_at: msg.deleted_at ? new Date(msg.deleted_at) : undefined, + pinned_at: msg.pinned_at ? new Date(msg.pinned_at) : undefined, reaction_groups: maybeGetReactionGroupsFallback( msg.reaction_groups, msg.reaction_counts, msg.reaction_scores, ), - status: msg.status || 'received', + status: (msg as LocalMessage).status || 'received', updated_at: msg.updated_at ? new Date(msg.updated_at) : new Date(), }; }; return { ...toLocalMessageBase(message), - error: (message as LocalMessage).error ?? null, - quoted_message: toLocalMessageBase((message as MessageResponse).quoted_message), + error: (message as LocalMessage).error ?? undefined, + quoted_message: + toLocalMessageBase((message as MessageResponse).quoted_message) ?? undefined, } as LocalMessage; } -/** - * @private - * - * Takes a LocalMessage, parses the dates back to strings, - * and converts the message back to a MessageResponse. - * - * @param {MessageResponse} message `MessageResponse` object - */ -export function unformatMessage(message: LocalMessage): MessageResponse { - const toMessageResponseBase = ( - msg: LocalMessage | null | undefined, - ): MessageResponseBase | null => { - if (!msg) return null; - const newDateString = new Date().toISOString(); - return { - ...msg, - created_at: message.created_at ? message.created_at.toISOString() : newDateString, - deleted_at: message.deleted_at ? message.deleted_at.toISOString() : undefined, - pinned_at: message.pinned_at ? message.pinned_at.toISOString() : undefined, - updated_at: message.updated_at ? message.updated_at.toISOString() : newDateString, - }; - }; - - return { - ...toMessageResponseBase(message), - quoted_message: toMessageResponseBase((message as LocalMessage).quoted_message), - } as MessageResponse; -} - -export const localMessageToNewMessagePayload = (localMessage: LocalMessage): Message => { - /* eslint-disable @typescript-eslint/no-unused-vars */ +export const localMessageToNewMessagePayload = ( + localMessage: LocalMessage, +): MessageRequest => { + /* eslint-disable unused-imports/no-unused-vars -- destructure-to-omit: fields intentionally stripped from the payload */ const { // Remove all timestamp fields and client-specific fields. // Field pinned_at can therefore be earlier than created_at as new message payload can hold it. @@ -396,22 +377,24 @@ export const localMessageToNewMessagePayload = (localMessage: LocalMessage): Mes reaction_counts, reaction_scores, reply_count, - // Message text related fields that shouldn't be in update + // MessageRequest text related fields that shouldn't be in update command, html, i18n, mentioned_groups, quoted_message, mentioned_users, - // Message content related fields + // MessageRequest content related fields ...messageFields } = localMessage; + /* eslint-enable unused-imports/no-unused-vars */ + // `messageFields` still carries LocalMessage-only fields (cid, deleted_reply_count, mentioned_*, + // pinned, shadowed, …) that the stricter OpenAPI `MessageRequest` omits; the server ignores them. return { ...messageFields, - pinned_at: messageFields.pinned_at?.toISOString(), mentioned_users: mentioned_users?.map((user) => user.id), - }; + } as MessageRequest; }; export const toUpdatedMessagePayload = ( @@ -442,7 +425,7 @@ export const toDeletedMessage = ({ deletedAt, hardDelete = false, }: { - message: LocalMessage | LocalMessageBase; + message: LocalMessage | LocalMessage; deletedAt: LocalMessage['deleted_at']; hardDelete: boolean; }) => { @@ -450,7 +433,7 @@ export const toDeletedMessage = ({ /** * In case of hard delete, we need to strip down all text, html, attachments and all the custom properties on message * The hard-deleted message is kept in the UI until the messages are re-queried - * FIXME: we are returning an object that does not match LocalMessage | LocalMessageBase + * FIXME: we are returning an object that does not match LocalMessage | LocalMessage */ return { attachments: [], @@ -572,7 +555,7 @@ function maybeGetReactionGroupsFallback( groups: { [key: string]: ReactionGroupResponse } | null | undefined, counts: { [key: string]: number } | null | undefined, scores: { [key: string]: number } | null | undefined, -): { [key: string]: ReactionGroupResponse } | null { +): { [key: string]: ReactionGroupResponse } | undefined { if (groups) { return groups; } @@ -581,19 +564,20 @@ function maybeGetReactionGroupsFallback( const fallback: { [key: string]: ReactionGroupResponse } = {}; for (const type of Object.keys(counts)) { + // Best-effort fallback derived from counts/scores; the richer OpenAPI `ReactionGroupResponse` + // fields (first/last_reaction_at, latest_reactions_by) are not available here. fallback[type] = { count: counts[type], sum_scores: scores[type], - }; + } as ReactionGroupResponse; } return fallback; } - return null; + return undefined; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any export interface DebouncedFunc any> { /** * Call the original function, but applying the debounce rules. @@ -622,7 +606,7 @@ export interface DebouncedFunc any> { } // works exactly the same as lodash.debounce -// eslint-disable-next-line @typescript-eslint/no-explicit-any + export const debounce = any>( fn: T, timeout = 0, @@ -670,7 +654,7 @@ export const debounce = any>( }; // works exactly the same as lodash.throttle -// eslint-disable-next-line @typescript-eslint/no-explicit-any + export const throttle = any>( fn: T, timeout = 200, @@ -738,7 +722,7 @@ export const uniqBy = ( */ const WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL: Record< string, - Promise | undefined + Promise | undefined > = {}; type GetChannelParams = { @@ -746,18 +730,20 @@ type GetChannelParams = { channel?: Channel; id?: string; members?: string[]; - options?: ChannelQueryOptions; + options?: ChannelGetOrCreateRequest; type?: string; }; /** * Calls channel.watch() if it was not already recently called. Waits for watch promise to resolve even if it was invoked previously. * If the channel is not passed as a property, it will get it either by its channel.cid or by its members list and do the same. - * @param client - * @param members - * @param options - * @param type - * @param id - * @param channel + * + * @param params - The channel query parameters. + * @param params.client - The chat client instance. + * @param params.members - Member user ids used to construct or identify the channel. + * @param params.options - Options forwarded to the underlying channel watch request. + * @param params.type - The channel type. + * @param params.id - The channel id. + * @param params.channel - An existing channel to watch (skips construction from type/id/members). */ export const getAndWatchChannel = async ({ channel, @@ -772,8 +758,13 @@ export const getAndWatchChannel = async ({ } // unfortunately typescript is not able to infer that if (!channel && !type) === false, then channel or type has to be truthy - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const channelToWatch = channel || client.channel(type!, id, { members }); + + const channelToWatch = + channel || + // `members` are member IDs; the OpenAPI `ChannelData.members` expects member objects. + client.channel(type as string, id, { + members: members?.map((user_id) => ({ user_id })), + }); // need to keep as with call to channel.watch the id can be changed from undefined to an actual ID generated server-side const originalCid = channelToWatch.id @@ -808,8 +799,9 @@ export const getAndWatchChannel = async ({ * Generates a temporary channel.cid for channels created without ID, as they need to be referenced * by an identifier until the back-end generates the final ID. The cid is generated by its member IDs * which are sorted and can be recreated the same every time given the same arguments. - * @param channelType - * @param members + * + * @param channelType - The channel type. + * @param members - The member ids used to build the temporary cid. */ export const generateChannelTempCid = (channelType: string, members: string[]) => { if (!members) return; @@ -820,7 +812,8 @@ export const generateChannelTempCid = (channelType: string, members: string[]) = /** * Checks if a channel is pinned or not. Will return true only if channel.state.membership.pinned_at exists. - * @param channel + * + * @param channel - The channel to check. */ export const isChannelPinned = (channel: Channel) => { if (!channel) return false; @@ -832,7 +825,8 @@ export const isChannelPinned = (channel: Channel) => { /** * Checks if a channel is archived or not. Will return true only if channel.state.membership.archived_at exists. - * @param channel + * + * @param channel - The channel to check. */ export const isChannelArchived = (channel: Channel) => { if (!channel) return false; @@ -845,9 +839,10 @@ export const isChannelArchived = (channel: Channel) => { /** * A utility that tells us whether we should consider archived channels or not based * on filters. Will return true only if filters.archived exists and is a boolean value. - * @param filters + * + * @param filters - The channel filters to inspect. */ -export const shouldConsiderArchivedChannels = (filters: ChannelFilters) => { +export const shouldConsiderArchivedChannels = (filters: ChannelFilters | undefined) => { if (!filters) return false; return typeof filters.archived === 'boolean'; @@ -857,9 +852,11 @@ export const shouldConsiderArchivedChannels = (filters: ChannelFilters) => { * Extracts the value of the sort parameter at a given index, for a targeted key. Can * handle both array and object versions of sort. Will return null if the index/key * combination does not exist. - * @param atIndex - the index at which we'll examine the sort value, if it's an array one - * @param sort - the sort value - both array and object notations are accepted - * @param targetKey - the target key which needs to exist for the sort at a certain index + * + * @param params - The extraction parameters. + * @param params.atIndex - the index at which we'll examine the sort value, if it's an array one + * @param params.sort - the sort value - both array and object notations are accepted + * @param params.targetKey - the target key which needs to exist for the sort at a certain index */ export const extractSortValue = ({ atIndex, @@ -867,33 +864,15 @@ export const extractSortValue = ({ targetKey, }: { atIndex: number; - targetKey: keyof ChannelSortBase; + targetKey: string; sort?: ChannelSort; }) => { if (!sort) return null; - let option: null | ChannelSortBase = null; - - if (Array.isArray(sort)) { - option = sort[atIndex] ?? null; - } else { - let index = 0; - for (const key in sort) { - if (index !== atIndex) { - index++; - continue; - } - - if (key !== targetKey) { - return null; - } - - option = sort; - - break; - } - } - - return option?.[targetKey] ?? null; + // `ChannelSort` is now `SortParamRequest[]` (`{ field, direction }[]`). Return the `direction` of + // the entry at `atIndex` when its `field` matches `targetKey`, otherwise null. + const option = sort[atIndex] ?? null; + if (!option || option.field !== targetKey) return null; + return option.direction ?? null; }; /** @@ -910,7 +889,9 @@ export const shouldConsiderPinnedChannels = (sort: ChannelSort) => { /** * Checks whether the sort value of type object contains a pinned_at value or if * an array sort value type has the first value be an object containing pinned_at. - * @param sort + * + * @param params - The sort container. + * @param params.sort - The sort value to inspect for a `pinned_at` order. */ export const findPinnedAtSortOrder = ({ sort }: { sort: ChannelSort }) => extractSortValue({ @@ -923,7 +904,9 @@ export const findPinnedAtSortOrder = ({ sort }: { sort: ChannelSort }) => * Finds the index of the last consecutively pinned channel, starting from the start of the * array. Will not consider any pinned channels after the contiguous subsequence at the * start of the array. - * @param channels + * + * @param params - The channel list container. + * @param params.channels - The channels to scan from the start of the array. */ export const findLastPinnedChannelIndex = ({ channels }: { channels: Channel[] }) => { let lastPinnedChannelIndex: number | null = null; @@ -945,10 +928,12 @@ export const findLastPinnedChannelIndex = ({ channels }: { channels: Channel[] } * A utility used to move a channel towards the beginning of a list of channels (promote it to a higher position). It * considers pinned channels in the process if needed and makes sure to only update the list reference if the list * should actually change. It will try to move the channel as high as it can within the list. - * @param channels - the list of channels we want to modify - * @param channelToMove - the channel we want to promote - * @param channelToMoveIndexWithinChannels - optionally, the index of the channel we want to move if we know it (will skip a manual check) - * @param sort - the sort value used to check for pinned channels + * + * @param params - The promotion parameters. + * @param params.channels - the list of channels we want to modify + * @param params.channelToMove - the channel we want to promote + * @param params.channelToMoveIndexWithinChannels - optionally, the index of the channel we want to move if we know it (will skip a manual check) + * @param params.sort - the sort value used to check for pinned channels */ export const promoteChannel = ({ channels, @@ -1013,7 +998,9 @@ export const runDetached = ( ) => { const { context, onSuccessCallback, onErrorCallback } = options ?? {}; const defaultOnError = (error: Error) => { - console.log(`An error has occurred in context ${context}: ${error}`); + logger + .withExtraTags('runDetached') + .error(`An error occurred in context "${context}".`, { error }); }; const onError = onErrorCallback ?? defaultOnError; @@ -1027,11 +1014,18 @@ export const runDetached = ( }; export const isBlockedMessage = (message: LocalMessage) => - message.type === 'error' && - (message.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_REMOVE' || - message.moderation?.action === 'remove'); + message.type === 'error' && message.moderation?.action === 'remove'; export const isBouncedMessage = (message: LocalMessage) => - message.type === 'error' && - (message?.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_BOUNCE' || - message?.moderation?.action === 'bounce'); + message.type === 'error' && message?.moderation?.action === 'bounce'; + +export const getEnv = (envKey: keyof NodeJS.ProcessEnv) => { + if ( + typeof process !== 'undefined' && + (Object.hasOwn(process, 'env') || 'env' in process) + ) { + return process.env[envKey]; + } + + return undefined; +}; diff --git a/src/utils/FixedSizeQueueCache.ts b/src/utils/FixedSizeQueueCache.ts index 9b5c6de57a..a1307d1cb6 100644 --- a/src/utils/FixedSizeQueueCache.ts +++ b/src/utils/FixedSizeQueueCache.ts @@ -2,6 +2,7 @@ type Dispose = (key: K, value: T) => void; /** * A cache that stores a fixed number of values in a queue. * The most recently added or retrieved value is kept at the front of the queue. + * * @template K - The type of the keys. * @template T - The type of the values. */ @@ -20,9 +21,10 @@ export class FixedSizeQueueCache { } /** - * Adds a new or moves the existing reference to the front of the queue - * @param key - * @param value + * Adds a new entry or moves the existing reference to the front of the queue. + * + * @param key - The cache key. + * @param value - The value to associate with `key`. */ add(key: K, value: T) { const index = this.keys.indexOf(key); @@ -48,8 +50,10 @@ export class FixedSizeQueueCache { } /** - * Retrieves the value by key. - * @param key + * Retrieves the value by key without changing its position in the queue. + * + * @param key - The cache key. + * @returns The value, or `undefined` when the key is not cached. */ peek(key: K) { const value = this.map.get(key); @@ -59,7 +63,9 @@ export class FixedSizeQueueCache { /** * Retrieves the value and moves it to the front of the queue. - * @param key + * + * @param key - The cache key. + * @returns The value, or `undefined` when the key is not cached. */ get(key: K) { const foundItem = this.peek(key); diff --git a/src/utils/WithSubscriptions.ts b/src/utils/WithSubscriptions.ts index 7c0dddf2a0..46e1a5688d 100644 --- a/src/utils/WithSubscriptions.ts +++ b/src/utils/WithSubscriptions.ts @@ -1,8 +1,9 @@ import type { Unsubscribe } from '../store'; /** - * @private * Class to use as a template for subscribable entities. + * + * @internal */ export abstract class WithSubscriptions { private unsubscribeFunctions: Set = new Set(); diff --git a/src/utils/concurrency.ts b/src/utils/concurrency.ts index abb985cdfc..99f912b3ab 100644 --- a/src/utils/concurrency.ts +++ b/src/utils/concurrency.ts @@ -16,9 +16,9 @@ type AsyncWrapper

= ( * should never run simultaneously: if marked with the same tag, functions * will run one after another. * - * @param tag Async functions with the same tag will run serially. Async functions + * @param tag - Async functions with the same tag will run serially. Async functions * with different tags can run in parallel. - * @param cb Async function to run. + * @param cb - Async function to run. * @returns Promise that resolves when async functions returns. */ export const withoutConcurrency = createRunner(wrapWithContinuationTracking); @@ -32,9 +32,9 @@ export const withoutConcurrency = createRunner(wrapWithContinuationTracking); * If an async function is already running and was canceled, it will be notified * via an abort signal passed as an argument. * - * @param tag Async functions with the same tag will run serially and are canceled + * @param tag - Async functions with the same tag will run serially and are canceled * when a new action with the same tag is scheduled. - * @param cb Async function to run. Receives AbortSignal as the only argument. + * @param cb - Async function to run. Receives AbortSignal as the only argument. * @returns Promise that resolves when async functions returns. If the function didn't * start and was canceled, will resolve with 'canceled'. If the function started to run, * it's up to the function to decide how to react to cancelation. diff --git a/src/utils/mergeWith/mergeWith.ts b/src/utils/mergeWith/mergeWith.ts index 8010f4116b..1f4cfd9082 100644 --- a/src/utils/mergeWith/mergeWith.ts +++ b/src/utils/mergeWith/mergeWith.ts @@ -6,9 +6,9 @@ * (objValue, srcValue, key, object, source, stack). * * @category Object - * @param object The destination object. - * @param source A single source object or an array of objects to be merged into the . - * @param customizer The function to customize assigned values. + * @param object - The destination object. + * @param source - A single source object or an array of objects to be merged into the . + * @param customizer - The function to customize assigned values. * @returns Returns `object`. * @example * diff --git a/src/utils/mergeWith/mergeWithDiff.ts b/src/utils/mergeWith/mergeWithDiff.ts index 34f02e0537..c5cb44ed82 100644 --- a/src/utils/mergeWith/mergeWithDiff.ts +++ b/src/utils/mergeWith/mergeWithDiff.ts @@ -3,9 +3,9 @@ * which keys have been added or updated during the merge operation. * * @category Object - * @param object The destination object. - * @param source A single source object or an array of objects to be merged into the object. - * @param customizer The function to customize assigned values. + * @param object - The destination object. + * @param source - A single source object or an array of objects to be merged into the object. + * @param customizer - The function to customize assigned values. * @returns Returns an object containing the merged result and a hierarchical diff object. * @example * diff --git a/src/utils/retryable.ts b/src/utils/retryable.ts new file mode 100644 index 0000000000..d33b1bfe0c --- /dev/null +++ b/src/utils/retryable.ts @@ -0,0 +1,117 @@ +type FunctionToRetry = (...functionArguments: any[]) => PromiseLike; + +enum RetryErrorType { + ABORT, + ATTEMPT_LIMIT_REACHED, +} + +export class RetryError extends Error { + public name = 'RetryError'; + + private static errorMap = { + [RetryErrorType.ABORT]: 'Value changed, retry handler aborted', + [RetryErrorType.ATTEMPT_LIMIT_REACHED]: 'Reached maximum amount of retry attempts', + } satisfies Record; + + constructor({ type }: { type: RetryErrorType }) { + super(RetryError.errorMap[type]); + } +} + +export const sleep = (duration: number) => + new Promise((resolve) => setTimeout(resolve, duration)); + +// export const handleFalsePositiveResponse = < +// T extends (...v: any[]) => PromiseLike, +// >( +// f: T, +// ) => +// async function falsePositiveHandler(...functionArguments: Parameters) { +// // await or throw if "f" fails +// const data = (await f(...functionArguments)) as Awaited>; +// // check for error data, throw if exist +// if (data.errors !== null) { +// const stringifiedErrors = data.errors +// .map((error, index) => `E${index + 1}(${error.errorCode}): ${error.message}`) +// .join('\n'); + +// throw new Error(stringifiedErrors); +// } + +// return data; +// }; + +export type RunWithRetryOptions = { + retryAttempts?: number; + delayBetweenRetries?: number | ((attempt: number) => number); + isRetryable?: (error: unknown) => boolean; + didValueChange?: (...functionArguments: Parameters) => Promise | boolean; +}; + +/** + * Function which wraps asynchronous functions with retry mechanism which'll keep executing said + * function pre-defined number of times until it resolves, rejects or the retry mechanism runs out of available attempts. + * + * #### Available options: + * - `didValueChange` - check function with initial argument value to check against new values, return `true` to abort next attempt + * + * - `isRetryable` - error evaluation function which determines whether to retry the execution + * + * - `delayBetweenRetries` - accepts either fixed numeric value or function which provides retry attempt as the argument, expects number as return value + * + * - `retryAttempts` - number of attempts to try out before rejecting the promise + */ +export const runWithRetry = ( + f: T, + { + retryAttempts = 3, + delayBetweenRetries, + isRetryable, + didValueChange, + }: RunWithRetryOptions = {}, +) => + async function retryable(...functionArguments: Parameters) { + // starting with -1 as first attempt is not considered a retry + let retryAttempt = -1; + + do { + let data: Awaited> | null = null; + let error: unknown = null; + + if (await didValueChange?.(...functionArguments)) { + throw new RetryError({ type: RetryErrorType.ABORT }); + } + + try { + data = await f(...functionArguments); + } catch (e) { + error = e; + } + + // disable value change check after successfull server call + // throwing error at this point could lead to stale local states + + // if (await didValueChange?.(...functionArguments)) { + // throw new RetryError({ type: 'abort' }); + // } + + if (data) return data; + + const runRetry = isRetryable?.(error) ?? true; + if (!runRetry) throw error; + + retryAttempt++; + + const isLastAttempt = retryAttempt === retryAttempts; + + if (delayBetweenRetries && !isLastAttempt) { + await sleep( + typeof delayBetweenRetries === 'function' + ? delayBetweenRetries(retryAttempt) + : delayBetweenRetries, + ); + } + } while (retryAttempt < retryAttempts); + + throw new RetryError({ type: RetryErrorType.ATTEMPT_LIMIT_REACHED }); + }; diff --git a/test/typescript/unit-test.ts b/test/typescript/unit-test.ts index 61f11e118d..86db03f623 100644 --- a/test/typescript/unit-test.ts +++ b/test/typescript/unit-test.ts @@ -9,16 +9,16 @@ import { Permission, MaxPriority, APIResponse, - AppSettingsAPIResponse, + GetApplicationResponse, UserResponse, SendFileAPIResponse, UR, Channel, - EventTypes, + EventType, ChannelState, ChannelMemberResponse, UpdateChannelAPIResponse, - PartialUserUpdate, + UpdateUserPartialRequest, PermissionObject, PolicyRequest, ConnectAPIResponse, @@ -94,10 +94,10 @@ const authType: string = client.getAuthType(); voidReturn = client.setBaseURL('https://chat.stream-io-api.com/'); const settingsPromise: Promise = client.updateAppSettings({}); -const appPromise: Promise = client.getAppSettings(); +const appPromise: Promise = client.getAppSettings(); voidPromise = client.disconnectUser(); -const updateRequest: PartialUserUpdate = { +const updateRequest: UpdateUserPartialRequest = { id: 'vishal', set: { name: 'Awesome', @@ -148,7 +148,7 @@ const file: Promise = client.sendFile( { id: 'james' }, ); -const type: EventTypes = 'user.updated'; +const type: EventType = 'user.updated'; const event: Event = { type, cid: 'channelid', diff --git a/test/unit/ChannelPaginatorsOrchestrator.test.ts b/test/unit/ChannelPaginatorsOrchestrator.test.ts index 61c4da07b4..72192894a6 100644 --- a/test/unit/ChannelPaginatorsOrchestrator.test.ts +++ b/test/unit/ChannelPaginatorsOrchestrator.test.ts @@ -169,7 +169,9 @@ describe('ChannelPaginatorsOrchestrator', () => { it('applies ownership rules to paginators when they paginate', async () => { const ch1 = makeChannel('messaging:101'); const ch2 = makeChannel('messaging:102'); - const queryChannelSpy = vi.spyOn(client, 'queryChannels').mockResolvedValue([ch1]); + const queryChannelSpy = vi + .spyOn(client, 'queryChannelsAndHydrate') + .mockResolvedValue([ch1]); const p1 = new ChannelPaginator({ client, filters: { type: 'messaging' }, diff --git a/test/unit/CooldownTimer.test.ts b/test/unit/CooldownTimer.test.ts index 4909cbe3c4..303af4c197 100644 --- a/test/unit/CooldownTimer.test.ts +++ b/test/unit/CooldownTimer.test.ts @@ -40,8 +40,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt.toISOString(), - updated_at: lastOwnMessageAt.toISOString(), + created_at: lastOwnMessageAt, + updated_at: lastOwnMessageAt, user: { id: client.userID as string }, }), ); @@ -77,8 +77,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: now.toISOString(), - updated_at: now.toISOString(), + created_at: now, + updated_at: now, user: { id: client.userID as string }, }), ); @@ -92,8 +92,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: now.toISOString(), - updated_at: now.toISOString(), + created_at: now, + updated_at: now, user: { id: client.userID as string }, }), ); @@ -107,8 +107,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: now.toISOString(), - updated_at: now.toISOString(), + created_at: now, + updated_at: now, user: { id: client.userID as string }, }), ); @@ -140,8 +140,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt.toISOString(), - updated_at: lastOwnMessageAt.toISOString(), + created_at: lastOwnMessageAt, + updated_at: lastOwnMessageAt, user: { id: client.userID as string }, }), ); @@ -167,8 +167,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: now.toISOString(), - updated_at: now.toISOString(), + created_at: now, + updated_at: now, user: { id: client.userID as string }, }), ); @@ -191,8 +191,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt.toISOString(), - updated_at: lastOwnMessageAt.toISOString(), + created_at: lastOwnMessageAt, + updated_at: lastOwnMessageAt, user: { id: client.userID as string }, }), ); @@ -226,8 +226,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt.toISOString(), - updated_at: lastOwnMessageAt.toISOString(), + created_at: lastOwnMessageAt, + updated_at: lastOwnMessageAt, user: { id: client.userID as string }, }), ); @@ -261,8 +261,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt.toISOString(), - updated_at: lastOwnMessageAt.toISOString(), + created_at: lastOwnMessageAt, + updated_at: lastOwnMessageAt, user: { id: client.userID as string }, }), ); @@ -299,8 +299,8 @@ describe('CooldownTimer', () => { user: { id: client.userID as string }, message: generateMsg({ cid: channel.cid, // must match the paginator filter so message.new ingests into an interval - created_at: now.toISOString(), - updated_at: now.toISOString(), + created_at: now, + updated_at: now, user: { id: client.userID as string }, }), } as Event); diff --git a/test/unit/LiveLocationManager.test.ts b/test/unit/LiveLocationManager.test.ts index 922c55fe77..b9bc15ffb4 100644 --- a/test/unit/LiveLocationManager.test.ts +++ b/test/unit/LiveLocationManager.test.ts @@ -88,8 +88,8 @@ describe('LiveLocationManager', () => { describe('live location management', () => { it('retrieves the active live locations and registers subscriptions on init', async () => { const client = await getClientWithUser({ id: 'user-abc' }); - const getSharedLocationsSpy = vi - .spyOn(client, 'getSharedLocations') + const getUserLiveLocationsSpy = vi + .spyOn(client, 'getUserLiveLocations') .mockResolvedValue({ active_live_locations: [], duration: '' }); const manager = new LiveLocationManager({ client, @@ -97,16 +97,16 @@ describe('LiveLocationManager', () => { watchLocation, }); - expect(getSharedLocationsSpy).toHaveBeenCalledTimes(0); + expect(getUserLiveLocationsSpy).toHaveBeenCalledTimes(0); expect(manager.stateIsReady).toBeFalsy(); await manager.init(); - expect(getSharedLocationsSpy).toHaveBeenCalledTimes(1); + expect(getUserLiveLocationsSpy).toHaveBeenCalledTimes(1); expect(manager.hasSubscriptions).toBeTruthy(); // @ts-expect-error accessing private attribute expect(manager.refCount).toBe(1); await manager.init(); - expect(getSharedLocationsSpy).toHaveBeenCalledTimes(1); + expect(getUserLiveLocationsSpy).toHaveBeenCalledTimes(1); expect(manager.hasSubscriptions).toBeTruthy(); expect(manager.stateIsReady).toBeTruthy(); // @ts-expect-error accessing private attribute @@ -115,8 +115,8 @@ describe('LiveLocationManager', () => { it('unregisters subscriptions', async () => { const client = await getClientWithUser({ id: 'user-abc' }); - const getSharedLocationsSpy = vi - .spyOn(client, 'getSharedLocations') + const getUserLiveLocationsSpy = vi + .spyOn(client, 'getUserLiveLocations') .mockResolvedValue({ active_live_locations: [], duration: '' }); const manager = new LiveLocationManager({ client, @@ -132,11 +132,11 @@ describe('LiveLocationManager', () => { describe('message addition or removal', () => { it('does not update active location if there are no active live locations', async () => { const client = await getClientWithUser({ id: 'user-abc' }); - const getSharedLocationsSpy = vi - .spyOn(client, 'getSharedLocations') + const getUserLiveLocationsSpy = vi + .spyOn(client, 'getUserLiveLocations') .mockResolvedValue({ active_live_locations: [], duration: '' }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ @@ -152,11 +152,11 @@ describe('LiveLocationManager', () => { it('does not update active location if there are no coordinate updates', async () => { // starting from 0 const client = await getClientWithUser({ id: 'user-abc' }); - const getSharedLocationsSpy = vi - .spyOn(client, 'getSharedLocations') + const getUserLiveLocationsSpy = vi + .spyOn(client, 'getUserLiveLocations') .mockResolvedValue({ active_live_locations: [liveLocation], duration: '' }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); const manager = new LiveLocationManager({ client, @@ -170,12 +170,12 @@ describe('LiveLocationManager', () => { it('updates active location on coordinate updates', async () => { const client = await getClientWithUser({ id: 'user-abc' }); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ @@ -187,7 +187,6 @@ describe('LiveLocationManager', () => { await manager.init(); expect(updateLocationSpy).toHaveBeenCalledTimes(1); expect(updateLocationSpy).toHaveBeenCalledWith({ - created_by_device_id: liveLocation.created_by_device_id, message_id: liveLocation.message_id, ...newCoords, }); @@ -196,12 +195,12 @@ describe('LiveLocationManager', () => { it('does not update active location if returning to 0 locations', async () => { const client = await getClientWithUser({ id: 'user-abc' }); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ @@ -220,12 +219,12 @@ describe('LiveLocationManager', () => { it('requests the live location upon adding a first message', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [], duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ @@ -255,12 +254,12 @@ describe('LiveLocationManager', () => { it('does not perform live location update request upon adding subsequent messages within min throttle timeout', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [], duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ @@ -298,12 +297,12 @@ describe('LiveLocationManager', () => { it('does not request live location upon adding subsequent messages beyond min throttle timeout', async () => { vi.useFakeTimers(); const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [], duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValueOnce(liveLocation) .mockResolvedValueOnce(liveLocation2); const newCoords = { latitude: 2, longitude: 2 }; @@ -347,12 +346,12 @@ describe('LiveLocationManager', () => { it('throttles live location update requests upon multiple watcher coords emissions under min throttle timeout', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); let watchHandler: WatchLocationHandler = () => { throw new Error('XX'); @@ -382,12 +381,12 @@ describe('LiveLocationManager', () => { it('allows live location update requests upon multiple watcher coords emissions beyond min throttle timeout', async () => { vi.useFakeTimers(); const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); let watchHandler: WatchLocationHandler = () => { throw new Error('XX'); @@ -424,7 +423,7 @@ describe('LiveLocationManager', () => { it('prevents live location update requests for expired live locations', async () => { vi.useFakeTimers(); const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [ { ...liveLocation, @@ -436,7 +435,7 @@ describe('LiveLocationManager', () => { duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); let watchHandler: WatchLocationHandler = () => { throw new Error('XX'); @@ -474,11 +473,11 @@ describe('LiveLocationManager', () => { describe('live_location_sharing.started', () => { it('registers a new message', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -506,11 +505,11 @@ describe('LiveLocationManager', () => { describe('message.updated', () => { it('registers a new message if not yet registered', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -536,11 +535,11 @@ describe('LiveLocationManager', () => { it('updates location for registered message', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [{ ...liveLocation, end_at: new Date().toISOString() }], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -571,11 +570,11 @@ describe('LiveLocationManager', () => { it('does not register a new message if it does not contain a live location', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -596,11 +595,11 @@ describe('LiveLocationManager', () => { it('does not register a new message if it does not contain user', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -625,11 +624,11 @@ describe('LiveLocationManager', () => { it('unregisters a message if the updated message does not contain a live location', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -655,11 +654,11 @@ describe('LiveLocationManager', () => { it('unregisters a message if its live location has been changed to static location', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -686,11 +685,11 @@ describe('LiveLocationManager', () => { it('unregisters a message if the updated message has end_at in the past', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -719,11 +718,11 @@ describe('LiveLocationManager', () => { describe('live_location_sharing.stopped', () => { it('unregisters a message', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -746,11 +745,11 @@ describe('LiveLocationManager', () => { describe('message.deleted', () => { it('unregisters a message', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -779,11 +778,11 @@ describe('LiveLocationManager', () => { describe('getters', async () => { it('deviceId is calculated only once', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const getDeviceId = vi .fn() .mockReturnValueOnce(deviceId) diff --git a/test/unit/MessageComposer/LocationComposer.test.ts b/test/unit/MessageComposer/LocationComposer.test.ts index e86267ed7d..65e5a0770c 100644 --- a/test/unit/MessageComposer/LocationComposer.test.ts +++ b/test/unit/MessageComposer/LocationComposer.test.ts @@ -159,10 +159,10 @@ describe('LocationComposer', () => { created_by_device_id: deviceId, latitude: data.latitude, longitude: data.longitude, - end_at: expect.any(String), + end_at: expect.any(Date), }); - const endAt = new Date(locationComposer.validLocation!.end_at); + const endAt = locationComposer.validLocation!.end_at as Date; const expectedEndAt = new Date(Date.now() + data.durationMs); expect(endAt.getTime()).toBeCloseTo(expectedEndAt.getTime(), -2); // Within 100ms }); diff --git a/test/unit/MessageComposer/attachmentIdentity.test.ts b/test/unit/MessageComposer/attachmentIdentity.test.ts index a1d8873ded..3d136cfd4f 100644 --- a/test/unit/MessageComposer/attachmentIdentity.test.ts +++ b/test/unit/MessageComposer/attachmentIdentity.test.ts @@ -81,34 +81,40 @@ describe('attachmentIdentity', () => { }); it('should return true for attachments with mime_type not in supportedVideoFormat', () => { - const attachment = { mime_type: 'application/pdf', type: 'audio' }; + const attachment = { + custom: { mime_type: 'application/pdf' }, + type: 'audio', + }; expect(isFileAttachment(attachment, ['video/mp4'])).toBe(true); }); it('should return false for attachments with mime_type not in supportedVideoFormat but declared as video type', () => { - const attachment = { mime_type: 'application/pdf', type: 'video' }; + const attachment = { + custom: { mime_type: 'application/pdf' }, + type: 'video', + }; expect(isFileAttachment(attachment, ['video/mp4'])).toBe(false); }); it('should return false for attachments with mime_type in supportedVideoFormat', () => { - const attachment = { mime_type: 'video/mp4', type: 'video' }; + const attachment = { custom: { mime_type: 'video/mp4' }, type: 'video' }; expect(isFileAttachment(attachment, ['video/mp4'])).toBe(false); }); }); describe('isLocalFileAttachment', () => { it('should return true for local file attachments', () => { - const attachment = { type: 'file', localMetadata: { id: 'test-id' } }; + const attachment = { custom: {}, type: 'file', localMetadata: { id: 'test-id' } }; expect(isLocalFileAttachment(attachment)).toBe(true); }); it('should return false for non-local file attachments', () => { - const attachment = { type: 'file' }; + const attachment = { custom: {}, type: 'file' }; expect(isLocalFileAttachment(attachment)).toBe(false); }); it('should return false for local non-file attachments', () => { - const attachment = { type: 'image', localMetadata: { id: 'test-id' } }; + const attachment = { custom: {}, type: 'image', localMetadata: { id: 'test-id' } }; expect(isLocalFileAttachment(attachment)).toBe(false); }); }); @@ -212,29 +218,29 @@ describe('attachmentIdentity', () => { }); it('should return true for attachments with mime_type in supportedVideoFormat', () => { - const attachment = { mime_type: 'video/mp4' }; + const attachment = { custom: { mime_type: 'video/mp4' } }; expect(isVideoAttachment(attachment, ['video/mp4'])).toBe(true); }); it('should return false for attachments with mime_type not in supportedVideoFormat', () => { - const attachment = { mime_type: 'application/pdf' }; + const attachment = { custom: { mime_type: 'application/pdf' } }; expect(isVideoAttachment(attachment, ['video/mp4'])).toBe(false); }); }); describe('isLocalVideoAttachment', () => { it('should return true for local video attachments', () => { - const attachment = { type: 'video', localMetadata: { id: 'test-id' } }; + const attachment = { custom: {}, type: 'video', localMetadata: { id: 'test-id' } }; expect(isLocalVideoAttachment(attachment)).toBe(true); }); it('should return false for non-local video attachments', () => { - const attachment = { type: 'video' }; + const attachment = { custom: {}, type: 'video' }; expect(isLocalVideoAttachment(attachment)).toBe(false); }); it('should return false for local non-video attachments', () => { - const attachment = { type: 'file', localMetadata: { id: 'test-id' } }; + const attachment = { custom: {}, type: 'file', localMetadata: { id: 'test-id' } }; expect(isLocalVideoAttachment(attachment)).toBe(false); }); }); diff --git a/test/unit/MessageComposer/attachmentManager.test.ts b/test/unit/MessageComposer/attachmentManager.test.ts index ddcc5dcac4..60ee8d9ee1 100644 --- a/test/unit/MessageComposer/attachmentManager.test.ts +++ b/test/unit/MessageComposer/attachmentManager.test.ts @@ -11,7 +11,7 @@ import { LocalMessage, StreamChat, } from '../../../src'; -import { AppSettings } from '../../../src'; +import { AppResponseFields } from '../../../src'; import * as Utils from '../../../src/utils'; import { beforeEach } from 'node:test'; @@ -88,7 +88,7 @@ const setup = ({ composition, config, }: { - appSettings?: Partial; + appSettings?: Partial; composition?: DraftResponse | LocalMessage; config?: Partial; } = {}) => { @@ -1362,17 +1362,17 @@ describe('AttachmentManager', () => { ), ).resolves.toEqual({ fallback: 'test.jpg', - file_size: 0, + custom: { file_size: 0, mime_type: 'image/jpeg' }, localMetadata: { id: expect.any(String), file, uploadState: 'failed', + uploadProgress: undefined, previewUri: expect.any(String), uploadPermissionCheck: { uploadBlocked: false, }, }, - mime_type: 'image/jpeg', type: 'image', }); @@ -1966,17 +1966,17 @@ describe('AttachmentManager', () => { await expect(attachmentManager.uploadFiles([file])).resolves.toEqual([ { fallback: 'test.jpg', - file_size: 0, + custom: { file_size: 0, mime_type: 'image/jpeg' }, localMetadata: { id: expect.any(String), file, uploadState: 'failed', + uploadProgress: undefined, previewUri: expect.any(String), uploadPermissionCheck: { uploadBlocked: false, }, }, - mime_type: 'image/jpeg', type: 'image', }, ]); @@ -2275,8 +2275,7 @@ describe('AttachmentManager', () => { const file = new File([fileContent], 'test.jpg', { type: 'image/jpeg' }); const result = await attachmentManager.fileToLocalUploadAttachment(file); expect(result).toMatchObject({ - file_size: 1234, - mime_type: 'image/jpeg', + custom: { file_size: 1234, mime_type: 'image/jpeg' }, type: 'image', localMetadata: expect.objectContaining({ file, @@ -2312,8 +2311,7 @@ describe('AttachmentManager', () => { expect(createObjectURLSpy).toHaveBeenCalledWith(file); expect(result).toMatchObject({ - file_size: 3, - mime_type: 'application/pdf', + custom: { file_size: 3, mime_type: 'application/pdf' }, type: 'file', localMetadata: expect.objectContaining({ file, @@ -2348,8 +2346,7 @@ describe('AttachmentManager', () => { }; const result = await attachmentManager.fileToLocalUploadAttachment(fileReference); expect(result).toMatchObject({ - file_size: 1234, - mime_type: 'image/jpeg', + custom: { file_size: 1234, mime_type: 'image/jpeg' }, type: 'image', localMetadata: expect.objectContaining({ file: fileReference, @@ -2389,8 +2386,7 @@ describe('AttachmentManager', () => { }; const result = await attachmentManager.fileToLocalUploadAttachment(fileReference); expect(result).toMatchObject({ - file_size: 4321, - mime_type: 'video/mp4', + custom: { file_size: 4321, mime_type: 'video/mp4' }, type: 'video', localMetadata: expect.objectContaining({ file: fileReference, @@ -2398,7 +2394,6 @@ describe('AttachmentManager', () => { uploadState: 'pending', }), title: 'test.mp4', - duration: 12.34, thumb_url: 'file://thumb.jpg', }); expect(result.localMetadata.previewUri).toBe('file://test.mp4'); diff --git a/test/unit/MessageComposer/linkPreviewsManager.test.ts b/test/unit/MessageComposer/linkPreviewsManager.test.ts index e08d7af8c2..4d7860778a 100644 --- a/test/unit/MessageComposer/linkPreviewsManager.test.ts +++ b/test/unit/MessageComposer/linkPreviewsManager.test.ts @@ -30,10 +30,12 @@ vi.mock('../../src/utils', () => ({ debouncedFn.flush = vi.fn(); return debouncedFn; }), + getEnv: vi.fn(), })); vi.mock('../../src/utils/mergeWith', () => ({ mergeWith: vi.fn().mockImplementation((target, source) => ({ ...target, ...source })), + getEnv: vi.fn(), })); vi.mock('linkifyjs', () => ({ @@ -85,8 +87,9 @@ const setup = ({ vi.clearAllMocks(); // Setup mocks - const mockClient = new StreamChat('apiKey', 'apiSecret'); - mockClient.enrichURL = vi.fn().mockResolvedValue(enrichURLReturnValue); + const mockClient = new StreamChat('apiKey'); + mockClient.user = { id: 'user' }; + mockClient.getOG = vi.fn().mockResolvedValue(enrichURLReturnValue); const mockChannel = mockClient.channel('channelType', 'channelId'); mockChannel.getConfig = vi.fn().mockImplementation(() => ({ url_enrichment: true })); @@ -190,7 +193,7 @@ describe('LinkPreviewsManager', () => { } = setup(); // Mock the enrichURL to never resolve - mockClient.enrichURL = vi.fn().mockImplementation(() => new Promise(() => {})); + mockClient.getOG = vi.fn().mockImplementation(() => new Promise(() => {})); // Add a loading preview linkPreviewsManager.findAndEnrichUrls('Check out https://example.com'); @@ -395,7 +398,7 @@ describe('LinkPreviewsManager', () => { mockClient, } = setup(); let resolveEnrichment: (value: typeof enrichURLReturnValue) => void = () => {}; - mockClient.enrichURL = vi.fn( + mockClient.getOG = vi.fn( () => new Promise((resolve) => { resolveEnrichment = resolve; @@ -424,7 +427,7 @@ describe('LinkPreviewsManager', () => { mockChannel.getConfig.mockReturnValueOnce({ url_enrichment: false }); linkPreviewsManager.findAndEnrichUrls('Check out https://example.com'); let enrichPromiseResolve; - mockClient.enrichURL = vi.fn().mockImplementation(() => { + mockClient.getOG = vi.fn().mockImplementation(() => { return new Promise((resolve) => { enrichPromiseResolve = resolve; }); @@ -432,7 +435,7 @@ describe('LinkPreviewsManager', () => { linkPreviewsManager.findAndEnrichUrls('Check out https://example.com'); // Wait for the debounced function to be called await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockClient.enrichURL).not.toHaveBeenCalled(); + expect(mockClient.getOG).not.toHaveBeenCalled(); expect(linkPreviewsManager.previews.size).toBe(0); }); @@ -443,7 +446,7 @@ describe('LinkPreviewsManager', () => { } = setup({ config: { enabled: false } }); linkPreviewsManager.findAndEnrichUrls('Check out https://example.com'); let enrichPromiseResolve; - mockClient.enrichURL = vi.fn().mockImplementation(() => { + mockClient.getOG = vi.fn().mockImplementation(() => { return new Promise((resolve) => { enrichPromiseResolve = resolve; }); @@ -451,7 +454,7 @@ describe('LinkPreviewsManager', () => { linkPreviewsManager.findAndEnrichUrls('Check out https://example.com'); // Wait for the debounced function to be called await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockClient.enrichURL).not.toHaveBeenCalled(); + expect(mockClient.getOG).not.toHaveBeenCalled(); expect(linkPreviewsManager.previews.size).toBe(0); }); @@ -461,7 +464,7 @@ describe('LinkPreviewsManager', () => { mockClient, } = setup(); let enrichPromiseResolve; - mockClient.enrichURL = vi.fn().mockImplementation(() => { + mockClient.getOG = vi.fn().mockImplementation(() => { return new Promise((resolve) => { enrichPromiseResolve = resolve; }); @@ -470,7 +473,7 @@ describe('LinkPreviewsManager', () => { // Wait for the debounced function to be called await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockClient.enrichURL).toHaveBeenCalledWith(linkUrl); + expect(mockClient.getOG).toHaveBeenCalledWith({ url: linkUrl }); expect(linkPreviewsManager.previews.size).toBe(1); const preview = linkPreviewsManager.previews.get(linkUrl); @@ -497,7 +500,7 @@ describe('LinkPreviewsManager', () => { messageComposer: { linkPreviewsManager }, mockClient, } = setup(); - mockClient.enrichURL.mockRejectedValueOnce(new Error('Enrichment failed')); + mockClient.getOG.mockRejectedValueOnce(new Error('Enrichment failed')); linkPreviewsManager.findAndEnrichUrls('Check out https://example.com'); @@ -523,7 +526,7 @@ describe('LinkPreviewsManager', () => { // Wait for the debounced function to be called await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockClient.enrichURL).toHaveBeenCalledTimes(1); + expect(mockClient.getOG).toHaveBeenCalledTimes(1); expect(linkPreviewsManager.previews.size).toBe(1); }); @@ -606,7 +609,10 @@ describe('LinkPreviewsManager', () => { } = setup(); linkPreviewsManager.state.partialNext({ previews: new Map([ - [linkUrl, { og_scrape_url: linkUrl, status: LinkPreviewStatus.LOADED }], + [ + linkUrl, + { og_scrape_url: linkUrl, status: LinkPreviewStatus.LOADED, custom: {} }, + ], ]), }); const onLinkPreviewDismissed = vi.fn(); diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index 6d791322ca..75b9642f01 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -1,14 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { chatLoggerSystem } from '../../../src/logger'; import { AbstractOfflineDB, Channel, - ChannelAPIResponse, + ChannelStateResponseFields, ChannelConfigWithInfo, ChannelResponse, DEFAULT_COMPOSER_CONFIG, LocalMessage, MessageComposerConfig, - StaticLocationPayload, + SharedLocation, StreamChat, Thread, } from '../../../src'; @@ -40,6 +41,7 @@ vi.mock('../../../src/utils', async (importOriginal) => ({ isLocalMessage: vi.fn().mockReturnValue(true), randomId: vi.fn().mockReturnValue('test-uuid'), throttle: vi.fn().mockImplementation((fn) => fn), + getEnv: vi.fn(), })); const quotedMessage = { @@ -66,19 +68,19 @@ const getThread = (channel: Channel, client: StreamChat, threadId: string) => text: 'Test message', type: 'regular' as const, user, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + created_at: new Date(), + updated_at: new Date(), }, channel: { - id: channel.id, + id: channel.id!, type: channel.type, cid: channel.cid, disabled: false, frozen: false, }, title: 'Test Thread', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + created_at: new Date(), + updated_at: new Date(), channel_cid: channel.cid, latest_replies: [], thread_participants: [], @@ -102,18 +104,13 @@ const setup = ({ } = {}) => { const mockClient = new StreamChat('test-api-key'); mockClient.user = user; - mockClient.userID = user.id; const cid = 'messaging:test-channel-id'; if (channelConfig) { // @ts-expect-error incomplete channel config object mockClient.configs[cid] = channelConfig; } // Create a proper Channel instance with only the necessary attributes mocked - const mockChannel = new Channel(mockClient, 'messaging', 'test-channel-id', { - id: 'test-channel-id', - type: 'messaging', - cid: 'messaging:test-channel-id', - }); + const mockChannel = mockClient.channel('messaging', 'test-channel-id'); // Mock the getClient method vi.spyOn(mockChannel, 'getClient').mockReturnValue(mockClient); @@ -139,15 +136,10 @@ const offlineModeMessageComposerSetup = ({ } = {}) => { const mockClient = new StreamChat('test-api-key'); mockClient.user = user; - mockClient.userID = user.id; mockClient.setOfflineDBApi(new MockOfflineDB({ client: mockClient })); vi.spyOn(mockClient.offlineDb!, 'initializeDB').mockResolvedValue(false); // Create a proper Channel instance with only the necessary attributes mocked - const mockChannel = new Channel(mockClient, 'messaging', 'test-channel-id', { - id: 'test-channel-id', - type: 'messaging', - cid: 'messaging:test-channel-id', - }); + const mockChannel = mockClient.channel('messaging', 'test-channel-id'); // Mock the getClient method vi.spyOn(mockChannel, 'getClient').mockReturnValue(mockClient); @@ -164,6 +156,7 @@ const offlineModeMessageComposerSetup = ({ describe('MessageComposer', () => { afterEach(() => { + chatLoggerSystem.restoreDefaults(); vi.clearAllMocks(); }); @@ -330,7 +323,7 @@ describe('MessageComposer', () => { it('does nothing if cids do not match', () => { const response = { channel: { cid: 'messaging:other' }, - } as unknown as ChannelAPIResponse; + } as unknown as ChannelStateResponseFields; composer.initStateFromChannelResponse(response); @@ -345,7 +338,7 @@ describe('MessageComposer', () => { const response = { channel: { cid: composer.channel.cid }, draft, - } as unknown as ChannelAPIResponse; + } as unknown as ChannelStateResponseFields; composer.initStateFromChannelResponse(response); @@ -357,7 +350,7 @@ describe('MessageComposer', () => { it('clears and deletes draft if no draft in response but draftId exists in state', () => { const response = { channel: { cid: composer.channel.cid }, - } as unknown as ChannelAPIResponse; + } as unknown as ChannelStateResponseFields; const executeQuerySafelySpy = vi .spyOn(composer.client.offlineDb!, 'executeQuerySafely') .mockImplementation(vi.fn()); @@ -383,7 +376,7 @@ describe('MessageComposer', () => { const response = { channel: { cid: composer.channel.cid }, - } as unknown as ChannelAPIResponse; + } as unknown as ChannelStateResponseFields; composer.initStateFromChannelResponse(response); @@ -1278,13 +1271,13 @@ describe('MessageComposer', () => { const result = await messageComposer.compose(); - expect(result).toEqual({ + expect(result).toMatchObject({ localMessage: { attachments: [], cid: 'messaging:test-channel-id', created_at: expect.any(Date), - deleted_at: null, - error: null, + deleted_at: undefined, + error: undefined, id: 'test-uuid', mentioned_channel: false, mentioned_group_ids: [], @@ -1292,9 +1285,9 @@ describe('MessageComposer', () => { mentioned_roles: [], mentioned_users: [], parent_id: undefined, - pinned_at: null, - quoted_message: null, - reaction_groups: null, + pinned_at: undefined, + quoted_message: undefined, + reaction_groups: undefined, status: 'sending', text: 'Test message', type: 'regular', @@ -1324,9 +1317,9 @@ describe('MessageComposer', () => { const date = new Date(); const { messageComposer } = setup({ composition: { - attachments: [{ type: 'file' }], + attachments: [{ type: 'file', custom: {} }], created_at: date, - deleted_at: null, + deleted_at: undefined, id: 'test-uuid', mentioned_users: [], pinned: true, @@ -1354,13 +1347,13 @@ describe('MessageComposer', () => { const result = await messageComposer.compose(); - expect(result).toEqual({ + expect(result).toMatchObject({ localMessage: { - attachments: [{ type: 'file' }], + attachments: [{ type: 'file', custom: {} }], cid: 'messaging:test-channel-id', created_at: date, - deleted_at: null, - error: null, + deleted_at: undefined, + error: undefined, id: 'test-uuid', mentioned_channel: false, mentioned_group_ids: [], @@ -1370,7 +1363,7 @@ describe('MessageComposer', () => { parent_id: undefined, pinned: true, pinned_at: date, - quoted_message: null, + quoted_message: undefined, reaction_counts: { like: 1, }, @@ -1751,7 +1744,7 @@ describe('MessageComposer', () => { await messageComposer.createDraft(); expect(spyComposeDraft).toHaveBeenCalled(); - expect(spyCreateDraft).toHaveBeenCalledWith(mockDraft); + expect(spyCreateDraft).toHaveBeenCalledWith({ message: mockDraft }); expect(spyLogDraftUpdateTimestamp).toHaveBeenCalled(); expect(messageComposer.state.getLatestValue().draftId).toBe('test-draft-id'); }); @@ -1803,7 +1796,7 @@ describe('MessageComposer', () => { await messageComposer.createDraft(); expect(spyComposeDraft).toHaveBeenCalled(); - expect(spyCreateDraft).toHaveBeenCalledWith(mockDraft); + expect(spyCreateDraft).toHaveBeenCalledWith({ message: mockDraft }); expect(spyLogDraftUpdateTimestamp).toHaveBeenCalled(); expect(messageComposer.state.getLatestValue().draftId).toBe('test-draft-id'); @@ -1839,9 +1832,10 @@ describe('MessageComposer', () => { const spyUpsertDraft = vi .spyOn(messageComposer.client.offlineDb!, 'upsertDraft') .mockRejectedValueOnce(new Error('offline insert failed')); - const spyLogger = vi - .spyOn(messageComposer.client, 'logger') - .mockImplementation(vi.fn()); + const spyLogger = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: spyLogger, level: 'trace' }, + }); const spyLogDraftUpdateTimestamp = vi.spyOn( messageComposer, @@ -1851,14 +1845,14 @@ describe('MessageComposer', () => { await messageComposer.createDraft(); expect(spyComposeDraft).toHaveBeenCalled(); - expect(spyCreateDraft).toHaveBeenCalledWith(mockDraft); + expect(spyCreateDraft).toHaveBeenCalledWith({ message: mockDraft }); expect(spyLogDraftUpdateTimestamp).toHaveBeenCalled(); expect(messageComposer.state.getLatestValue().draftId).toBe('test-draft-id'); expect(spyUpsertDraft).toHaveBeenCalledTimes(1); expect(spyLogger).toHaveBeenCalledWith( 'error', - 'offlineDb:upsertDraft', + expect.stringContaining('Upserting the draft to the offline database failed.'), expect.objectContaining({ error: expect.any(Error), }), @@ -2008,16 +2002,17 @@ describe('MessageComposer', () => { const spyChannelDeleteDraft = vi .spyOn(mockChannel, 'deleteDraft') .mockResolvedValue({}); - const spyLogger = vi - .spyOn(messageComposer.client, 'logger') - .mockImplementation(vi.fn()); + const spyLogger = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: spyLogger, level: 'trace' }, + }); await messageComposer.deleteDraft(); expect(spyChannelDeleteDraft).toHaveBeenCalled(); expect(spyLogger).toHaveBeenCalledWith( 'error', - 'offlineDb:deleteDraft', + expect.stringContaining('Deleting the draft from the offline database failed.'), expect.objectContaining({ error: expect.any(Error), }), @@ -2116,7 +2111,7 @@ describe('MessageComposer', () => { created_by_device_id: messageComposer.locationComposer.deviceId, latitude: 1, longitude: 1, - } as StaticLocationPayload); + } as SharedLocation); expect(messageComposer.locationComposer.state.getLatestValue()).toEqual({ location: null, }); @@ -2301,7 +2296,10 @@ describe('MessageComposer', () => { const spyChannelGetDraft = vi.spyOn(mockChannel, 'getDraft'); spyChannelGetDraft.mockRejectedValue(new Error('Failed to get draft')); - const spyLogger = vi.spyOn(mockClient, 'logger'); + const spyLogger = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: spyLogger, level: 'trace' }, + }); await messageComposer.getDraft(); diff --git a/test/unit/MessageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.test.ts b/test/unit/MessageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.test.ts index 2442426f0e..57887b4b6e 100644 --- a/test/unit/MessageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.test.ts +++ b/test/unit/MessageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.test.ts @@ -8,6 +8,7 @@ import { getClientWithUser } from '../../../../test-utils/getClient'; vi.mock('../../../../../src/utils', () => ({ generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), + getEnv: vi.fn(), })); const setupHandlerParams = (initialState: AttachmentPostUploadMiddlewareState) => { diff --git a/test/unit/MessageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.test.ts b/test/unit/MessageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.test.ts index f7ce1435db..c3acbde037 100644 --- a/test/unit/MessageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.test.ts +++ b/test/unit/MessageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.test.ts @@ -12,6 +12,7 @@ import { getClientWithUser } from '../../../../test-utils/getClient'; vi.mock('../../../../../src/utils', () => ({ generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), + getEnv: vi.fn(), })); const setupHandlerParams = (initialState: AttachmentPreUploadMiddlewareState) => { diff --git a/test/unit/MessageComposer/middleware/attachmentManager/preUpload/serverUploadConfigCheck.test.ts b/test/unit/MessageComposer/middleware/attachmentManager/preUpload/serverUploadConfigCheck.test.ts index bb463c1e85..f51d5391a4 100644 --- a/test/unit/MessageComposer/middleware/attachmentManager/preUpload/serverUploadConfigCheck.test.ts +++ b/test/unit/MessageComposer/middleware/attachmentManager/preUpload/serverUploadConfigCheck.test.ts @@ -26,6 +26,7 @@ const setupHandlerParams = (initialState: AttachmentPreUploadMiddlewareState) => // Mock dependencies vi.mock('../../../../../src/utils', () => ({ generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), + getEnv: vi.fn(), })); const setup = () => { diff --git a/test/unit/MessageComposer/middleware/messageComposer/cleanData.test.ts b/test/unit/MessageComposer/middleware/messageComposer/cleanData.test.ts index f29f8c3f02..a612d58a87 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/cleanData.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/cleanData.test.ts @@ -51,8 +51,12 @@ describe('stream-io/message-composer-middleware/data-cleanup', () => { ...stateSeed, localMessage: { ...stateSeed.localMessage, - error: null, - quoted_message: null, + deleted_at: undefined, + error: undefined, + pinned_at: undefined, + quoted_message: undefined, + reaction_groups: undefined, + user_id: undefined, type: 'regular', }, message: { @@ -75,8 +79,12 @@ describe('stream-io/message-composer-middleware/data-cleanup', () => { ...stateSeed, localMessage: { ...stateSeed.localMessage, - error: null, - quoted_message: null, + deleted_at: undefined, + error: undefined, + pinned_at: undefined, + quoted_message: undefined, + reaction_groups: undefined, + user_id: undefined, type: 'regular', }, message: { diff --git a/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts b/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts index d241decd3e..5a118b3829 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts @@ -4,7 +4,7 @@ import { StreamChat } from '../../../../../src/client'; import { MessageComposer } from '../../../../../src/messageComposer/messageComposer'; import { createCommandInjectionMiddleware } from '../../../../../src/messageComposer/middleware/messageComposer/commandInjection'; import { - CommandResponse, + Command, createDraftCommandInjectionMiddleware, MessageComposerMiddlewareState, MessageDraftComposerMiddlewareValueState, @@ -71,7 +71,7 @@ describe('stream-io/message-composer-middleware/command-injection', () => { get mentionedUsers() { return []; }, - setCommand: (command: CommandResponse | null) => {}, + setCommand: (command: Command | null) => {}, }; const attachmentManager = { @@ -241,7 +241,7 @@ describe('stream-io/message-composer-middleware/draft-command-injection', () => get mentionedUsers() { return []; }, - setCommand: (command: CommandResponse | null) => {}, + setCommand: (command: Command | null) => {}, }; const attachmentManager = { diff --git a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts index 82cf8959c2..b68f4e3242 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts @@ -29,7 +29,6 @@ const setupMiddleware = ( const user = { id: 'user' }; const client = new StreamChat('apiKey'); client.user = user; - client.userID = user.id; const channelResponse = generateChannel(); const channel = client.channel( diff --git a/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts b/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts index 26965ac1a1..aa1917f9ca 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts @@ -18,6 +18,7 @@ import { MessageDraftComposerMiddlewareValueState, MiddlewareStatus, } from '../../../../../src'; +import { getClientWithUser } from '../../../test-utils/getClient'; const enrichURLReturnValue = { asset_url: 'https://example.com/image.jpg', @@ -71,8 +72,9 @@ const setup = ({ } = {}) => { vi.clearAllMocks(); - const mockClient = new StreamChat('apiKey', 'apiSecret'); - mockClient.enrichURL = vi.fn().mockResolvedValue(enrichURLReturnValue); + const mockClient = getClientWithUser({ id: 'user' }); + + mockClient.getOG = vi.fn().mockResolvedValue(enrichURLReturnValue); const mockChannel = mockClient.channel('messaging', 'test-channel', { members: [], @@ -584,8 +586,8 @@ const setupForDraft = ({ } = {}) => { vi.clearAllMocks(); - const mockClient = new StreamChat('apiKey', 'apiSecret'); - mockClient.enrichURL = vi.fn().mockResolvedValue(enrichURLReturnValue); + const mockClient = getClientWithUser({ id: 'user' }); + mockClient.getOG = vi.fn().mockResolvedValue(enrichURLReturnValue); const mockChannel = mockClient.channel('messaging', 'test-channel', { members: [], diff --git a/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts index 81d9326766..90f3abbc09 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts @@ -65,10 +65,10 @@ describe('stream-io/message-composer-middleware/shared-location', () => { localMessage: { shared_location: { channel_cid: messageComposer.channel.cid, - created_at: expect.any(String), + created_at: expect.any(Date), created_by_device_id: messageComposer.locationComposer.deviceId, message_id: messageComposer.id, - updated_at: expect.any(String), + updated_at: expect.any(Date), user_id: user.id, ...coords, }, @@ -101,7 +101,6 @@ describe('stream-io/message-composer-middleware/shared-location', () => { it('does not inject shared_location to localMessage and message payloads if the location state is corrupted', async () => { const { messageComposer } = setup(); const middleware = createSharedLocationCompositionMiddleware(messageComposer); - // @ts-expect-error invalid location payload messageComposer.locationComposer.state.next({ location: { latitude: 1, diff --git a/test/unit/MessageComposer/middleware/pollComposer/state.test.ts b/test/unit/MessageComposer/middleware/pollComposer/state.test.ts index 01676d6d02..781071fd7a 100644 --- a/test/unit/MessageComposer/middleware/pollComposer/state.test.ts +++ b/test/unit/MessageComposer/middleware/pollComposer/state.test.ts @@ -27,6 +27,7 @@ const setupHandlerParams = (initialState: PollComposerStateChangeMiddlewareValue // Mock dependencies vi.mock('../../../../../src/utils', () => ({ generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), + getEnv: vi.fn(), })); const getInitialState = (): PollComposerState => ({ @@ -39,7 +40,6 @@ const getInitialState = (): PollComposerState => ({ max_votes_allowed: '', name: '', options: [{ id: 'option-id', text: '' }], - user_id: 'user-id', voting_visibility: VotingVisibility.public, }, errors: {}, diff --git a/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts b/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts index 35d11341e9..5955ae30ae 100644 --- a/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts @@ -9,13 +9,13 @@ import { StreamChat } from '../../../../../src/client'; import { MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY } from '../../../../../src/constants'; import type { ChannelMemberResponse, + MemberFilters, SearchUserGroupsOptions, SearchUserGroupsResponse, - Mute, + UserFilters, UserGroupResponse, + UserMuteResponse, UserResponse, - UserFilters, - MemberFilters, } from '../../../../../src/types'; import type { MentionSuggestion } from '../../../../../src/messageComposer/middleware/textComposer/types'; @@ -116,6 +116,7 @@ describe('MentionsSearchSource', () => { client = { userID: 'currentUser', + userId: 'currentUser', searchRoles: vi.fn().mockImplementation(async ({ query }: { query: string }) => ({ roles: [ { name: 'admin' }, @@ -498,7 +499,7 @@ describe('MentionsSearchSource', () => { it('should preserve special mentions while filtering muted users', () => { const source = new MentionsSearchSource(channel); - const mute: Mute = { + const mute: UserMuteResponse = { target: { id: 'user1' }, user: { id: 'currentUser' }, created_at: new Date().toISOString(), @@ -521,7 +522,7 @@ describe('MentionsSearchSource', () => { it('should return only muted users for /unmute and hide special mentions', () => { const source = new MentionsSearchSource(channel); - const mute: Mute = { + const mute: UserMuteResponse = { target: { id: 'user1' }, user: { id: 'currentUser' }, created_at: new Date().toISOString(), @@ -577,11 +578,9 @@ describe('MentionsSearchSource', () => { await source.executeQuery(); - expect(client.queryUsers).toHaveBeenCalledWith( - expect.any(Object), - expect.any(Object), - expect.objectContaining({ limit: 10, offset: 3 }), - ); + expect(client.queryUsers).toHaveBeenCalledWith({ + payload: expect.objectContaining({ limit: 10, offset: 3 }), + }); expect(client.searchUserGroups).toHaveBeenCalledWith({ id_gt: 'group-0', limit: 10, @@ -622,11 +621,11 @@ describe('MentionsSearchSource', () => { it('should prepare correct query parameters for members search', () => { const source = new MentionsSearchSource(channel); source.memberFilters = { name: { $autocomplete: 'john' } } as MemberFilters; - source.memberSort = { created_at: -1 }; + source.memberSort = [{ field: 'created_at', direction: -1 }]; const params = source.prepareQueryMembersParams('john', 5); expect(params.filters).toEqual({ name: { $autocomplete: 'john' } }); - expect(params.sort).toEqual({ created_at: -1 }); + expect(params.sort).toEqual([{ field: 'created_at', direction: -1 }]); expect(params.options).toEqual(expect.objectContaining({ limit: 10, offset: 5 })); }); @@ -668,11 +667,9 @@ describe('MentionsSearchSource', () => { source.config.mentionAllAppUsers = true; await source.query('test'); - expect(client.queryUsers).toHaveBeenCalledWith( - expect.any(Object), - expect.any(Object), - expect.objectContaining({ presence: true }), - ); + expect(client.queryUsers).toHaveBeenCalledWith({ + payload: expect.objectContaining({ presence: true }), + }); }); it('should correctly calculate Levenshtein distance for fuzzy matching', () => { diff --git a/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts b/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts index 08df97fb07..57eb7bd833 100644 --- a/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts @@ -7,13 +7,10 @@ import { } from '../../../../../src/messageComposer/messageComposer'; import { createMentionsMiddleware } from '../../../../../src/messageComposer/middleware/textComposer/mentions'; import type { TextComposerSuggestion } from '../../../../../src/messageComposer/'; -import type { - CommandResponse, - DraftResponse, - LocalMessage, -} from '../../../../../src/types'; +import type { Command, DraftResponse, LocalMessage } from '../../../../../src/types'; import { TextComposerMiddleware } from '../../../../../src'; import type { UserSuggestion } from '../../../../../src/messageComposer/middleware/textComposer/types'; +import { getClientWithUser } from '../../../test-utils/getClient'; // Mock dependencies vi.mock('../../../src/utils', () => ({ @@ -46,7 +43,7 @@ const setup = ({ vi.clearAllMocks(); // Setup mocks - const client = new StreamChat('apiKey', 'apiSecret'); + const client = getClientWithUser({ id: 'user' }); client.queryUsers = vi.fn().mockResolvedValue({ users: [] }); const channel = client.channel('channelType', 'channelId'); @@ -242,7 +239,7 @@ describe('TextComposerMiddlewareExecutor', () => { id: 'ban', name: 'ban', description: 'Ban a user', - } as TextComposerSuggestion; + } as TextComposerSuggestion; await textComposer.handleSelect(selectedSuggestion); @@ -273,7 +270,7 @@ describe('TextComposerMiddlewareExecutor', () => { id: 'ban', name: 'ban', description: 'Ban a user', - } as TextComposerSuggestion); + } as TextComposerSuggestion); expect(textComposer.text).toBe('/ba'); expect(textComposer.command).toBeNull(); @@ -313,7 +310,7 @@ describe('TextComposerMiddlewareExecutor', () => { name: 'ban', description: 'Ban a user', set: 'moderation_set', - } as TextComposerSuggestion); + } as TextComposerSuggestion); expect(textComposer.text).toBe('/ba'); expect(textComposer.command).toBeNull(); diff --git a/test/unit/MessageComposer/middleware/textComposer/command.test.ts b/test/unit/MessageComposer/middleware/textComposer/command.test.ts index aeb4d9bc6d..c5a2bfce64 100644 --- a/test/unit/MessageComposer/middleware/textComposer/command.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/command.test.ts @@ -9,6 +9,7 @@ import type { DraftResponse, LocalMessage } from '../../../../../src/types'; import { TextComposerMiddleware } from '../../../../../src'; import { createActiveCommandGuardMiddleware } from '../../../../../src/messageComposer/middleware/textComposer/activeCommandGuard'; import { createCommandStringExtractionMiddleware } from '../../../../../src/messageComposer/middleware/textComposer/commandStringExtraction'; +import { getClientWithUser } from '../../../test-utils/getClient'; // Mock dependencies @@ -25,7 +26,7 @@ const setup = ({ vi.clearAllMocks(); // Setup mocks - const client = new StreamChat('apiKey', 'apiSecret'); + const client = getClientWithUser({ id: 'user' }); client.queryUsers = vi.fn().mockResolvedValue({ users: [] }); const channel = client.channel('channelType', 'channelId'); diff --git a/test/unit/MessageComposer/pollComposer.test.ts b/test/unit/MessageComposer/pollComposer.test.ts index 053a8a7ced..f50a10fbde 100644 --- a/test/unit/MessageComposer/pollComposer.test.ts +++ b/test/unit/MessageComposer/pollComposer.test.ts @@ -6,6 +6,7 @@ import { VotingVisibility } from '../../../src/types'; // Mock dependencies vi.mock('../../../src/utils', () => ({ generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), + getEnv: vi.fn(), })); vi.mock('../../../src/messageComposer/middleware/pollComposer', () => ({ @@ -93,7 +94,6 @@ describe('PollComposer', () => { expect(initialState.data.max_votes_allowed).toBe(''); expect(initialState.data.name).toBe(''); expect(initialState.data.options).toEqual([{ id: 'test-uuid', text: '' }]); - expect(initialState.data.user_id).toBe('user-id'); expect(initialState.data.voting_visibility).toBe(VotingVisibility.public); expect(initialState.errors).toEqual({}); }); @@ -112,7 +112,6 @@ describe('PollComposer', () => { max_votes_allowed: '', name: '', options: [{ id: 'option-id', text: '' }], - user_id: 'user-id', voting_visibility: VotingVisibility.anonymous, }, errors: {}, @@ -126,7 +125,6 @@ describe('PollComposer', () => { expect(pollComposer.max_votes_allowed).toBe(''); expect(pollComposer.name).toBe(''); expect(pollComposer.options).toEqual([{ id: 'option-id', text: '' }]); - expect(pollComposer.user_id).toBe('user-id'); expect(pollComposer.voting_visibility).toBe(VotingVisibility.anonymous); }); }); @@ -139,7 +137,6 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '', id: 'test-id', - user_id: 'user-id', voting_visibility: VotingVisibility.public, }, errors: {}, @@ -155,7 +152,6 @@ describe('PollComposer', () => { name: '', max_votes_allowed: '', id: 'test-id', - user_id: 'user-id', voting_visibility: VotingVisibility.public, }, errors: {}, @@ -171,7 +167,6 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '1', // Less than 2 id: 'test-id', - user_id: 'user-id', voting_visibility: VotingVisibility.public, }, errors: {}, @@ -187,7 +182,6 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '', id: 'test-id', - user_id: 'user-id', voting_visibility: VotingVisibility.public, }, errors: { name: 'Name is required' }, @@ -203,7 +197,6 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '', id: 'test-id', - user_id: 'user-id', voting_visibility: VotingVisibility.public, }, errors: {}, @@ -218,7 +211,6 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '', id: 'test-id', - user_id: 'user-id', voting_visibility: VotingVisibility.public, }, errors: { name: undefined, options: undefined }, @@ -241,7 +233,6 @@ describe('PollComposer', () => { max_votes_allowed: '5', name: 'Different Name', options: [{ id: 'different-option-id', text: 'Different Option' }], - user_id: 'different-user-id', voting_visibility: VotingVisibility.anonymous, }, errors: { name: 'Error' }, @@ -260,7 +251,6 @@ describe('PollComposer', () => { expect(currentState.data.max_votes_allowed).toBe(''); expect(currentState.data.name).toBe(''); expect(currentState.data.options).toEqual([{ id: 'test-uuid', text: '' }]); - expect(currentState.data.user_id).toBe('user-id'); expect(currentState.data.voting_visibility).toBe(VotingVisibility.public); expect(currentState.errors).toEqual({}); }); diff --git a/test/unit/MessageComposer/textComposer.test.ts b/test/unit/MessageComposer/textComposer.test.ts index 75d3df8397..fec78af64c 100644 --- a/test/unit/MessageComposer/textComposer.test.ts +++ b/test/unit/MessageComposer/textComposer.test.ts @@ -12,6 +12,7 @@ import { logChatPromiseExecution } from '../../../src/utils'; import { TextComposerConfig } from '../../../src/messageComposer/configuration'; import { LinkPreviewStatus } from '../../../src/messageComposer/linkPreviewsManager'; import type { LocalAttachment } from '../../../src/messageComposer/types'; +import { getClientWithUser } from '../test-utils/getClient'; const textComposerMiddlewareExecuteOutput = { state: { @@ -46,6 +47,7 @@ vi.mock('../../../src/utils', () => ({ formatMessage: vi.fn().mockImplementation((msg) => msg), throttle: vi.fn().mockImplementation((fn) => fn), normalizeQuerySort: vi.fn().mockReturnValue([{ field: 'created_at', direction: -1 }]), + getEnv: vi.fn(), })); const setup = ({ @@ -61,7 +63,8 @@ const setup = ({ vi.clearAllMocks(); // Setup mocks - const mockClient = new StreamChat('apiKey', 'apiSecret'); + const mockClient = getClientWithUser({ id: 'user' }); + mockClient.queryUsers = vi.fn().mockResolvedValue({ users: [] }); const mockChannel = mockClient.channel('channelType', 'channelId'); diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index d8bfb36d5a..f74597c87a 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -38,7 +38,7 @@ describe('Channel count unread', function () { client = new StreamChat('apiKey'); client.user = user; - client.userID = 'user'; + client.user = { id: 'user' }; client.userMuteStatus = (targetId) => targetId.startsWith('mute'); channel = client.channel(channelResponse.channel.type, channelResponse.channel.id); @@ -221,9 +221,11 @@ describe('Channel count unread', function () { }); it('should return undefined if client user is not set (server-side client)', () => { - client = new StreamChat('apiKey', 'secret'); + // client.channel() now requires a connected user, so create the channel with the user + // set, then clear it to model a client with no connected user (userId undefined). channel = client.channel(channelResponse.channel.type, channelResponse.channel.id); channel.initialized = true; + client.user = undefined; expect(channel.lastRead()).to.be.undefined; }); }); @@ -236,7 +238,7 @@ describe('Channel isViewingLive (unread bump gating)', function () { const setupChannel = () => { const client = new StreamChat('apiKey'); client.user = user; - client.userID = user.id; + client.user = { id: user.id }; client.userMuteStatus = () => false; const channel = client.channel('messaging', 'live-mode-id'); channel.initialized = true; @@ -298,7 +300,7 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function const setupChannel = ({ isLocalUnreadCountEnabled }) => { const client = new StreamChat('apiKey', { isLocalUnreadCountEnabled }); client.user = user; - client.userID = user.id; + client.user = { id: user.id }; client.userMuteStatus = () => false; const channel = client.channel('messaging', 'live-id'); channel.initialized = true; @@ -349,7 +351,10 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function it('markReadLocally resets the count and emits a message.read-shaped message.read_locally event', function () { const { client, channel } = setupChannel({ isLocalUnreadCountEnabled: true }); - const post = vi.spyOn(client, 'post').mockResolvedValue({}); + // markReadLocally is purely local; assert it performs no HTTP request via the api seam. + const sendRequest = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); const lastMsg = generateMsg({ user: otherUser }); seedLatestWindow(channel, [lastMsg]); channel.state.unreadCount = 5; @@ -367,7 +372,7 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function expect(channel.countUnread()).to.be.equal(0); expect(channel.state.read[user.id].unread_messages).to.be.equal(0); expect(channel.state.read[user.id].last_read_message_id).to.be.equal(lastMsg.id); - expect(post.mock.calls.length).to.be.equal(0); + expect(sendRequest.mock.calls.length).to.be.equal(0); expect(onLocalRead.mock.calls.length).to.be.equal(1); const event = onLocalRead.mock.calls[0][0]; @@ -377,19 +382,19 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function expect(event.channel_type).to.be.equal(channel.type); expect(event.user.id).to.be.equal(user.id); expect(event.last_read_message_id).to.be.equal(lastMsg.id); - expect(event.created_at).to.be.a('string'); + // markReadLocally now builds the event with a Date `created_at` (not an ISO string). + expect(event.created_at).to.be.instanceof(Date); // markReadLocally returns the same dispatched event so callers (e.g. the RN SDK) can sync // their own unread UI from that read info instead of re-deriving it. expect(returned).to.equal(event); expect(returned.last_read_message_id).to.be.equal(lastMsg.id); - expect(returned.created_at).to.be.a('string'); + expect(returned.created_at).to.be.instanceof(Date); }); it('markReadLocally returns undefined and dispatches nothing when there is no connected user', function () { const { client, channel } = setupChannel({ isLocalUnreadCountEnabled: true }); client.user = undefined; - client.userID = undefined; const onLocalRead = vi.fn(); channel.on('message.read_locally', onLocalRead); @@ -401,7 +406,9 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function it('markReadLocally resets the count and creates the own read row when none exists yet (fresh livestream)', function () { const { client, channel } = setupChannel({ isLocalUnreadCountEnabled: true }); - const post = vi.spyOn(client, 'post').mockResolvedValue({}); + const sendRequest = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); const lastMsg = generateMsg({ user: otherUser }); seedLatestWindow(channel, [lastMsg]); channel.state.unreadCount = 3; @@ -413,7 +420,7 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function expect(channel.state.read[user.id]).to.be.ok; expect(channel.state.read[user.id].unread_messages).to.be.equal(0); expect(channel.state.read[user.id].last_read_message_id).to.be.equal(lastMsg.id); - expect(post.mock.calls.length).to.be.equal(0); + expect(sendRequest.mock.calls.length).to.be.equal(0); }); }); @@ -426,7 +433,7 @@ describe('Channel _handleChannelEvent', function () { beforeEach(() => { client = new StreamChat('apiKey'); client.user = user; - client.userID = user.id; + client.user = { id: user.id }; client.userMuteStatus = (targetId) => targetId.startsWith('mute'); channel = client.channel('messaging', 'id'); channel.data.own_capabilities = ['read-events']; @@ -1974,8 +1981,9 @@ describe('Channel _handleChannelEvent', function () { }); it(`should make sure that state reload doesn't wipe out existing data`, async () => { - const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockChannelQueryResponse)); + sinon + .stub(client.api, 'sendRequest') + .resolves({ body: mockChannelQueryResponse, metadata: {} }); channel.state.members = { user: { id: 'user' }, @@ -2000,10 +2008,11 @@ describe('Channel _handleChannelEvent', function () { // capabilities.changed is emitted from the channel.updated / query path. it('should dispatch "capabilities.changed" event', async () => { - const mock = sinon.mock(client); const response = mockChannelQueryResponse; channel.data.own_capabilities = response.channel.own_capabilities.slice(0, 1); - mock.expects('post').returns(Promise.resolve(response)); + const sendRequestStub = sinon + .stub(client.api, 'sendRequest') + .resolves({ body: response, metadata: {} }); const spy = sinon.spy(); channel.on('capabilities.changed', spy); @@ -2021,7 +2030,7 @@ describe('Channel _handleChannelEvent', function () { }); channel.data.own_capabilities = response.channel.own_capabilities; - mock.expects('post').returns(Promise.resolve(response)); + sendRequestStub.resolves({ body: response, metadata: {} }); spy.resetHistory(); await channel.query(); @@ -2114,7 +2123,7 @@ describe('Uninitialized Channel', () => { beforeEach(() => { client = new StreamChat('apiKey'); client.user = user; - client.userID = user.id; + client.user = { id: user.id }; client.userMuteStatus = (targetId) => targetId.startsWith('mute'); channel = client.channel('messaging', 'id'); channel.initialized = false; @@ -2188,6 +2197,8 @@ describe('Uninitialized Channel', () => { describe('Channels - Constructor', function () { const client = new StreamChat('key', 'secret'); + // client.channel() now requires a connected user (userId derives from client.user). + client.user = { id: 'thierry' }; it('canonical form', function () { const channel = client.channel('messaging', '123', { cool: true }); @@ -2201,9 +2212,13 @@ describe('Channels - Constructor', function () { expect(channel.cid).to.eql('messaging:brand_new_123'); expect(channel.id).to.eql('brand_new_123'); expect(channel.data.cool).to.eql(true); - channel = client.channel('messaging', 'brand_new_123', { custom_cool: true }); + // Re-fetching a cached channel now merges only the reserved `custom` payload onto existing + // data (getChannelById), leaving previously-set top-level data untouched. + channel = client.channel('messaging', 'brand_new_123', { + custom: { custom_cool: true }, + }); expect(channel.data.cool).to.eql(true); - expect(channel.data.custom_cool).to.eql(true); + expect(channel.data.custom.custom_cool).to.eql(true); }); it('default options', function () { @@ -2264,7 +2279,7 @@ describe('Ensure single channel per cid on client activeChannels state', () => { clientVish.connectUser = () => { clientVish.user = user; - clientVish.userID = user.id; + clientVish.user = { id: user.id }; clientVish.wsPromise = Promise.resolve(); }; @@ -2281,7 +2296,11 @@ describe('Ensure single channel per cid on client activeChannels state', () => { }); // to mock the channel.watch call - clientVish.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; + clientVish.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(mockedChannelResponse).response.data, + metadata: {}, + }); const channelVish_copy1 = clientVish.channel('messaging', channelVishId); const cid = `${channelType}:${channelVishId}`; @@ -2305,7 +2324,11 @@ describe('Ensure single channel per cid on client activeChannels state', () => { }); // to mock the channel.watch call - clientVish.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; + clientVish.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(mockedChannelResponse).response.data, + metadata: {}, + }); const channelVish_copy1 = clientVish.channel('messaging', channelVishId); @@ -2336,7 +2359,11 @@ describe('Ensure single channel per cid on client activeChannels state', () => { const mockedChannelResponse = generateChannel({ members: [memberVish, memberAmin], }); - clientVish.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; + clientVish.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(mockedChannelResponse).response.data, + metadata: {}, + }); // Lets start testing const channelVish_copy1 = clientVish.channel('messaging', { @@ -2384,7 +2411,11 @@ describe('Ensure single channel per cid on client activeChannels state', () => { }); // to mock the channel.watch call - clientVish.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; + clientVish.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(mockedChannelResponse).response.data, + metadata: {}, + }); // Case 1 =======================> const channelVish_copy1 = clientVish.channel('messaging', { @@ -2422,7 +2453,11 @@ describe('Ensure single channel per cid on client activeChannels state', () => { const mockedChannelResponse = generateChannel({ members: [memberVish, memberAmin], }); - clientVish.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; + clientVish.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(mockedChannelResponse).response.data, + metadata: {}, + }); // Lets start testing const channelVish_copy1 = clientVish.channel('messaging', undefined, { @@ -2470,7 +2505,11 @@ describe('Ensure single channel per cid on client activeChannels state', () => { }); // to mock the channel.watch call - clientVish.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; + clientVish.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(mockedChannelResponse).response.data, + metadata: {}, + }); // Case 1 =======================> const channelVish_copy1 = clientVish.channel('messaging', undefined, { @@ -2512,7 +2551,11 @@ describe('Ensure single channel per cid on client activeChannels state', () => { }); // to mock the channel.watch call - clientVish.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; + clientVish.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(mockedChannelResponse).response.data, + metadata: {}, + }); // Case 1 =======================> const channelVish_copy1 = clientVish.channel('messaging', undefined, { @@ -2555,35 +2598,55 @@ describe('event subscription and unsubscription', () => { const { unsubscribe: unsubscribe1 } = channel.on('message.new', () => {}); const { unsubscribe: unsubscribe2 } = channel.on(() => {}); - expect(Object.values(channel.listeners).length).to.be.equal(2); + // channel.listeners is now a Map>; unsubscribing the last handler for a + // key deletes the key entirely. + expect(channel.listeners.size).to.be.equal(2); unsubscribe1(); - expect(channel.listeners['message.new'].length).to.be.equal(0); + expect(channel.listeners.get('message.new')?.size ?? 0).to.be.equal(0); unsubscribe2(); - expect(channel.listeners['all'].length).to.be.equal(0); + expect(channel.listeners.get('all')?.size ?? 0).to.be.equal(0); }); }); describe('Channel search', async () => { const client = await getClientWithUser(); const channel = client.channel('messaging', uuidv4()); + // search now takes a single request object `{ payload }` and forwards the payload straight to + // the generated ChatApi.search (GET /search) via client.api.sendRequest. Sort normalization is + // no longer done inside search, so the caller passes the already-shaped `{ field, direction }`. it('search with sorting by defined field', async () => { - client.get = (url, config) => { - expect(config.payload.sort).to.be.eql([{ field: 'updated_at', direction: -1 }]); - }; - await channel.search('query', { sort: [{ updated_at: -1 }] }); + const sendRequest = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); + const payload = { query: 'query', sort: [{ field: 'updated_at', direction: -1 }] }; + await channel.search({ payload }); + expect(sendRequest).toHaveBeenCalledWith('GET', '/api/v2/chat/search', undefined, { + payload, + }); }); it('search with sorting by custom field', async () => { - client.get = (url, config) => { - expect(config.payload.sort).to.be.eql([{ field: 'custom_field', direction: -1 }]); - }; - await channel.search('query', { sort: [{ custom_field: -1 }] }); + const sendRequest = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); + const payload = { query: 'query', sort: [{ field: 'custom_field', direction: -1 }] }; + await channel.search({ payload }); + expect(sendRequest).toHaveBeenCalledWith('GET', '/api/v2/chat/search', undefined, { + payload, + }); }); it('sorting and offset works', async () => { - await expect(channel.search('query', { offset: 1, sort: [{ custom_field: -1 }] })); + vi.spyOn(client.api, 'sendRequest').mockResolvedValue({ body: {}, metadata: {} }); + await expect( + channel.search({ + payload: { query: 'query', offset: 1, sort: [{ custom_field: -1 }] }, + }), + ).resolves.toBeDefined(); }); it('next and offset fails', async () => { - await expect(channel.search('query', { offset: 1, next: 'next' })).rejects.toThrow(); + await expect( + channel.search({ payload: { query: 'query', offset: 1, next: 'next' } }), + ).rejects.toThrow(); }); }); @@ -2816,11 +2879,12 @@ describe('Channel.query', async () => { generateMsg({ created_at: new Date(1700000000000 + i * 1000).toISOString() }), ), }; - const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); + const stub = sinon + .stub(client.api, 'sendRequest') + .resolves({ body: mockedChannelQueryResponse, metadata: {} }); await channel.query(); expect(Object.keys(client.activeChannels).length).to.be.equal(0); - mock.restore(); + stub.restore(); }); it('seeds the message paginator with the full latest page on query', async () => { @@ -2833,15 +2897,16 @@ describe('Channel.query', async () => { generateMsg, ), }; - const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); + const stub = sinon + .stub(client.api, 'sendRequest') + .resolves({ body: mockedChannelQueryResponse, metadata: {} }); await channel.query({}, 'latest'); // A latest-page query seeds the message paginator with the returned page. expect(channel.messagePaginator.items).to.have.length( DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE, ); expect(channel.messagePaginator.headmostItem).to.not.equal(undefined); - mock.restore(); + stub.restore(); }); it('seeds the message paginator with a partial latest page on query', async () => { @@ -2854,13 +2919,14 @@ describe('Channel.query', async () => { generateMsg, ), }; - const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); + const stub = sinon + .stub(client.api, 'sendRequest') + .resolves({ body: mockedChannelQueryResponse, metadata: {} }); await channel.query({}, 'latest'); expect(channel.messagePaginator.items).to.have.length( DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE - 1, ); - mock.restore(); + stub.restore(); }); it(`update the messageComposer config`, async () => { @@ -2868,21 +2934,27 @@ describe('Channel.query', async () => { const channel = client.channel('messaging', uuidv4()); expect(channel.messageComposer.config.location.enabled).toBe(true); - const postStub = sinon.stub(client, 'post'); - postStub.onFirstCall().resolves({ - ...mockChannelQueryResponse, - channel: { - ...mockChannelQueryResponse.channel, - config: { ...mockChannelQueryResponse.channel.config, shared_locations: false }, + const sendRequestStub = sinon.stub(client.api, 'sendRequest'); + sendRequestStub.onFirstCall().resolves({ + body: { + ...mockChannelQueryResponse, + channel: { + ...mockChannelQueryResponse.channel, + config: { ...mockChannelQueryResponse.channel.config, shared_locations: false }, + }, }, + metadata: {}, }); - postStub.onSecondCall().resolves({ - ...mockChannelQueryResponse, - channel: { - ...mockChannelQueryResponse.channel, - config: { ...mockChannelQueryResponse.channel.config, shared_locations: true }, + sendRequestStub.onSecondCall().resolves({ + body: { + ...mockChannelQueryResponse, + channel: { + ...mockChannelQueryResponse.channel, + config: { ...mockChannelQueryResponse.channel.config, shared_locations: true }, + }, }, + metadata: {}, }); await channel.query(); @@ -2897,12 +2969,12 @@ describe('send reaction flow', () => { const messageId = 'msg-456'; const reaction = { type: 'love' }; const options = { enforce_unique: true, skip_push: true }; + // Reactions are now sent as a single request object: sendReaction({ id, reaction, ...flags }). + const request = { id: messageId, reaction, ...options }; let client; let channel; - let loggerSpy; let queueTaskSpy; - let postSpy; beforeEach(async () => { client = await getClientWithUser(); @@ -2913,15 +2985,16 @@ describe('send reaction flow', () => { channel = client.channel('messaging', 'test'); - loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); - postSpy = vi.spyOn(client, 'post').mockResolvedValue({}); }); afterEach(() => { vi.resetAllMocks(); }); + // NOTE: the 'Message id is missing' / 'Reaction object is missing' validation was dropped in + // the OpenAPI-client migration; sendReaction / _sendReaction no longer throw on missing fields. + describe('sendReaction', () => { beforeEach(() => { vi.spyOn(channel, '_sendReaction').mockResolvedValue({}); @@ -2931,20 +3004,8 @@ describe('send reaction flow', () => { vi.resetAllMocks(); }); - it('throws if messageID is missing', async () => { - await expect(channel.sendReaction('', reaction)).rejects.toThrow( - 'Message id is missing', - ); - }); - - it('throws if reaction is missing or empty', async () => { - await expect(channel.sendReaction(messageId, {})).rejects.toThrow( - 'Reaction object is missing', - ); - }); - it('queues task if offlineDb exists', async () => { - await channel.sendReaction(messageId, reaction, options); + await channel.sendReaction(request); expect(queueTaskSpy).toHaveBeenCalledTimes(1); @@ -2954,7 +3015,7 @@ describe('send reaction flow', () => { channelId: 'test', channelType: 'messaging', messageId, - payload: [messageId, reaction, options], + payload: [request], type: 'send-reaction', }, }); @@ -2965,55 +3026,50 @@ describe('send reaction flow', () => { it('falls back to _sendReaction if offlineDb throws', async () => { client.offlineDb.queueTask.mockRejectedValue(new Error('Offline failure')); - await channel.sendReaction(messageId, reaction, options); + await channel.sendReaction(request); - expect(loggerSpy).toHaveBeenCalledTimes(1); expect(channel._sendReaction).toHaveBeenCalledTimes(1); - expect(channel._sendReaction).toHaveBeenCalledWith(messageId, reaction, options); + expect(channel._sendReaction).toHaveBeenCalledWith(request); }); it('falls back to _sendReaction if offlineDb is undefined', async () => { client.offlineDb = undefined; - await channel.sendReaction(messageId, reaction, options); + await channel.sendReaction(request); expect(channel._sendReaction).toHaveBeenCalledTimes(1); - expect(channel._sendReaction).toHaveBeenCalledWith(messageId, reaction, options); + expect(channel._sendReaction).toHaveBeenCalledWith(request); }); }); describe('_sendReaction', () => { - it('throws if messageID is missing', async () => { - await expect(channel._sendReaction('', reaction)).rejects.toThrow( - 'Message id is missing', - ); - }); - - it('throws if reaction is missing or empty', async () => { - await expect(channel._sendReaction(messageId, {})).rejects.toThrow( - 'Reaction object is missing', - ); - }); - - it('posts to correct URL with reaction and options', async () => { - await channel._sendReaction(messageId, reaction, options); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}/reaction`, - { - reaction, - ...options, - }, + it('sends the reaction to the correct endpoint with reaction and options', async () => { + const sendRequestSpy = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); + + await channel._sendReaction(request); + + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(sendRequestSpy).toHaveBeenCalledWith( + 'POST', + '/api/v2/chat/messages/{id}/reaction', + { id: messageId }, + undefined, + { reaction, enforce_unique: true, skip_push: true }, + 'application/json', ); }); - it('returns the response from post', async () => { - postSpy.mockResolvedValue({ message: 'ok' }); + it('returns the response from the underlying call', async () => { + vi.spyOn(client.api, 'sendRequest').mockResolvedValue({ + body: { message: { id: messageId } }, + metadata: {}, + }); - const result = await channel._sendReaction(messageId, reaction); + const result = await channel._sendReaction(request); - expect(result).toEqual({ message: 'ok' }); + expect(result.message).toMatchObject({ id: messageId }); }); }); }); @@ -3023,12 +3079,13 @@ describe('delete reaction flow', () => { const reactionType = 'love'; const user_id = 'user-abc'; + // Reactions are now deleted with a single request object: deleteReaction({ id, type, user_id? }). + const request = { id: messageId, type: reactionType }; + let client; let channel; - let loggerSpy; let queueTaskSpy; let deleteReactionSpy; - let deleteSpy; beforeEach(async () => { client = await getClientWithUser({ id: user_id }); @@ -3045,16 +3102,17 @@ describe('delete reaction flow', () => { // (channel.deleteReaction now resolves the message via messagePaginator.getItem). channel.messagePaginator.ingestItem({ id: messageId }); - loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); deleteReactionSpy = vi.spyOn(client.offlineDb, 'deleteReaction').mockResolvedValue(); - deleteSpy = vi.spyOn(client, 'delete').mockResolvedValue({}); }); afterEach(() => { vi.resetAllMocks(); }); + // NOTE: the 'Deleting a reaction requires specifying both the message and reaction type' + // validation was dropped in the OpenAPI-client migration; the throw tests were removed. + describe('deleteReaction', () => { beforeEach(() => { vi.spyOn(channel, '_deleteReaction').mockResolvedValue({}); @@ -3064,32 +3122,16 @@ describe('delete reaction flow', () => { vi.resetAllMocks(); }); - it('throws if messageID or reactionType is missing', async () => { - await expect(channel.deleteReaction('', reactionType)).rejects.toThrow( - 'Deleting a reaction requires specifying both the message and reaction type', - ); - await expect(channel.deleteReaction(messageId, '')).rejects.toThrow( - 'Deleting a reaction requires specifying both the message and reaction type', - ); - }); - it('calls offlineDb.deleteReaction and queues task if offlineDb exists', async () => { - await channel.deleteReaction(messageId, reactionType); + await channel.deleteReaction(request); expect(deleteReactionSpy).toHaveBeenCalledTimes(1); expect(queueTaskSpy).toHaveBeenCalledTimes(1); - const expectedReaction = { - created_at: '', - updated_at: '', - message_id: messageId, - type: reactionType, - user_id: user_id, - }; - + // The optimistic reaction now carries only message_id and type. expect(deleteReactionSpy).toHaveBeenCalledWith({ - message: { id: messageId }, - reaction: expectedReaction, + message: channel.messagePaginator.getItem(messageId), + reaction: { message_id: messageId, type: reactionType }, }); expect(queueTaskSpy).toHaveBeenCalledWith({ @@ -3097,7 +3139,7 @@ describe('delete reaction flow', () => { channelId: 'test', channelType: 'messaging', messageId, - payload: [messageId, reactionType], + payload: [request], type: 'delete-reaction', }, }); @@ -3106,8 +3148,8 @@ describe('delete reaction flow', () => { }); it('skips calling offlineDb.deleteReaction if the message does not exist in the state, but still queues the task', async () => { - const unknownMessageId = 'some-unknown-message-id'; - await channel.deleteReaction(unknownMessageId, reactionType); + const unknownRequest = { id: 'some-unknown-message-id', type: reactionType }; + await channel.deleteReaction(unknownRequest); expect(deleteReactionSpy).not.toHaveBeenCalled(); expect(queueTaskSpy).toHaveBeenCalledTimes(1); @@ -3115,8 +3157,8 @@ describe('delete reaction flow', () => { task: { channelId: 'test', channelType: 'messaging', - messageId: unknownMessageId, - payload: [unknownMessageId, reactionType], + messageId: unknownRequest.id, + payload: [unknownRequest], type: 'delete-reaction', }, }); @@ -3126,67 +3168,64 @@ describe('delete reaction flow', () => { it('falls back to _deleteReaction if offlineDb throws', async () => { deleteReactionSpy.mockRejectedValue(new Error('Offline failure')); - await channel.deleteReaction(messageId, reactionType); + await channel.deleteReaction(request); - expect(loggerSpy).toHaveBeenCalledTimes(1); expect(channel._deleteReaction).toHaveBeenCalledTimes(1); - expect(channel._deleteReaction).toHaveBeenCalledWith( - messageId, - reactionType, - undefined, - ); + expect(channel._deleteReaction).toHaveBeenCalledWith(request); }); it('falls back to _deleteReaction if offlineDb is undefined', async () => { client.offlineDb = undefined; - await channel.deleteReaction(messageId, reactionType); + await channel.deleteReaction(request); expect(channel._deleteReaction).toHaveBeenCalledTimes(1); - expect(channel._deleteReaction).toHaveBeenCalledWith( - messageId, - reactionType, - undefined, - ); + expect(channel._deleteReaction).toHaveBeenCalledWith(request); }); }); describe('_deleteReaction', () => { - it('throws if messageID or reactionType is missing', async () => { - await expect(channel._deleteReaction(undefined, reactionType)).rejects.toThrow( - 'Deleting a reaction requires specifying both the message and reaction type', - ); - await expect(channel._deleteReaction(messageId, undefined)).rejects.toThrow( - 'Deleting a reaction requires specifying both the message and reaction type', - ); - }); - - it('calls delete with user_id when provided', async () => { - await channel._deleteReaction(messageId, reactionType, user_id); - - expect(deleteSpy).toHaveBeenCalledTimes(1); - expect(deleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}/reaction/${encodeURIComponent(reactionType)}`, + it('calls sendRequest with user_id when provided', async () => { + const sendRequestSpy = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); + + await channel._deleteReaction({ ...request, user_id }); + + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(sendRequestSpy).toHaveBeenCalledWith( + 'DELETE', + '/api/v2/chat/messages/{id}/reaction/{type}', + { id: messageId, type: reactionType }, { user_id }, ); }); - it('calls delete with empty body if user_id is not provided', async () => { - await channel._deleteReaction(messageId, reactionType); + it('calls sendRequest with undefined user_id if user_id is not provided', async () => { + const sendRequestSpy = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); + + await channel._deleteReaction(request); - expect(deleteSpy).toHaveBeenCalledTimes(1); - expect(deleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}/reaction/${encodeURIComponent(reactionType)}`, - {}, + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(sendRequestSpy).toHaveBeenCalledWith( + 'DELETE', + '/api/v2/chat/messages/{id}/reaction/{type}', + { id: messageId, type: reactionType }, + { user_id: undefined }, ); }); - it('returns the response from delete', async () => { - deleteSpy.mockResolvedValue({ success: true }); + it('returns the response from the underlying call', async () => { + vi.spyOn(client.api, 'sendRequest').mockResolvedValue({ + body: { message: { id: messageId } }, + metadata: {}, + }); - const result = await channel._deleteReaction(messageId, reactionType); + const result = await channel._deleteReaction(request); - expect(result).toEqual({ success: true }); + expect(result.message).toMatchObject({ id: messageId }); }); }); }); @@ -3194,9 +3233,7 @@ describe('delete reaction flow', () => { describe('message sending flow', () => { let client; let channel; - let loggerSpy; let queueTaskSpy; - let postSpy; const message = { id: 'msg-123', @@ -3204,11 +3241,8 @@ describe('message sending flow', () => { user: { id: 'user-abc' }, }; - const options = { - pending: true, - skip_push: true, - pending_message_metadata: { source: 'local' }, - }; + // Messages are now sent as a single request object: sendMessage({ message, ...flags }). + const request = { message, skip_push: true }; beforeEach(async () => { client = await getClientWithUser({ id: 'user-abc' }); @@ -3219,9 +3253,7 @@ describe('message sending flow', () => { channel = client.channel('messaging', 'test'); - loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); - postSpy = vi.spyOn(client, 'post').mockResolvedValue({}); }); afterEach(() => { @@ -3238,7 +3270,7 @@ describe('message sending flow', () => { }); it('queues task if offlineDb exists and message has ID', async () => { - const result = await channel.sendMessage(message, options); + const result = await channel.sendMessage(request); expect(queueTaskSpy).toHaveBeenCalledTimes(1); expect(queueTaskSpy).toHaveBeenCalledWith({ @@ -3246,7 +3278,7 @@ describe('message sending flow', () => { channelId: 'test', channelType: 'messaging', messageId: 'msg-123', - payload: [message, options], + payload: [request], type: 'send-message', }, }); @@ -3258,53 +3290,74 @@ describe('message sending flow', () => { it('falls back to _sendMessage if offlineDb is missing', async () => { client.offlineDb = undefined; - const result = await channel.sendMessage(message, options); + const result = await channel.sendMessage(request); expect(channel._sendMessage).toHaveBeenCalledTimes(1); - expect(channel._sendMessage).toHaveBeenCalledWith(message, options); + expect(channel._sendMessage).toHaveBeenCalledWith(request); expect(result).toEqual({}); }); it('falls back to _sendMessage if message.id is missing', async () => { - const msg = { ...message, id: undefined }; + const noIdRequest = { message: { ...message, id: undefined }, skip_push: true }; - await channel.sendMessage(msg, options); + await channel.sendMessage(noIdRequest); - expect(channel._sendMessage).toHaveBeenCalledWith(msg, options); + expect(channel._sendMessage).toHaveBeenCalledWith(noIdRequest); }); it('falls back to _sendMessage if offlineDb throws', async () => { queueTaskSpy.mockRejectedValue(new Error('Queue failed')); - const result = await channel.sendMessage(message, options); + const result = await channel.sendMessage(request); - expect(loggerSpy).toHaveBeenCalledTimes(1); - expect(channel._sendMessage).toHaveBeenCalledWith(message, options); + expect(channel._sendMessage).toHaveBeenCalledWith(request); expect(result).toEqual({}); }); }); describe('_sendMessage', () => { - it('posts the message to the correct endpoint with options', async () => { - const expectedUrl = `${client.baseURL}/channels/messaging/test/message`; - - const result = await channel._sendMessage(message, options); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy).toHaveBeenCalledWith(expectedUrl, { - message, - ...options, - }); - - expect(result).toEqual({}); + it('sends the message to the correct endpoint with options', async () => { + const sendRequestSpy = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); + + await channel._sendMessage(request); + + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(sendRequestSpy).toHaveBeenCalledWith( + 'POST', + '/api/v2/chat/channels/{type}/{id}/message', + { type: 'messaging', id: 'test' }, + undefined, + { + message, + keep_channel_hidden: undefined, + skip_enrich_url: undefined, + skip_push: true, + }, + 'application/json', + ); }); it('works without options', async () => { - await channel._sendMessage(message); + const sendRequestSpy = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels/messaging/test/message`, - { message }, + await channel._sendMessage({ message }); + + expect(sendRequestSpy).toHaveBeenCalledWith( + 'POST', + '/api/v2/chat/channels/{type}/{id}/message', + { type: 'messaging', id: 'test' }, + undefined, + { + message, + keep_channel_hidden: undefined, + skip_enrich_url: undefined, + skip_push: undefined, + }, + 'application/json', ); }); }); @@ -3331,50 +3384,45 @@ describe('share location', () => { const channel = client.channel('messaging', 'test'); const sendMessageSpy = vi.spyOn(channel, 'sendMessage').mockResolvedValue({}); const dispatchEventSpy = vi.spyOn(client, 'dispatchEvent').mockResolvedValue({}); - const updateLocationSpy = vi.spyOn(client, 'updateLocation').mockResolvedValue({}); + // stopLiveLocationSharing now goes through the generated client.updateLiveLocation. + const updateLiveLocationSpy = vi + .spyOn(client, 'updateLiveLocation') + .mockResolvedValue({}); return { channel, client, dispatchEventSpy, sendMessageSpy, - updateLocationSpy, + updateLiveLocationSpy, }; }; it('forwards the location object', async () => { const { channel, sendMessageSpy } = await setup(); + // sendSharedLocation now forwards a single-object sendMessage request wrapping the location. await channel.sendSharedLocation(staticLocation); expect(sendMessageSpy).toHaveBeenCalledWith({ - id: staticLocation.message_id, - shared_location: staticLocation, - user: undefined, + message: { id: staticLocation.message_id, shared_location: staticLocation }, }); await channel.sendSharedLocation(liveLocation); expect(sendMessageSpy).toHaveBeenCalledWith({ - id: liveLocation.message_id, - shared_location: liveLocation, - user: undefined, + message: { id: liveLocation.message_id, shared_location: liveLocation }, }); }); - it('injects the user object into the request payload', async () => { + it('does not inject a user into the request payload', async () => { + // The `userId`/`user` injection was dropped in the OpenAPI-client migration: + // sendSharedLocation takes only the location and forwards no user object. const { channel, sendMessageSpy } = await setup(); await channel.sendSharedLocation(staticLocation, userId); - expect(sendMessageSpy).toHaveBeenCalledWith({ - id: staticLocation.message_id, - shared_location: staticLocation, - user: { id: userId }, - }); - - await channel.sendSharedLocation(liveLocation, userId); - expect(sendMessageSpy).toHaveBeenCalledWith({ - id: liveLocation.message_id, - shared_location: liveLocation, - user: { id: userId }, + const sentArg = sendMessageSpy.mock.calls[0][0]; + expect(sentArg).to.deep.equal({ + message: { id: staticLocation.message_id, shared_location: staticLocation }, }); + expect(sentArg.message.user).to.be.undefined; }); it('emits live_location_sharing.started local event', async () => { const { channel, dispatchEventSpy, sendMessageSpy } = await setup(); @@ -3392,16 +3440,16 @@ describe('share location', () => { }); it('stops live location sharing', async () => { - const { channel, dispatchEventSpy, updateLocationSpy } = await setup(); + const { channel, dispatchEventSpy, updateLiveLocationSpy } = await setup(); - updateLocationSpy.mockResolvedValueOnce(staticLocation); + updateLiveLocationSpy.mockResolvedValueOnce(staticLocation); await channel.stopLiveLocationSharing(staticLocation); expect(dispatchEventSpy).toHaveBeenCalledWith({ live_location: expect.objectContaining(staticLocation), type: 'live_location_sharing.stopped', }); - updateLocationSpy.mockResolvedValueOnce(liveLocation); + updateLiveLocationSpy.mockResolvedValueOnce(liveLocation); await channel.stopLiveLocationSharing(liveLocation); expect(dispatchEventSpy).toHaveBeenCalledWith({ live_location: expect.objectContaining(liveLocation), diff --git a/test/unit/channel_manager.test.ts b/test/unit/channel_manager.test.ts index 74b5863229..fa5f9a5995 100644 --- a/test/unit/channel_manager.test.ts +++ b/test/unit/channel_manager.test.ts @@ -1,7 +1,7 @@ import sinon from 'sinon'; import { Channel, - ChannelAPIResponse, + ChannelStateResponseFields, ChannelManager, ChannelResponse, StreamChat, @@ -10,7 +10,9 @@ import { channelManagerEventToHandlerMapping, DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, QueryChannelsRequestType, - QueryChannelsAPIResponse, + QueryChannelsResponse, + RequestMetadata, + EventPayload, } from '../../src'; import { generateChannel } from './test-utils/generateChannel'; @@ -24,7 +26,7 @@ import { DEFAULT_QUERY_CHANNELS_RETRY_COUNT } from '../../src/constants'; describe('ChannelManager', () => { let client: StreamChat; let channelManager: ChannelManager; - let channelsResponse: ChannelAPIResponse[]; + let channelsResponse: ChannelStateResponseFields[]; beforeEach(async () => { client = await getClientWithUser(); @@ -37,7 +39,7 @@ describe('ChannelManager', () => { ]; client.hydrateActiveChannels(channelsResponse); const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), + client.channel(c.channel!.type, c.channel!.id), ); channelManager.state.partialNext({ channels, initialized: true }); }); @@ -66,8 +68,6 @@ describe('ChannelManager', () => { isLoading: false, isLoadingNext: false, hasNext: false, - filters: {}, - sort: {}, options: DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, }); expect(state.initialized).to.be.false; @@ -135,7 +135,7 @@ describe('ChannelManager', () => { }); const clientQueryChannelsSpy = vi - .spyOn(client, 'queryChannels') + .spyOn(client, 'queryChannelsAndHydrate') .mockImplementation(async () => []); await (channelManager as any).queryChannelsRequest({}); expect(clientQueryChannelsSpy).toHaveBeenCalledOnce(); @@ -295,14 +295,14 @@ describe('ChannelManager', () => { presence: true, state: true, watch: true, + filter_conditions: { team: 'blue' }, + sort: [{ field: 'last_message_at', direction: -1 }], }; channelManager.state.partialNext({ pagination: { ...pagination, - filters: { team: 'blue' }, options, - sort: { last_message_at: -1 }, }, }); @@ -310,9 +310,7 @@ describe('ChannelManager', () => { expect(client.offlineDb!.upsertCidsForQuery).toHaveBeenCalledExactlyOnceWith({ cids: channels.map((channel) => channel.cid), - filters: { team: 'blue' }, options, - sort: { last_message_at: -1 }, }); }); }); @@ -510,17 +508,14 @@ describe('ChannelManager', () => { mockChannelPages.flat().map((obj) => [obj.cid, obj]), ); clientQueryChannelsStub = sinon - .stub(client, 'queryChannels') - .callsFake((filters, _sort, options) => { - if ( - typeof filters.cid === 'object' && - filters.cid !== null && - '$in' in filters.cid - ) { - const toReturn = (filters.cid['$in'] ?? []) as string[]; + .stub(client, 'queryChannelsAndHydrate') + .callsFake((request) => { + const cidFilter = request?.filter_conditions?.cid; + if (typeof cidFilter === 'object' && cidFilter !== null && '$in' in cidFilter) { + const toReturn = (cidFilter['$in'] ?? []) as string[]; return Promise.resolve(toReturn.map((cid) => mockChannelCidMap[cid])); } - const offset = options?.offset ?? 0; + const offset = request?.offset ?? 0; return Promise.resolve(mockChannelPages[Math.floor(offset / 10)]); }); }); @@ -569,15 +564,17 @@ describe('ChannelManager', () => { ); stateChangeSpy.resetHistory(); - await channelManager.queryChannels({ filterA: true }, { asc: 1 }); + const request = { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + }; + await channelManager.queryChannels(request); const { channels } = channelManager.state.getLatestValue(); expect(client.offlineDb!.getChannelsForQuery).toHaveBeenCalledExactlyOnceWith({ userId: client.userID, - filters: { filterA: true }, - options: {}, - sort: { asc: 1 }, + options: request, }); expect( @@ -593,20 +590,20 @@ describe('ChannelManager', () => { }); it('passes full predefined-filter query options when hydrating channels from DB', async () => { - const options = { + const request = { + filter_conditions: {}, + sort: [], predefined_filter: 'user_messaging', filter_values: { user_id: 'dan' }, sort_values: { sort_field: 'last_message_at' }, limit: 20, }; - await channelManager.queryChannels({}, [], options); + await channelManager.queryChannels(request); expect(client.offlineDb!.getChannelsForQuery).toHaveBeenCalledExactlyOnceWith({ userId: client.userID, - filters: {}, - options, - sort: [], + options: request, }); }); @@ -619,7 +616,10 @@ describe('ChannelManager', () => { ); stateChangeSpy.resetHistory(); - await channelManager.queryChannels({ filterA: true }, { asc: 1 }); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + }); expect(client.offlineDb!.getChannelsForQuery).not.toHaveBeenCalled(); expect(hydrateActiveChannelsSpy.called).to.be.false; @@ -636,7 +636,11 @@ describe('ChannelManager', () => { ); stateChangeSpy.resetHistory(); - await channelManager.queryChannels({ filterA: true }, { asc: 1 }); + const request = { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + }; + await channelManager.queryChannels(request); expect(executeChannelsQuerySpy.called).to.be.false; expect(scheduleSyncStatusCallbackSpy.calledOnce).toBe(true); @@ -650,9 +654,9 @@ describe('ChannelManager', () => { expect( executeChannelsQuerySpy.calledOnceWithExactly({ - filters: { filterA: true }, - sort: { asc: 1 }, - options: {}, + filters: request.filter_conditions, + sort: request.sort, + options: request, stateOptions: {}, }), ).to.be.true; @@ -678,7 +682,10 @@ describe('ChannelManager', () => { ); stateChangeSpy.resetHistory(); - await channelManager.queryChannels({ filterA: true }, { asc: 1 }); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + }); expect(client.offlineDb!.getChannelsForQuery).toHaveBeenCalled(); expect(hydrateActiveChannelsSpy.called).to.be.true; @@ -697,7 +704,10 @@ describe('ChannelManager', () => { ); stateChangeSpy.resetHistory(); - await channelManager.queryChannels({ filterA: true }, { asc: 1 }); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + }); expect(client.offlineDb!.getChannelsForQuery).not.toHaveBeenCalled(); expect(hydrateActiveChannelsSpy.called).to.be.false; @@ -786,30 +796,29 @@ describe('ChannelManager', () => { stateChangeSpy.resetHistory(); await channelManager['executeChannelsQuery']({ - filters: { filterA: true }, - sort: { asc: 1 }, - options: { limit: 10, offset: 0 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 0, + }, }); const { channels } = channelManager.state.getLatestValue(); expect(clientQueryChannelsStub.calledOnce).to.be.true; - expect( - clientQueryChannelsStub.calledWith( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ), - ); expect(stateChangeSpy.callCount).to.equal(1); expect(stateChangeSpy.args[0][0]).to.deep.equal({ pagination: { - filters: {}, hasNext: true, isLoading: false, isLoadingNext: false, - options: { limit: 10, offset: 10 }, - sort: {}, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 10, + }, }, }); expect(channels.length).to.equal(10); @@ -827,6 +836,8 @@ describe('ChannelManager', () => { ).mockResolvedValue([]); const queryOptions = { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], predefined_filter: 'user_messaging', filter_values: { user_id: 'user123' }, sort_values: { sort_field: 'last_message_at' }, @@ -838,17 +849,10 @@ describe('ChannelManager', () => { }; const { pagination } = channelManager.state.getLatestValue(); channelManager.state.partialNext({ - pagination: { - ...pagination, - filters: { filterA: true }, - options: queryOptions, - sort: { asc: 1 }, - }, + pagination: { ...pagination, options: queryOptions }, }); await channelManager['executeChannelsQuery']({ - filters: { filterA: true }, - sort: { asc: 1 }, options: queryOptions, stateOptions: {}, }); @@ -857,7 +861,7 @@ describe('ChannelManager', () => { cids: mockChannelPages[0].map((channel) => channel.cid), filters: { filterA: true }, options: queryOptions, - sort: { asc: 1 }, + sort: [{ field: 'asc', direction: 1 }], }); }); @@ -865,7 +869,7 @@ describe('ChannelManager', () => { clientQueryChannelsStub.callsFake(() => mockChannelPages[2]); await channelManager['executeChannelsQuery']({ filters: { filterA: true }, - sort: { asc: 1 }, + sort: [{ field: 'asc', direction: 1 }], options: { limit: 10, offset: 0 }, }); @@ -895,7 +899,7 @@ describe('ChannelManager', () => { await channelManager['executeChannelsQuery']({ filters: { filterA: true }, - sort: { asc: 1 }, + sort: [{ field: 'asc', direction: 1 }], options: { limit: 10, offset: 0 }, }); @@ -937,7 +941,7 @@ describe('ChannelManager', () => { await channelManager['executeChannelsQuery']({ filters: { filterA: true }, - sort: { asc: 1 }, + sort: [{ field: 'asc', direction: 1 }], options: { limit: 10, offset: 0 }, }); @@ -967,7 +971,7 @@ describe('ChannelManager', () => { await channelManager['executeChannelsQuery']( { filters: { filterA: true }, - sort: { asc: 1 }, + sort: [{ field: 'asc', direction: 1 }], options: { limit: 10, offset: 0 }, }, 3, @@ -999,7 +1003,7 @@ describe('ChannelManager', () => { await channelManager['executeChannelsQuery']({ filters: { filterA: true }, - sort: { asc: 1 }, + sort: [{ field: 'asc', direction: 1 }], options: { limit: 10, offset: 0 }, }); @@ -1022,11 +1026,13 @@ describe('ChannelManager', () => { ); stateChangeSpy.resetHistory(); - await channelManager.queryChannels( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ); + const request = { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 0, + }; + await channelManager.queryChannels(request); const { channels } = channelManager.state.getLatestValue(); @@ -1034,22 +1040,18 @@ describe('ChannelManager', () => { expect(stateChangeSpy.callCount).to.equal(2); expect(stateChangeSpy.args[0][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: false, isLoading: true, isLoadingNext: false, - options: { limit: 10, offset: 0 }, - sort: { asc: 1 }, + options: request, }, }); expect(stateChangeSpy.args[1][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: false, - options: { limit: 10, offset: 10 }, - sort: { asc: 1 }, + options: { ...request, offset: 10 }, }, }); expect(channels.length).to.equal(10); @@ -1057,11 +1059,12 @@ describe('ChannelManager', () => { it('should properly update hasNext and offset if the first returned page is less than the limit', async () => { clientQueryChannelsStub.callsFake(() => mockChannelPages[2]); - await channelManager.queryChannels( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 0, + }); const { channels, @@ -1082,18 +1085,25 @@ describe('ChannelManager', () => { const queryChannelsOverride = async ( ...params: Parameters ) => { - const [filters, ...restParams] = params; - filters.cid = { $in: fetchedChannels.map((c) => c.cid) }; + const [request, ...restParams] = params; + const updatedRequest = { + ...request, + filter_conditions: { + ...request?.filter_conditions, + cid: { $in: fetchedChannels.map((c) => c.cid) }, + }, + }; - return await client.queryChannels(filters, ...restParams); + return await client.queryChannelsAndHydrate(updatedRequest, ...restParams); }; channelManager.setQueryChannelsRequest(queryChannelsOverride); - await channelManager.queryChannels( - { filterA: true }, - { asc: 1 }, - { limit: 15, offset: 0 }, - ); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 15, + offset: 0, + }); const { channels, @@ -1170,11 +1180,12 @@ describe('ChannelManager', () => { }); it('should properly set the new pagination parameters and update the offset after loading next', async () => { - await channelManager.queryChannels( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 0, + }); const stateChangeSpy = sinon.spy(); channelManager.state.subscribeWithSelector( @@ -1192,33 +1203,40 @@ describe('ChannelManager', () => { expect(stateChangeSpy.callCount).to.equal(2); expect(stateChangeSpy.args[0][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: true, - options: { limit: 10, offset: 10 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 10, + }, }, }); expect(stateChangeSpy.args[1][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: false, - options: { limit: 10, offset: 20 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 20, + }, }, }); expect(channels.length).to.equal(20); }); it('should properly paginate even if state.channels gets modified in the meantime', async () => { - await channelManager.queryChannels( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 0, + }); channelManager.state.next((prevState) => ({ ...prevState, channels: [...mockChannelPages[2].slice(0, 5), ...prevState.channels], @@ -1240,33 +1258,40 @@ describe('ChannelManager', () => { expect(stateChangeSpy.callCount).to.equal(2); expect(stateChangeSpy.args[0][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: true, - options: { limit: 10, offset: 10 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 10, + }, }, }); expect(stateChangeSpy.args[1][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: false, - options: { limit: 10, offset: 20 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 20, + }, }, }); expect(channels.length).to.equal(25); }); it('should properly deduplicate when paginating if channels from the next page have been promoted', async () => { - await channelManager.queryChannels( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 0, + }); channelManager.state.next((prevState) => ({ ...prevState, channels: [...mockChannelPages[1].slice(0, 5), ...prevState.channels], @@ -1288,33 +1313,40 @@ describe('ChannelManager', () => { expect(stateChangeSpy.callCount).to.equal(2); expect(stateChangeSpy.args[0][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: true, - options: { limit: 10, offset: 10 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 10, + }, }, }); expect(stateChangeSpy.args[1][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: false, - options: { limit: 10, offset: 20 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 20, + }, }, }); expect(channels.length).to.equal(20); }); it('should properly deduplicate when paginating if channels latter pages have been promoted and reached', async () => { - await channelManager.queryChannels( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 0, + }); channelManager.state.next((prevState) => ({ ...prevState, channels: [...mockChannelPages[2].slice(0, 3), ...prevState.channels], @@ -1342,32 +1374,41 @@ describe('ChannelManager', () => { expect(stateChangeSpy.callCount).to.equal(4); expect(stateChangeSpy.args[0][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: true, - options: { limit: 10, offset: 10 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 10, + }, }, }); expect(stateChangeSpy.args[1][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: false, - options: { limit: 10, offset: 20 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 20, + }, }, }); expect(stateChangeSpy.args[3][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: false, isLoading: false, isLoadingNext: false, - options: { limit: 10, offset: 25 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 25, + }, }, }); expect(channels.length).to.equal(25); @@ -1377,11 +1418,12 @@ describe('ChannelManager', () => { const { channels: initialChannels } = channelManager.state.getLatestValue(); expect(initialChannels.length).to.equal(0); - await channelManager.queryChannels( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 0, + }); await channelManager.loadNext(); const { @@ -1412,24 +1454,31 @@ describe('ChannelManager', () => { const queryChannelsOverride = async ( ...params: Parameters ) => { - const [filters, sort, options, ...restParams] = params; - const isInitialPage = options?.offset === 0; - filters.cid = { - $in: (isInitialPage ? fetchedChannels : fetchedNextPageChannels).map( - (c) => c.cid, - ), + const [request, ...restParams] = params; + const isInitialPage = request?.offset === 0; + const updatedRequest = { + ...request, + filter_conditions: { + ...request?.filter_conditions, + cid: { + $in: (isInitialPage ? fetchedChannels : fetchedNextPageChannels).map( + (c) => c.cid, + ), + }, + }, }; - return await client.queryChannels(filters, sort, options, ...restParams); + return await client.queryChannelsAndHydrate(updatedRequest, ...restParams); }; channelManager.setQueryChannelsRequest(queryChannelsOverride); - await channelManager.queryChannels( - { filterA: true }, - { asc: 1 }, - { limit: 15, offset: 0 }, - ); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 15, + offset: 0, + }); const { channels: prevChannels, @@ -1496,9 +1545,9 @@ describe('ChannelManager', () => { sort, }: { filter: Record; - sort?: NonNullable['sort']; + sort?: NonNullable['sort']; }) => { - vi.spyOn(client, 'post').mockResolvedValueOnce({ + vi.spyOn(client, 'queryChannels').mockResolvedValueOnce({ duration: '0.01s', channels: channelsResponse, predefined_filter: { @@ -1506,9 +1555,12 @@ describe('ChannelManager', () => { filter, sort, }, - } satisfies QueryChannelsAPIResponse); + metadata: {} as RequestMetadata, + }); - await channelManager.queryChannels({}, [], { + await channelManager.queryChannels({ + filter_conditions: {}, + sort: [], predefined_filter: 'messaging_channels', }); setChannelsStub.mockClear(); @@ -1548,7 +1600,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); @@ -1562,7 +1614,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); @@ -1580,7 +1632,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); @@ -1598,7 +1650,7 @@ describe('ChannelManager', () => { client.dispatchEvent({ type: 'notification.message_new', channel: { type: 'messaging', id: 'channel4' } as unknown as ChannelResponse, - }); + } as EventPayload<'notification.message_new'>); await clock.runAllAsync(); clock.restore(); @@ -1620,7 +1672,7 @@ describe('ChannelManager', () => { type: 'channel.visible', channel_id: 'channel4', channel_type: 'messaging', - }); + } as EventPayload<'channel.visible'>); await clock.runAllAsync(); clock.restore(); @@ -1640,8 +1692,8 @@ describe('ChannelManager', () => { type: 'member.updated', channel_id: 'channel2', channel_type: 'messaging', - member: { user: { id: client.userID } }, - }); + member: { user: { id: client.userId! } }, + } as EventPayload<'member.updated'>); expect(setChannelsStub).toHaveBeenCalledOnce(); expect( @@ -1666,7 +1718,7 @@ describe('ChannelManager', () => { channel_id: 'channel3', channel_type: 'messaging', member: { user: { id: client.userID } }, - }); + } as EventPayload<'member.updated'>); expect(setChannelsStub).toHaveBeenCalledOnce(); expect( @@ -1675,11 +1727,16 @@ describe('ChannelManager', () => { }); it('keeps non-predefined query behavior based on caller filters and sort', async () => { - vi.spyOn(client, 'post').mockResolvedValueOnce({ + vi.spyOn(client, 'queryChannels').mockResolvedValueOnce({ duration: '0.01s', channels: channelsResponse, - } satisfies QueryChannelsAPIResponse); - await channelManager.queryChannels({ archived: false }, [], { limit: 10 }); + metadata: {} as RequestMetadata, + }); + await channelManager.queryChannels({ + filter_conditions: { archived: false }, + sort: [], + limit: 10, + }); setChannelsStub.mockClear(); setChannelMembership('channel2', { archived_at: '2024-01-15T10:30:00Z', @@ -1689,13 +1746,13 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); it('preserves resolved predefined response metadata after loading the next page', async () => { - vi.spyOn(client, 'post') + vi.spyOn(client, 'queryChannels') .mockResolvedValueOnce({ duration: '0.01s', channels: channelsResponse, @@ -1704,7 +1761,8 @@ describe('ChannelManager', () => { filter: { archived: false }, sort: [{ field: 'pinned_at', direction: -1 }], }, - } satisfies QueryChannelsAPIResponse) + metadata: {} as RequestMetadata, + }) .mockResolvedValueOnce({ duration: '0.01s', channels: [ @@ -1716,9 +1774,12 @@ describe('ChannelManager', () => { filter: { archived: false }, sort: [{ field: 'pinned_at', direction: -1 }], }, - } satisfies QueryChannelsAPIResponse); + metadata: {} as RequestMetadata, + }); - await channelManager.queryChannels({}, [], { + await channelManager.queryChannels({ + filter_conditions: {}, + sort: [], predefined_filter: 'messaging_channels', limit: 2, offset: 0, @@ -1733,13 +1794,13 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); it('clears resolved predefined response metadata when switching to a non-predefined query', async () => { - vi.spyOn(client, 'post') + vi.spyOn(client, 'queryChannels') .mockResolvedValueOnce({ duration: '0.01s', channels: channelsResponse, @@ -1748,16 +1809,24 @@ describe('ChannelManager', () => { filter: { archived: false }, sort: [{ field: 'pinned_at', direction: -1 }], }, - } satisfies QueryChannelsAPIResponse) + metadata: {} as RequestMetadata, + }) .mockResolvedValueOnce({ duration: '0.01s', channels: channelsResponse, - } satisfies QueryChannelsAPIResponse); + metadata: {} as RequestMetadata, + }); - await channelManager.queryChannels({}, [], { + await channelManager.queryChannels({ + filter_conditions: {}, + sort: [], predefined_filter: 'messaging_channels', }); - await channelManager.queryChannels({}, [], { limit: 10 }); + await channelManager.queryChannels({ + filter_conditions: {}, + sort: [], + limit: 10, + }); setChannelsStub.mockClear(); setChannelMembership('channel2', { archived_at: '2024-01-15T10:30:00Z', @@ -1767,7 +1836,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); expect(setChannelsStub).toHaveBeenCalledOnce(); expect( @@ -1780,7 +1849,7 @@ describe('ChannelManager', () => { let channelToRemove: ChannelResponse; beforeEach(() => { - channelToRemove = channelsResponse[1].channel; + channelToRemove = channelsResponse[1].channel!; }); ( @@ -1793,14 +1862,23 @@ describe('ChannelManager', () => { it('should return early if channels is undefined', () => { channelManager.state.partialNext({ channels: undefined }); - client.dispatchEvent({ type: eventType, cid: channelToRemove.cid }); - client.dispatchEvent({ type: eventType, channel: channelToRemove }); + client.dispatchEvent({ + type: eventType, + cid: channelToRemove.cid, + } as EventPayload); + client.dispatchEvent({ + type: eventType, + channel: channelToRemove, + } as EventPayload); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); it('should remove the channel when event.cid matches', () => { - client.dispatchEvent({ type: eventType, cid: channelToRemove.cid }); + client.dispatchEvent({ + type: eventType, + cid: channelToRemove.cid, + } as EventPayload); expect(setChannelsStub).toHaveBeenCalledOnce(); const channels = setChannelsStub.mock.lastCall?.[0] as Channel[]; @@ -1809,7 +1887,10 @@ describe('ChannelManager', () => { }); it('should remove the channel when event.channel?.cid matches', () => { - client.dispatchEvent({ type: eventType, channel: channelToRemove }); + client.dispatchEvent({ + type: eventType, + channel: channelToRemove, + } as EventPayload); expect(setChannelsStub).toHaveBeenCalledOnce(); expect( @@ -1819,7 +1900,9 @@ describe('ChannelManager', () => { it('should not modify the list if no channels match', () => { const { channels: prevChannels } = channelManager.state.getLatestValue(); - client.dispatchEvent({ type: eventType, cid: 'channel123' }); + client.dispatchEvent({ type: eventType, cid: 'channel123' } as EventPayload< + typeof eventType + >); const { channels: newChannels } = channelManager.state.getLatestValue(); expect(setChannelsStub).toHaveBeenCalledTimes(0); @@ -1837,7 +1920,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); @@ -1851,7 +1934,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); const { channels: newChannels } = channelManager.state.getLatestValue(); @@ -1864,7 +1947,13 @@ describe('ChannelManager', () => { const { channels: prevChannels } = channelManager.state.getLatestValue(); channelManager.state.next((prevState) => ({ ...prevState, - pagination: { ...prevState.pagination, filters: { archived: false } }, + pagination: { + ...prevState.pagination, + options: { + ...prevState.pagination.options, + filter_conditions: { archived: false }, + }, + }, })); isChannelArchivedStub.mockReturnValueOnce(true); shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); @@ -1873,7 +1962,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); const { channels: newChannels } = channelManager.state.getLatestValue(); @@ -1886,7 +1975,13 @@ describe('ChannelManager', () => { const { channels: prevChannels } = channelManager.state.getLatestValue(); channelManager.state.next((prevState) => ({ ...prevState, - pagination: { ...prevState.pagination, filters: { archived: true } }, + pagination: { + ...prevState.pagination, + options: { + ...prevState.pagination.options, + filter_conditions: { archived: true }, + }, + }, })); isChannelArchivedStub.mockReturnValueOnce(false); shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); @@ -1895,7 +1990,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); const { channels: newChannels } = channelManager.state.getLatestValue(); @@ -1912,7 +2007,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); const { channels: newChannels } = channelManager.state.getLatestValue(); @@ -1942,7 +2037,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel4', - }); + } as EventPayload<'message.new'>); const { channels: newChannels } = channelManager.state.getLatestValue(); @@ -1965,7 +2060,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel4', - }); + } as EventPayload<'message.new'>); const stateAfter = channelManager.state.getLatestValue(); @@ -2001,7 +2096,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); const stateAfter = channelManager.state.getLatestValue(); @@ -2040,7 +2135,7 @@ describe('ChannelManager', () => { client.dispatchEvent({ type: 'notification.message_new', channel: {} as unknown as ChannelResponse, - }); + } as EventPayload<'notification.message_new'>); await clock.runAllAsync(); @@ -2051,14 +2146,14 @@ describe('ChannelManager', () => { it('should execute getAndWatchChannel if id and type are provided', async () => { const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); const newChannel = client.channel( - newChannelResponse.channel.type, - newChannelResponse.channel.id, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValue(newChannel); client.dispatchEvent({ type: 'notification.message_new', channel: { type: 'messaging', id: 'channel4' } as unknown as ChannelResponse, - }); + } as EventPayload<'notification.message_new'>); await clock.runAllAsync(); @@ -2075,18 +2170,27 @@ describe('ChannelManager', () => { shouldConsiderArchivedChannelsStub.mockReturnValue(true); const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); getAndWatchChannelStub.mockImplementation(async () => - client.channel(newChannelResponse.channel.type, newChannelResponse.channel.id), + client.channel( + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, + ), ); channelManager.state.next((prevState) => ({ ...prevState, - pagination: { ...prevState.pagination, filters: { archived: false } }, + pagination: { + ...prevState.pagination, + options: { + ...prevState.pagination.options, + filter_conditions: { archived: false }, + }, + }, })); client.dispatchEvent({ type: 'notification.message_new', - channel: newChannelResponse.channel as ChannelResponse, - }); + channel: newChannelResponse.channel, + } as EventPayload<'notification.message_new'>); await clock.runAllAsync(); @@ -2099,18 +2203,27 @@ describe('ChannelManager', () => { shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); getAndWatchChannelStub.mockImplementation(async () => - client.channel(newChannelResponse.channel.type, newChannelResponse.channel.id), + client.channel( + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, + ), ); channelManager.state.next((prevState) => ({ ...prevState, - pagination: { ...prevState.pagination, filters: { archived: true } }, + pagination: { + ...prevState.pagination, + options: { + ...prevState.pagination.options, + filter_conditions: { archived: true }, + }, + }, })); client.dispatchEvent({ type: 'notification.message_new', channel: newChannelResponse.channel as ChannelResponse, - }); + } as EventPayload<'notification.message_new'>); await clock.runAllAsync(); @@ -2121,8 +2234,8 @@ describe('ChannelManager', () => { it('should not update the state if allowNotLoadedChannelPromotionForEvent["notification.message_new"] is false', async () => { const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); const newChannel = client.channel( - newChannelResponse.channel.type, - newChannelResponse.channel.id, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValueOnce(newChannel); channelManager.setOptions({ @@ -2136,7 +2249,7 @@ describe('ChannelManager', () => { client.dispatchEvent({ type: 'notification.message_new', channel: { type: 'messaging', id: 'channel4' } as unknown as ChannelResponse, - }); + } as EventPayload<'notification.message_new'>); await clock.runAllAsync(); @@ -2149,8 +2262,8 @@ describe('ChannelManager', () => { it('should move channel when all criteria are met', async () => { const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); const newChannel = client.channel( - newChannelResponse.channel.type, - newChannelResponse.channel.id, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValueOnce(newChannel); @@ -2158,8 +2271,8 @@ describe('ChannelManager', () => { client.dispatchEvent({ type: 'notification.message_new', - channel: { type: 'messaging', id: 'channel4' } as unknown as ChannelResponse, - }); + channel: { type: 'messaging', id: 'channel4' }, + } as EventPayload<'notification.message_new'>); await clock.runAllAsync(); @@ -2188,8 +2301,8 @@ describe('ChannelManager', () => { it('should not add duplicate channels for multiple event invocations', async () => { const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); const newChannel = client.channel( - newChannelResponse.channel.type, - newChannelResponse.channel.id, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValue(newChannel); @@ -2198,7 +2311,7 @@ describe('ChannelManager', () => { const event = { type: 'notification.message_new', channel: newChannelResponse.channel as ChannelResponse, - } as const; + } as EventPayload<'notification.message_new'>; // call the event 3 times client.dispatchEvent(event); client.dispatchEvent(event); @@ -2243,8 +2356,8 @@ describe('ChannelManager', () => { it('should not update the state if the event has no id and type', async () => { client.dispatchEvent({ type: 'channel.visible', - channel: {} as unknown as ChannelResponse, - }); + channel: {}, + } as EventPayload<'channel.visible'>); await clock.runAllAsync(); @@ -2256,13 +2369,16 @@ describe('ChannelManager', () => { channelManager.state.partialNext({ channels: undefined }); const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); getAndWatchChannelStub.mockImplementation(async () => - client.channel(newChannelResponse.channel.type, newChannelResponse.channel.id), + client.channel( + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, + ), ); client.dispatchEvent({ type: 'channel.visible', - channel_id: newChannelResponse.channel.id, - channel_type: newChannelResponse.channel.type, - }); + channel_id: newChannelResponse.channel!.id, + channel_type: newChannelResponse.channel!.type, + } as EventPayload<'channel.visible'>); await clock.runAllAsync(); @@ -2276,19 +2392,28 @@ describe('ChannelManager', () => { const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); getAndWatchChannelStub.mockImplementation(async () => - client.channel(newChannelResponse.channel.type, newChannelResponse.channel.id), + client.channel( + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, + ), ); channelManager.state.next((prevState) => ({ ...prevState, - pagination: { ...prevState.pagination, filters: { archived: false } }, + pagination: { + ...prevState.pagination, + options: { + ...prevState.pagination.options, + filter_conditions: { archived: false }, + }, + }, })); client.dispatchEvent({ type: 'channel.visible', - channel_id: newChannelResponse.channel.cid, - channel_type: newChannelResponse.channel.type, - }); + channel_id: newChannelResponse.channel!.cid, + channel_type: newChannelResponse.channel!.type, + } as EventPayload<'channel.visible'>); await clock.runAllAsync(); @@ -2303,19 +2428,28 @@ describe('ChannelManager', () => { const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); getAndWatchChannelStub.mockImplementation(async () => - client.channel(newChannelResponse.channel.type, newChannelResponse.channel.id), + client.channel( + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, + ), ); channelManager.state.next((prevState) => ({ ...prevState, - pagination: { ...prevState.pagination, filters: { archived: true } }, + pagination: { + ...prevState.pagination, + options: { + ...prevState.pagination.options, + filter_conditions: { archived: true }, + }, + }, })); client.dispatchEvent({ type: 'channel.visible', - channel_id: newChannelResponse.channel.id, - channel_type: newChannelResponse.channel.type, - }); + channel_id: newChannelResponse.channel!.id, + channel_type: newChannelResponse.channel!.type, + } as EventPayload<'channel.visible'>); await clock.runAllAsync(); @@ -2326,8 +2460,8 @@ describe('ChannelManager', () => { it('should add the channel to the list if all criteria are met', async () => { const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); const newChannel = client.channel( - newChannelResponse.channel.type, - newChannelResponse.channel.id, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValue(newChannel); @@ -2337,7 +2471,7 @@ describe('ChannelManager', () => { type: 'channel.visible', channel_id: 'channel4', channel_type: 'messaging', - }); + } as EventPayload<'channel.visible'>); await clock.runAllAsync(); @@ -2375,8 +2509,8 @@ describe('ChannelManager', () => { type: 'member.updated', channel_id: id ?? 'channel2', channel_type: 'messaging', - member: { user: { id: client?.userID ?? 'anonymous' } }, - }); + member: { user: { id: client?.userId ?? 'anonymous' } }, + } as EventPayload<'member.updated'>); }); afterEach(() => { @@ -2389,7 +2523,7 @@ describe('ChannelManager', () => { channel_id: 'channel2', channel_type: 'messaging', member: { user: { id: 'wrongUserID' } }, - }); + } as EventPayload<'member.updated'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); client.dispatchEvent({ @@ -2397,7 +2531,7 @@ describe('ChannelManager', () => { channel_id: 'channel2', channel_type: 'messaging', member: {}, - }); + } as EventPayload<'member.updated'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); @@ -2405,19 +2539,19 @@ describe('ChannelManager', () => { client.dispatchEvent({ type: 'member.updated', member: { user: { id: 'user123' } }, - }); + } as EventPayload<'member.updated'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); client.dispatchEvent({ type: 'member.updated', member: { user: { id: 'user123' } }, channel_type: 'messaging', - }); + } as EventPayload<'member.updated'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); client.dispatchEvent({ type: 'member.updated', member: { user: { id: 'user123' } }, channel_id: 'channel2', - }); + } as EventPayload<'member.updated'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); @@ -2462,7 +2596,13 @@ describe('ChannelManager', () => { it('should handle archiving correctly', () => { channelManager.state.next((prevState) => ({ ...prevState, - pagination: { ...prevState.pagination, filters: { archived: true } }, + pagination: { + ...prevState.pagination, + options: { + ...prevState.pagination.options, + filter_conditions: { archived: true }, + }, + }, })); isChannelArchivedStub.mockReturnValueOnce(true); shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); @@ -2542,14 +2682,16 @@ describe('ChannelManager', () => { }); it('should not update state if event.channel defaults are missing', async () => { - client.dispatchEvent({ type: 'notification.added_to_channel' }); + client.dispatchEvent({ + type: 'notification.added_to_channel', + } as EventPayload<'notification.added_to_channel'>); await clock.runAllAsync(); expect(setChannelsStub).toHaveBeenCalledTimes(0); client.dispatchEvent({ type: 'notification.added_to_channel', channel: { id: '123' } as unknown as ChannelResponse, - }); + } as EventPayload<'notification.added_to_channel'>); await clock.runAllAsync(); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); @@ -2557,8 +2699,8 @@ describe('ChannelManager', () => { it('should not update state if allowNotLoadedChannelPromotionForEvent["notification.added_to_channel"] is false', async () => { const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); const newChannel = client.channel( - newChannelResponse.channel.type, - newChannelResponse.channel.id, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValueOnce(newChannel); channelManager.setOptions({ @@ -2575,8 +2717,8 @@ describe('ChannelManager', () => { id: 'channel4', type: 'messaging', members: [{ user_id: 'user1' }], - } as unknown as ChannelResponse, - }); + }, + } as EventPayload<'notification.added_to_channel'>); await clock.runAllAsync(); @@ -2587,8 +2729,8 @@ describe('ChannelManager', () => { it('should call getAndWatchChannel with correct parameters', async () => { const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); const newChannel = client.channel( - newChannelResponse.channel.type, - newChannelResponse.channel.id, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValueOnce(newChannel); client.dispatchEvent({ @@ -2597,8 +2739,8 @@ describe('ChannelManager', () => { id: 'channel4', type: 'messaging', members: [{ user_id: 'user1' }], - } as unknown as ChannelResponse, - }); + }, + } as EventPayload<'notification.added_to_channel'>); await clock.runAllAsync(); @@ -2614,8 +2756,8 @@ describe('ChannelManager', () => { it('should move the channel upwards when criteria is met', async () => { const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); const newChannel = client.channel( - newChannelResponse.channel.type, - newChannelResponse.channel.id, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValue(newChannel); @@ -2627,8 +2769,8 @@ describe('ChannelManager', () => { id: 'channel4', type: 'messaging', members: [{ user_id: 'user1' }], - } as unknown as ChannelResponse, - }); + }, + } as EventPayload<'notification.added_to_channel'>); await clock.runAllAsync(); diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index 80f15d9ad1..55daadec2f 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -14,7 +14,7 @@ describe('ChannelState clean', () => { let channel; beforeEach(() => { client = new StreamChat(); - client.userID = 'observer'; + client.user = { id: 'observer' }; channel = new Channel(client, 'live', 'stream', {}); client.activeChannels[channel.cid] = channel; }); diff --git a/test/unit/client.construction.test.ts b/test/unit/client.construction.test.ts new file mode 100644 index 0000000000..5ef9d55da6 --- /dev/null +++ b/test/unit/client.construction.test.ts @@ -0,0 +1,409 @@ +import axios from 'axios'; +import https from 'https'; +import sinon from 'sinon'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { ClientState } from '../../src/client_state'; +import { FixedSizeQueueCache } from '../../src/utils/FixedSizeQueueCache'; +import { InsightMetrics } from '../../src/insights'; +import { MessageDeliveryReporter } from '../../src/messageDelivery'; +import { Moderation } from '../../src/moderation'; +import { NotificationManager } from '../../src/notifications'; +import { PollManager } from '../../src/poll_manager'; +import { ReminderManager } from '../../src/reminders'; +import { StateStore } from '../../src/store'; +import { StreamChat } from '../../src/client'; +import { ThreadManager } from '../../src/thread_manager'; +import { TokenManager } from '../../src/token_manager'; +import { UploadManager } from '../../src/uploadManager'; +import { axiosParamsSerializer } from '../../src/utils'; + +const API_KEY = 'apiKey'; + +const snapshotLocalTestEnv = () => { + const run = process.env.STREAM_LOCAL_TEST_RUN; + const host = process.env.STREAM_LOCAL_TEST_HOST; + delete process.env.STREAM_LOCAL_TEST_RUN; + delete process.env.STREAM_LOCAL_TEST_HOST; + return () => { + if (typeof run === 'undefined') delete process.env.STREAM_LOCAL_TEST_RUN; + else process.env.STREAM_LOCAL_TEST_RUN = run; + if (typeof host === 'undefined') delete process.env.STREAM_LOCAL_TEST_HOST; + else process.env.STREAM_LOCAL_TEST_HOST = host; + }; +}; + +describe('StreamChat construction', () => { + let restoreEnv: () => void; + + beforeEach(() => { + delete (StreamChat as unknown as { _instance?: StreamChat })._instance; + restoreEnv = snapshotLocalTestEnv(); + }); + + afterEach(() => { + sinon.restore(); + restoreEnv(); + }); + + describe('signature', () => { + it('accepts just a key', () => { + const client = new StreamChat(API_KEY); + expect(client.key).to.equal(API_KEY); + expect(client.axiosInstance.defaults.timeout).to.equal(3000); + }); + + it('accepts (key, options)', () => { + const client = new StreamChat(API_KEY, { + axiosRequestConfig: { timeout: 5000 }, + }); + expect(client.key).to.equal(API_KEY); + expect(client.axiosInstance.defaults.timeout).to.equal(5000); + }); + + it('treats an omitted options argument as an empty options object', () => { + const client = new StreamChat(API_KEY); + expect(client.options.warmUp).to.equal(false); + expect(client.options.recoverStateOnReconnect).to.equal(true); + expect(client.options.disableCache).to.equal(false); + }); + }); + + describe('initial instance state', () => { + it('initializes empty collections and null connection refs', () => { + const client = new StreamChat(API_KEY); + + expect(client.listeners).to.be.instanceOf(Map); + expect(client.listeners.size).to.equal(0); + expect(client.mutedChannels).to.deep.equal([]); + expect(client.mutedUsers).to.deep.equal([]); + expect(client.activeChannels).to.deep.equal({}); + expect(client.configs).to.deep.equal({}); + + expect(client.wsConnection).to.be.null; + expect(client.wsPromise).to.be.null; + expect(client.setUserPromise).to.be.null; + + expect(client.anonymous).to.equal(false); + expect(client.defaultWSTimeoutWithFallback).to.equal(6000); + expect(client.defaultWSTimeout).to.equal(15000); + }); + + it('initializes blockedUsers as a StateStore with empty userIds', () => { + const client = new StreamChat(API_KEY); + expect(client.blockedUsers).to.be.instanceOf(StateStore); + expect(client.blockedUsers.getLatestValue()).to.deep.equal({ userIds: [] }); + }); + + it('does not share mutable state between instances', () => { + const a = new StreamChat(API_KEY); + const b = new StreamChat(API_KEY); + + expect(a.listeners).to.not.equal(b.listeners); + expect(a.mutedChannels).to.not.equal(b.mutedChannels); + expect(a.mutedUsers).to.not.equal(b.mutedUsers); + expect(a.activeChannels).to.not.equal(b.activeChannels); + expect(a.configs).to.not.equal(b.configs); + expect(a.blockedUsers).to.not.equal(b.blockedUsers); + expect(a.options).to.not.equal(b.options); + expect(a.axiosInstance).to.not.equal(b.axiosInstance); + }); + }); + + describe('options resolution', () => { + it('applies defaults when no options are passed', () => { + const client = new StreamChat(API_KEY); + + expect(client.options.warmUp).to.equal(false); + expect(client.options.recoverStateOnReconnect).to.equal(true); + expect(client.options.disableCache).to.equal(false); + expect(client.options.wsUrlParams).to.be.instanceOf(URLSearchParams); + expect(client.recoverStateOnReconnect).to.equal(true); + }); + + it('honors user-provided overrides', () => { + const client = new StreamChat(API_KEY, { + warmUp: true, + disableCache: true, + recoverStateOnReconnect: false, + }); + + expect(client.options.warmUp).to.equal(true); + expect(client.options.disableCache).to.equal(true); + expect(client.options.recoverStateOnReconnect).to.equal(false); + expect(client.recoverStateOnReconnect).to.equal(false); + }); + + it('passes persistUserOnConnectionFailure through to the client', () => { + const defaultClient = new StreamChat(API_KEY); + expect(defaultClient.persistUserOnConnectionFailure).to.be.undefined; + + const customClient = new StreamChat(API_KEY, { + persistUserOnConnectionFailure: true, + }); + expect(customClient.persistUserOnConnectionFailure).to.equal(true); + }); + }); + + describe('axios instantiation', () => { + let createSpy: sinon.SinonSpy< + Parameters, + ReturnType + >; + + beforeEach(() => { + createSpy = sinon.spy(axios, 'create'); + }); + + it('invokes axios.create exactly once and stores the result on axiosInstance', () => { + const client = new StreamChat(API_KEY); + expect(createSpy.calledOnce).to.be.true; + expect(client.axiosInstance).to.equal(createSpy.firstCall.returnValue); + }); + + it('passes baked-in defaults (timeout, withCredentials, paramsSerializer) into axios.create', () => { + new StreamChat(API_KEY, { browser: true }); + const config = createSpy.firstCall.args[0]!; + expect(config.timeout).to.equal(3000); + expect(config.withCredentials).to.equal(false); + expect(config.paramsSerializer).to.equal(axiosParamsSerializer); + }); + + it('bakes defaults into the axios instance defaults', () => { + const client = new StreamChat(API_KEY); + expect(client.axiosInstance.defaults.timeout).to.equal(3000); + expect(client.axiosInstance.defaults.withCredentials).to.equal(false); + expect(client.axiosInstance.defaults.paramsSerializer).to.equal( + axiosParamsSerializer, + ); + }); + + it('spreads axiosRequestConfig values into the axios.create config', () => { + const axiosRequestConfig = { + timeout: 9999, + withCredentials: true, + headers: { 'Cache-Control': 'no-cache' }, + }; + const client = new StreamChat(API_KEY, { axiosRequestConfig }); + expect(client.axiosInstance.defaults.timeout).to.equal(9999); + expect(client.axiosInstance.defaults.withCredentials).to.equal(true); + expect(client.axiosInstance.defaults.headers).to.include({ + 'Cache-Control': 'no-cache', + }); + }); + + it('keeps paramsSerializer fixed even when axiosRequestConfig tries to override it', () => { + const customSerializer = () => 'overridden'; + const client = new StreamChat(API_KEY, { + axiosRequestConfig: { paramsSerializer: customSerializer }, + }); + expect(client.axiosInstance.defaults.paramsSerializer).to.equal( + axiosParamsSerializer, + ); + expect(client.axiosInstance.defaults.paramsSerializer).to.not.equal( + customSerializer, + ); + }); + + it('preserves axiosRequestConfig on the client options', () => { + const axiosRequestConfig = { headers: { 'Cache-Control': 'no-cache' } }; + const client = new StreamChat(API_KEY, { axiosRequestConfig }); + expect(client.options.axiosRequestConfig).to.equal(axiosRequestConfig); + }); + + it('produces a paramsSerializer that matches axiosParamsSerializer behavior', () => { + const client = new StreamChat(API_KEY); + const serializer = client.axiosInstance.defaults.paramsSerializer as ( + params: Record, + ) => string; + const sample = { a: 1, b: [2, 3], skip: undefined }; + expect(serializer(sample)).to.equal(axiosParamsSerializer!(sample)); + }); + + describe('httpsAgent', () => { + it('auto-creates a keep-alive https.Agent in node mode', () => { + const client = new StreamChat(API_KEY, { browser: false }); + const httpsAgent = client.axiosInstance.defaults.httpsAgent as https.Agent; + expect(httpsAgent).to.be.instanceOf(https.Agent); + expect(httpsAgent.keepAlive).to.equal(true); + }); + + it('lets axiosRequestConfig.httpsAgent override the auto-created agent', () => { + const customAgent = new https.Agent({ keepAlive: false }); + const client = new StreamChat(API_KEY, { + browser: false, + axiosRequestConfig: { httpsAgent: customAgent }, + }); + expect(client.axiosInstance.defaults.httpsAgent).to.equal(customAgent); + }); + + it('does not auto-create an httpsAgent in browser mode', () => { + const client = new StreamChat(API_KEY, { browser: true }); + expect(client.axiosInstance.defaults.httpsAgent).to.be.undefined; + }); + }); + }); + + describe('baseURL resolution', () => { + let setBaseURLSpy: sinon.SinonSpy<[string], void>; + + beforeEach(() => { + setBaseURLSpy = sinon.spy(StreamChat.prototype, 'setBaseURL'); + }); + + it('defaults to the production baseURL with a wss WebSocket URL', () => { + const client = new StreamChat(API_KEY); + expect(client.baseURL).to.equal('https://chat.stream-io-api.com'); + expect(client.wsBaseURL).to.equal('wss://chat.stream-io-api.com'); + expect(setBaseURLSpy.calledOnce).to.be.true; + expect(setBaseURLSpy.firstCall.args[0]).to.equal('https://chat.stream-io-api.com'); + }); + + it('uses a custom baseURL when provided', () => { + const client = new StreamChat(API_KEY, { baseURL: 'http://example.com:3030' }); + expect(client.baseURL).to.equal('http://example.com:3030'); + // http -> ws, :3030 -> :8800 + expect(client.wsBaseURL).to.equal('ws://example.com:8800'); + }); + + it('overrides the baseURL when STREAM_LOCAL_TEST_RUN is set', () => { + process.env.STREAM_LOCAL_TEST_RUN = 'true'; + const client = new StreamChat(API_KEY); + expect(client.baseURL).to.equal('http://localhost:3030'); + expect(client.wsBaseURL).to.equal('ws://localhost:8800'); + // default url + override + expect(setBaseURLSpy.callCount).to.equal(2); + }); + + it('further overrides the baseURL when STREAM_LOCAL_TEST_HOST is set', () => { + process.env.STREAM_LOCAL_TEST_HOST = 'mybox.test:3030'; + const client = new StreamChat(API_KEY); + expect(client.baseURL).to.equal('http://mybox.test:3030'); + expect(client.wsBaseURL).to.equal('ws://mybox.test:8800'); + // default url + host override + expect(setBaseURLSpy.callCount).to.equal(2); + }); + + it('lets STREAM_LOCAL_TEST_HOST win over STREAM_LOCAL_TEST_RUN', () => { + process.env.STREAM_LOCAL_TEST_RUN = 'true'; + process.env.STREAM_LOCAL_TEST_HOST = 'mybox.test:3030'; + const client = new StreamChat(API_KEY); + expect(client.baseURL).to.equal('http://mybox.test:3030'); + expect(setBaseURLSpy.callCount).to.equal(3); + }); + }); + + describe('platform detection', () => { + it('auto-detects platform based on the global window', () => { + const client = new StreamChat(API_KEY); + const expectedBrowser = typeof window !== 'undefined'; + expect(client.browser).to.equal(expectedBrowser); + expect(client.node).to.equal(!expectedBrowser); + }); + + it('honors an explicit browser:true override', () => { + const client = new StreamChat(API_KEY, { browser: true }); + expect(client.browser).to.equal(true); + expect(client.node).to.equal(false); + }); + + it('honors an explicit browser:false override', () => { + const client = new StreamChat(API_KEY, { browser: false }); + expect(client.browser).to.equal(false); + expect(client.node).to.equal(true); + }); + }); + + describe('subsystem managers', () => { + it('constructs the canonical set of managers', () => { + const client = new StreamChat(API_KEY); + + expect(client.state).to.be.instanceOf(ClientState); + expect(client.notifications).to.be.instanceOf(NotificationManager); + expect(client.uploadManager).to.be.instanceOf(UploadManager); + expect(client.moderation).to.be.instanceOf(Moderation); + expect(client.tokenManager).to.be.instanceOf(TokenManager); + expect(client.threads).to.be.instanceOf(ThreadManager); + expect(client.polls).to.be.instanceOf(PollManager); + expect(client.reminders).to.be.instanceOf(ReminderManager); + expect(client.messageDeliveryReporter).to.be.instanceOf(MessageDeliveryReporter); + expect(client.messageComposerCache).to.be.instanceOf(FixedSizeQueueCache); + expect(client.insightMetrics).to.be.instanceOf(InsightMetrics); + }); + + it('reuses an externally supplied NotificationManager instead of wrapping it', () => { + const notifications = new NotificationManager(); + const client = new StreamChat(API_KEY, { notifications }); + expect(client.notifications).to.equal(notifications); + }); + + it('constructs the TokenManager with no preloaded secret', () => { + const client = new StreamChat(API_KEY); + expect(client.tokenManager.secret).to.be.undefined; + }); + + it('caps the message composer cache at 64 entries', () => { + const client = new StreamChat(API_KEY); + for (let i = 0; i < 64; i++) { + client.messageComposerCache.add(`k-${i}`, { i } as never); + } + expect(client.messageComposerCache.peek('k-0')).to.not.be.undefined; + + client.messageComposerCache.add('k-64', { i: 64 } as never); + + expect(client.messageComposerCache.peek('k-0')).to.be.undefined; + expect(client.messageComposerCache.peek('k-64')).to.deep.equal({ i: 64 }); + }); + + it('builds fresh manager instances per client', () => { + const a = new StreamChat(API_KEY); + const b = new StreamChat(API_KEY); + expect(a.threads).to.not.equal(b.threads); + expect(a.polls).to.not.equal(b.polls); + expect(a.reminders).to.not.equal(b.reminders); + expect(a.tokenManager).to.not.equal(b.tokenManager); + expect(a.moderation).to.not.equal(b.moderation); + expect(a.uploadManager).to.not.equal(b.uploadManager); + expect(a.messageDeliveryReporter).to.not.equal(b.messageDeliveryReporter); + expect(a.messageComposerCache).to.not.equal(b.messageComposerCache); + expect(a.insightMetrics).to.not.equal(b.insightMetrics); + expect(a.notifications).to.not.equal(b.notifications); + expect(a.state).to.not.equal(b.state); + }); + }); + + describe('getInstance', () => { + it('returns the same instance for repeated calls', () => { + const a = StreamChat.getInstance(API_KEY); + const b = StreamChat.getInstance(API_KEY); + expect(a).to.equal(b); + }); + + it('caches the instance on the static _instance slot', () => { + const instance = StreamChat.getInstance(API_KEY); + expect((StreamChat as unknown as { _instance: StreamChat })._instance).to.equal( + instance, + ); + }); + + it('ignores subsequent key and options after the first call', () => { + const first = StreamChat.getInstance(API_KEY, { + axiosRequestConfig: { timeout: 1111 }, + }); + const second = StreamChat.getInstance('different-key', { + axiosRequestConfig: { timeout: 9999 }, + }); + + expect(second).to.equal(first); + expect(first.key).to.equal(API_KEY); + expect(first.axiosInstance.defaults.timeout).to.equal(1111); + }); + + it('routes options through the constructor on first call', () => { + const client = StreamChat.getInstance(API_KEY, { + axiosRequestConfig: { timeout: 5000 }, + }); + expect(client.axiosInstance.defaults.timeout).to.equal(5000); + }); + }); +}); diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 6f0b8713c3..b58aad385e 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -4,11 +4,15 @@ import { getClientWithUser } from './test-utils/getClient'; import * as utils from '../../src/utils'; import { StreamChat } from '../../src/client'; +import { chatLoggerSystem } from '../../src/logger'; import { ConnectionState } from '../../src/connection_fallback'; import { StableWSConnection } from '../../src/connection'; import { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse'; import { generateThreadResponse } from './test-utils/generateThreadResponse'; -import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from '../../src/constants'; +import { + DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE, + DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE, +} from '../../src/constants'; import { describe, @@ -21,7 +25,6 @@ import { vi, } from 'vitest'; import { Channel } from '../../src'; -import { normalizeQuerySort } from '../../src/utils'; import { MockOfflineDB } from './offline-support/MockOfflineDB'; describe('StreamChat getInstance', () => { @@ -84,7 +87,7 @@ describe('StreamChat getInstance', () => { }); it('should set axios request config correctly', async () => { - const client = StreamChat.getInstance('key', 'secret', { + const client = StreamChat.getInstance('key', { axiosRequestConfig: { headers: { 'Cache-Control': 'no-cache', @@ -92,42 +95,28 @@ describe('StreamChat getInstance', () => { }, }, }); + client.tokenManager.getToken = () => 'mock-token'; - let requestConfig = {}; - client.axiosInstance.get = (url, config) => { - requestConfig = config; - return { - status: 200, - }; - }; - - await client.getChannelType('messaging'); - - expect(requestConfig.headers).to.haveOwnProperty('Cache-Control', 'no-cache'); - expect(requestConfig.headers).to.haveOwnProperty('Pragma', 'no-cache'); - }); + const requestSpy = vi + .spyOn(client.axiosInstance, 'request') + .mockResolvedValueOnce({ data: {}, status: 200 }); - it('app settings do not mutate', async () => { - const client = new StreamChat('key', 'secret'); - const cert = Buffer.from('test'); - const options = { apn_config: { p12_cert: cert } }; - await expect(client.updateAppSettings(options)).rejects.toThrow(/.*/); + await client.getAppSettings(); - expect(options.apn_config.p12_cert).to.be.eql(cert); + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy.mock.calls[0][0].headers).to.haveOwnProperty( + 'Cache-Control', + 'no-cache', + ); + expect(requestSpy.mock.calls[0][0].headers).to.haveOwnProperty('Pragma', 'no-cache'); }); it('should correctly resolve _cacheEnabled', async () => { - const client1 = new StreamChat('key', 'secret', { - disableCache: true, - }); + const client1 = new StreamChat('key', { disableCache: true }); expect(client1._cacheEnabled()).to.be.equal(false); - const client2 = new StreamChat('key', 'secret', { - disableCache: false, - }); + const client2 = new StreamChat('key', { disableCache: false }); expect(client2._cacheEnabled()).to.be.equal(true); - const client3 = new StreamChat('key', { - disableCache: true, - }); + const client3 = new StreamChat('key'); expect(client3._cacheEnabled()).to.be.equal(true); }); }); @@ -274,6 +263,41 @@ describe('Client active channels cache', () => { }); }); +describe('client.channel() custom-data preservation', () => { + let client; + beforeEach(async () => { + client = await getClientWithUser(); + }); + + it("does not wipe an existing channel's custom when re-resolved with a non-custom arg", () => { + // First resolution seeds the channel's custom data (e.g. its display name). + const channel = client.channel('messaging', 'little-italy', { + custom: { name: 'Little-Italy' }, + }); + expect(channel.data.custom.name).to.equal('Little-Italy'); + + // A later `client.channel(type, id, arg)` for the SAME channel that passes other fields but + // no `custom` — as thread hydration and getChannel do (`{ members }`, or even + // `{ members: undefined }` when no members are given) — must NOT blank the channel's custom. + // Regression: getChannelById used to run `channel.data.custom = arg.custom` on any non-empty + // arg, wiping custom to `undefined` and dropping the channel's name from the channel list. + const viaMembers = client.channel('messaging', 'little-italy', { + members: [{ user_id: 'u2' }], + }); + expect(viaMembers).to.equal(channel); // same cached instance + expect(channel.data.custom.name).to.equal('Little-Italy'); + + client.channel('messaging', 'little-italy', { members: undefined }); + expect(channel.data.custom.name).to.equal('Little-Italy'); + }); + + it('applies custom when the caller actually provides it', () => { + const channel = client.channel('messaging', 'ch-custom', { custom: { name: 'Old' } }); + client.channel('messaging', 'ch-custom', { custom: { name: 'New' } }); + expect(channel.data.custom.name).to.equal('New'); + }); +}); + describe('Client openConnection', () => { let client; @@ -295,7 +319,7 @@ describe('Client openConnection', () => { }); it('should return same promise in case of multiple calls', async () => { - client.userID = 'vishal'; + client.user = { id: 'vishal' }; client._setUser({ id: 'vishal', }); @@ -433,163 +457,22 @@ describe('Detect node environment', () => { }); it('should warn when using connectUser on a node environment', async () => { - const _warn = console.warn; - let warning = ''; - console.warn = (msg) => { - warning = msg; - }; + const sinkSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: sinkSpy, level: 'trace' }, + }); try { await client.connectUser({ id: 'user' }, 'fake token'); } catch (e) {} await client.disconnectUser(); - expect(warning).to.equal( - '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.', - ); - - console.warn = _warn; - }); - - it('should not warn when adding the allowServerSideConnect flag', async () => { - const client2 = new StreamChat('', '', { allowServerSideConnect: true }); - - const _warn = console.warn; - let warning = ''; - console.warn = (msg) => { - warning = msg; - }; - - try { - await client2.connectUser({ id: 'user' }, 'fake token'); - } catch (e) {} - - await client2.disconnect(); - expect(warning).to.equal(''); - - console.warn = _warn; - }); -}); - -describe('Client deleteUsers', () => { - it('should allow completely optional options', async () => { - const client = await getClientWithUser(); - - client.post = () => Promise.resolve(); - - await expect(client.deleteUsers(['_'])).resolves.toEqual(); - }); - - it('delete types - options.conversations', async () => { - const client = await getClientWithUser(); - - client.post = () => Promise.resolve(); - - await expect(client.deleteUsers(['_'], { conversations: 'hard' })).resolves.toEqual(); - await expect(client.deleteUsers(['_'], { conversations: 'soft' })).resolves.toEqual(); - await expect( - client.deleteUsers(['_'], { conversations: 'pruning' }), - ).rejects.toThrow(); - await expect(client.deleteUsers(['_'], { conversations: '' })).rejects.toThrow(); - }); - - it('delete types - options.messages', async () => { - const client = await getClientWithUser(); - - client.post = () => Promise.resolve(); - - await expect(client.deleteUsers(['_'], { messages: 'hard' })).resolves.toEqual(); - await expect(client.deleteUsers(['_'], { messages: 'soft' })).resolves.toEqual(); - await expect(client.deleteUsers(['_'], { messages: 'pruning' })).resolves.toEqual(); - await expect(client.deleteUsers(['_'], { messages: '' })).rejects.toThrow(); - }); - - it('delete types - options.user', async () => { - const client = await getClientWithUser(); - - client.post = () => Promise.resolve(); - - await expect(client.deleteUsers(['_'], { user: 'hard' })).resolves.toEqual(); - await expect(client.deleteUsers(['_'], { user: 'soft' })).resolves.toEqual(); - await expect(client.deleteUsers(['_'], { user: 'pruning' })).resolves.toEqual(); - await expect(client.deleteUsers(['_'], { user: '' })).rejects.toThrow(); - }); -}); - -describe('updateMessage should maintain data integrity', () => { - let client; - - beforeEach(async () => { - client = await getClientWithUser(); - }); - - it('should convert mentioned_users from array of user objects to array of userIds', async () => { - client.post = (url, config) => { - expect(typeof config.message.mentioned_users[0]).to.be.equal('string'); - expect(config.message.mentioned_users[0]).to.be.equal('uthred'); - }; - await client.updateMessage( - generateMsg({ - mentioned_users: [ - { - id: 'uthred', - name: 'Uthred Of Bebbanburg', - }, - ], - }), - ); - - await client.updateMessage( - generateMsg({ - mentioned_users: ['uthred'], - }), - ); - }); - - it('should allow empty mentioned_users', async () => { - client.post = (url, config) => { - expect(config.message.mentioned_users[0]).to.be.equal(undefined); - }; - - await client.updateMessage( - generateMsg({ - mentioned_users: [], - }), + expect(sinkSpy).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('Do not use connectUser server-side.'), ); - client.post = (url, config) => { - expect(config.message.mentioned_users).to.be.equal(undefined); - }; - - await client.updateMessage( - generateMsg({ - text: 'test message', - mentioned_users: undefined, - }), - ); - }); - - it('should remove reserved and volatile fields before running the update', async () => { - const postSpy = sinon.stub(client, 'post'); - const updatedMessage = generateMsg({ - text: 'test message', - pinned_at: new Date().toISOString(), - mentioned_users: undefined, - }); - - await client.updateMessage(updatedMessage); - - const messageInQuery = { - attachments: updatedMessage.attachments, - mentioned_users: updatedMessage.mentioned_users, - reaction_scores: updatedMessage.reaction_scores, - silent: updatedMessage.silent, - status: updatedMessage.status, - text: updatedMessage.text, - }; - - expect(postSpy.callCount).to.equal(1); - expect(postSpy.firstCall.args[1].message).to.toMatchObject(messageInQuery); + chatLoggerSystem.restoreDefaults(); }); }); @@ -606,12 +489,16 @@ describe('message update', () => { client.setOfflineDBApi(offlineDb); await client.offlineDb.init(client.userID); - loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); + loggerSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: loggerSpy, level: 'trace' }, + }); queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); _updateMessageSpy = vi.spyOn(client, '_updateMessage').mockResolvedValue({}); }); afterEach(() => { + chatLoggerSystem.restoreDefaults(); vi.resetAllMocks(); }); @@ -622,8 +509,9 @@ describe('message update', () => { cid: 'messaging:channel-123', text: 'edited', }); + const request = { id: message.id, message, skip_enrich_url: true }; - await client.updateMessage(message, { id: 'user-123' }, { skip_enrich_url: true }); + await client.updateMessage(request); expect(queueTaskSpy).toHaveBeenCalledTimes(1); expect(queueTaskSpy).toHaveBeenCalledWith({ @@ -631,7 +519,7 @@ describe('message update', () => { channelId: 'channel-123', channelType: 'messaging', messageId: 'msg-123', - payload: [message, { id: 'user-123' }, { skip_enrich_url: true }], + payload: [request], type: 'update-message', }, }); @@ -644,13 +532,14 @@ describe('message update', () => { cid: 'invalid-cid', text: 'edited', }); + const request = { id: message.id, message }; - await client.updateMessage(message); + await client.updateMessage(request); expect(queueTaskSpy).toHaveBeenCalledWith({ task: { messageId: 'msg-123', - payload: [message, undefined, undefined], + payload: [request], type: 'update-message', }, }); @@ -661,15 +550,14 @@ describe('message update', () => { id: 'msg-123', text: 'edited', }); + const request = { id: message.id, message, skip_enrich_url: true }; client.offlineDb = undefined; - await client.updateMessage(message, 'user-123', { skip_enrich_url: true }); + await client.updateMessage(request); expect(_updateMessageSpy).toHaveBeenCalledTimes(1); - expect(_updateMessageSpy).toHaveBeenCalledWith(message, 'user-123', { - skip_enrich_url: true, - }); + expect(_updateMessageSpy).toHaveBeenCalledWith(request); }); it('routes updates with local attachment metadata through offlineDb queue handling', async () => { @@ -687,14 +575,15 @@ describe('message update', () => { }, ], }); + const request = { id: message.id, message }; - await client.updateMessage(message); + await client.updateMessage(request); expect(queueTaskSpy).toHaveBeenCalledTimes(1); expect(queueTaskSpy).toHaveBeenCalledWith({ task: { messageId: 'msg-123', - payload: [message, undefined, undefined], + payload: [request], type: 'update-message', }, }); @@ -712,14 +601,15 @@ describe('message update', () => { }, ], }); + const request = { id: message.id, message }; - await client.updateMessage(message); + await client.updateMessage(request); expect(queueTaskSpy).toHaveBeenCalledTimes(1); expect(queueTaskSpy).toHaveBeenCalledWith({ task: { messageId: 'msg-123', - payload: [message, undefined, undefined], + payload: [request], type: 'update-message', }, }); @@ -731,13 +621,14 @@ describe('message update', () => { id: 'msg-123', text: 'edited', }); + const request = { id: message.id, message }; queueTaskSpy.mockRejectedValue(new Error('Offline failure')); - await client.updateMessage(message); + await client.updateMessage(request); expect(loggerSpy).toHaveBeenCalledTimes(1); expect(_updateMessageSpy).toHaveBeenCalledTimes(1); - expect(_updateMessageSpy).toHaveBeenCalledWith(message, undefined, undefined); + expect(_updateMessageSpy).toHaveBeenCalledWith(request); }); it('logs and falls back to _updateMessage when queueTask rethrows for failed offline edits', async () => { @@ -747,67 +638,27 @@ describe('message update', () => { text: 'edited', message_text_updated_at: '2026-04-01T20:48:43.886269Z', }); + const request = { id: failedEditedMessage.id, message: failedEditedMessage }; client.wsConnection = { isHealthy: false }; queueTaskSpy.mockRejectedValue(new Error('Offline failure')); _updateMessageSpy.mockResolvedValue({ message: failedEditedMessage }); - const response = await client.updateMessage(failedEditedMessage); + const response = await client.updateMessage(request); expect(queueTaskSpy).toHaveBeenCalledTimes(1); expect(loggerSpy).toHaveBeenCalledTimes(1); expect(_updateMessageSpy).toHaveBeenCalledTimes(1); - expect(_updateMessageSpy).toHaveBeenCalledWith( - failedEditedMessage, - undefined, - undefined, - ); + expect(_updateMessageSpy).toHaveBeenCalledWith(request); expect(response.message.text).toBe('edited'); expect(response.message.status).toBe('failed'); }); }); }); -describe('Client search', async () => { - const client = await getClientWithUser(); - - it('search with sorting by defined field', async () => { - client.get = (url, config) => { - expect(config.payload.sort).to.be.eql([{ field: 'updated_at', direction: -1 }]); - }; - await client.search({ cid: 'messaging:my-cid' }, 'query', { - sort: [{ updated_at: -1 }], - }); - }); - it('search with sorting by custom field', async () => { - client.get = (url, config) => { - expect(config.payload.sort).to.be.eql([{ field: 'custom_field', direction: -1 }]); - }; - await client.search({ cid: 'messaging:my-cid' }, 'query', { - sort: [{ custom_field: -1 }], - }); - }); - it('sorting and offset works', async () => { - await expect( - client.search({ cid: 'messaging:my-cid' }, 'query', { - offset: 1, - sort: [{ custom_field: -1 }], - }), - ).resolves.toEqual(); - }); - it('next and offset fails', async () => { - await expect( - client.search({ cid: 'messaging:my-cid' }, 'query', { - offset: 1, - next: 'next', - }), - ).rejects.toThrow(Error); - }); -}); - describe('Client setLocalDevice', async () => { const device = { id: 'id1', push_provider: 'apn' }; - const client = new StreamChat('', '', { device }); + const client = new StreamChat('', { device }); it('should update device info before ws open', async () => { expect(client.options.device).to.deep.equal(device); @@ -853,7 +704,7 @@ describe('Client WSFallback', () => { .onCall(0) .resolves({ event: { connection_id: 'new_id', received_at: eventDate } }); - client.doAxiosRequest = stub; + client.api.doAxiosRequest = stub; client.wsBaseURL = 'ws://getstream.io'; const health = await client.connectUser({ id: 'amin' }, userToken); expect(health).to.be.eql({ connection_id: 'new_id', received_at: eventDate }); @@ -873,7 +724,7 @@ describe('Client WSFallback', () => { it('should fire transport.changed and health.check event', async () => { const eventDate = new Date(Date.UTC(2009, 1, 3, 23, 3, 3)); sinon.spy(client, 'dispatchEvent'); - client.doAxiosRequest = () => ({ + client.api.doAxiosRequest = () => ({ event: { type: 'health.check', connection_id: 'new_id', received_at: eventDate }, }); client.wsBaseURL = 'ws://getstream.io'; @@ -951,12 +802,13 @@ describe('StreamChat.queryChannels', async () => { generateMsg, ), })); - const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockedChannelsQueryResponse)); - await client.queryChannels(); + sinon + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelsQueryResponse }); + await client.queryChannelsAndHydrate(); expect(Object.keys(client.activeChannels).length).to.be.equal(0); expect(Object.keys(client.configs).length).to.be.equal(0); - mock.restore(); + sinon.restore(); }); it('should return hydrated channels as Channel instances from queryChannels', async () => { @@ -968,15 +820,15 @@ describe('StreamChat.queryChannels', async () => { generateMsg, ), })); - const postStub = sinon - .stub(client, 'post') - .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); - const queryChannelsResponse = await client.queryChannels(); + const stub = sinon + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelsQueryResponse }); + const queryChannelsResponse = await client.queryChannelsAndHydrate(); expect(queryChannelsResponse.length).to.be.equal(mockedChannelsQueryResponse.length); queryChannelsResponse.forEach((item) => { expect(item).to.be.instanceOf(Channel); }); - postStub.restore(); + stub.restore(); }); it('should sync channel data-backed stores when hydrating channels from queryChannels', async () => { @@ -995,11 +847,11 @@ describe('StreamChat.queryChannels', async () => { ), }, ]; - const postStub = sinon - .stub(client, 'post') - .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); + const stub = sinon + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelsQueryResponse }); - const [channel] = await client.queryChannels(); + const [channel] = await client.queryChannelsAndHydrate(); expect(channel.state.member_count).to.equal(7); expect(channel.state.ownCapabilitiesStore.getLatestValue()).to.eql({ @@ -1014,7 +866,7 @@ describe('StreamChat.queryChannels', async () => { ownCapabilities: ['send-message'], }); - postStub.restore(); + stub.restore(); }); it('does not weld a jumped/older window into the newest page when re-hydrating a shared channel on re-query', async () => { @@ -1024,16 +876,14 @@ describe('StreamChat.queryChannels', async () => { generateMsg({ id: 'm6', created_at: '2023-11-14T12:00:06.000Z' }), generateMsg({ id: 'm7', created_at: '2023-11-14T12:00:07.000Z' }), ]; - const postStub = sinon.stub(client, 'post').returns( - Promise.resolve({ - channels: [{ ...mockChannelQueryResponse, messages: newest }], - }), - ); + const stub = sinon.stub(client, 'queryChannels').resolves({ + channels: [{ ...mockChannelQueryResponse, messages: newest }], + }); // Initial query seeds the (cold) paginator with the newest window. message_limit === page // length so the seed is NOT flagged as the complete set (hasMoreTail stays true: older exist, // so an older jumped window stays a separate interval instead of merging at the tail edge). - const [channel] = await client.queryChannels({}, {}, { message_limit: 3 }); + const [channel] = await client.queryChannelsAndHydrate({ message_limit: 3 }); // Simulate the user jumping to an OLDER window, disjoint from the newest, which becomes the // active (visible) interval while the newest window stays loaded as a separate interval. @@ -1059,32 +909,14 @@ describe('StreamChat.queryChannels', async () => { // A channel-list re-query on reconnect re-hydrates the SAME channel instance with the newest // window (disjoint from the jumped one). It must NOT weld them (which would drop m3/m4 in the // middle) nor yank the user off the jumped window. - await client.queryChannels({}, {}, { message_limit: 3 }); + await client.queryChannelsAndHydrate({ message_limit: 3 }); const activeAfter = channel.messagePaginator.state .getLatestValue() .items?.map((m) => m.id); expect(activeAfter).to.eql(['m1', 'm2']); - postStub.restore(); - }); - - it('should return the raw channels response from queryChannelsRequest', async () => { - const client = await getClientWithUser(); - const mockedChannelsQueryResponse = Array.from({ length: 10 }, () => ({ - ...mockChannelQueryResponse, - messages: Array.from( - { length: DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE }, - generateMsg, - ), - })); - const postStub = sinon - .stub(client, 'post') - .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); - const queryChannelsResponse = await client.queryChannelsRequest(); - expect(queryChannelsResponse.length).to.be.equal(mockedChannelsQueryResponse.length); - expect(queryChannelsResponse).to.deep.equal(mockedChannelsQueryResponse); - postStub.restore(); + stub.restore(); }); it('seeds each queried channel paginator with its full message page', async () => { @@ -1092,22 +924,21 @@ describe('StreamChat.queryChannels', async () => { const mockedChannelsQueryResponse = Array.from({ length: 10 }, () => ({ ...mockChannelQueryResponse, messages: Array.from( - { length: DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE }, + { length: DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE }, generateMsg, ), })); - const mock = sinon.mock(client); - mock - .expects('post') - .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); - await client.queryChannels(); + sinon + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelsQueryResponse }); + await client.queryChannelsAndHydrate(); expect(Object.keys(client.activeChannels).length).to.be.greaterThan(0); Object.values(client.activeChannels).forEach((channel) => { expect(channel.messagePaginator.items).to.have.length( - DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE, + DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE, ); }); - mock.restore(); + sinon.restore(); }); it('seeds each queried channel paginator with its partial message page', async () => { @@ -1115,22 +946,21 @@ describe('StreamChat.queryChannels', async () => { const mockedChannelQueryResponse = Array.from({ length: 10 }, () => ({ ...mockChannelQueryResponse, messages: Array.from( - { length: DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE - 1 }, + { length: DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE - 1 }, generateMsg, ), })); - const mock = sinon.mock(client); - mock - .expects('post') - .returns(Promise.resolve({ channels: mockedChannelQueryResponse })); - await client.queryChannels(); + sinon + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelQueryResponse }); + await client.queryChannelsAndHydrate(); expect(Object.keys(client.activeChannels).length).to.be.greaterThan(0); Object.values(client.activeChannels).forEach((channel) => { expect(channel.messagePaginator.items).to.have.length( - DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE - 1, + DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE - 1, ); }); - mock.restore(); + sinon.restore(); }); }); @@ -1144,11 +974,10 @@ describe('StreamChat.queryThreads', () => { ); const apiResponse = { threads: [rawThread], next: undefined }; - const postStub = sinon.stub(client, 'post'); - postStub.onFirstCall().resolves(apiResponse); + sinon.stub(client, 'queryThreads').resolves(apiResponse); const hydratePollCacheSpy = sinon.spy(client.polls, 'hydratePollCache'); - const result = await client.queryThreads(); + const result = await client.queryThreadsAndHydrate(); expect(result.threads).to.have.lengthOf(1); expect(result.threads[0].id).to.equal(parentMessage.id); @@ -1156,7 +985,7 @@ describe('StreamChat.queryThreads', () => { expect(hydratePollCacheSpy.calledOnce).to.be.true; expect(hydratePollCacheSpy.calledWith([parentMessage])).to.be.true; - postStub.restore(); + sinon.restore(); }); }); @@ -1166,7 +995,7 @@ describe('StreamChat.queryReactions', () => { let postStub; const messageId = 'msg-1'; const filter = { type: { $in: ['like', 'love'] } }; - const sort = [{ created_at: -1 }]; + const sort = [{ field: 'created_at', direction: -1 }]; const options = { limit: 50 }; const offlineReactions = [ @@ -1189,7 +1018,7 @@ describe('StreamChat.queryReactions', () => { await client.offlineDb.init(client.userID); dispatchSpy = vi.spyOn(client, 'dispatchEvent'); - postStub = vi.spyOn(client, 'post').mockResolvedValueOnce(postResponse); + postStub = vi.spyOn(client, 'queryReactions').mockResolvedValueOnce(postResponse); client.offlineDb.getReactions.mockResolvedValue(offlineReactions); }); @@ -1198,7 +1027,13 @@ describe('StreamChat.queryReactions', () => { }); it('should query reactions from offlineDb and dispatch offline_reactions.queried event', async () => { - const result = await client.queryReactions(messageId, filter, sort, options); + const request = { + id: messageId, + filter, + sort, + limit: options.limit, + }; + const result = await client.queryReactionsAndHydrate(request); expect(client.offlineDb.getReactions).toHaveBeenCalledWith({ messageId, @@ -1220,74 +1055,71 @@ describe('StreamChat.queryReactions', () => { ]); expect(postStub).toHaveBeenCalledTimes(1); - expect(postStub).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}/reactions`, - { - filter, - sort: normalizeQuerySort(sort), - limit: 50, - }, - ); + expect(postStub).toHaveBeenCalledWith(request); expect(result).to.eql(postResponse); }); it('should skip querying offlineDb if options.next is true', async () => { - await client.queryReactions(messageId, filter, sort, { next: true, limit: 20 }); + const request = { + id: messageId, + filter, + sort, + next: true, + limit: 20, + }; + await client.queryReactionsAndHydrate(request); expect(client.offlineDb.getReactions).not.toHaveBeenCalled(); - - expect(postStub).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}/reactions`, - { - filter, - sort: normalizeQuerySort(sort), - next: true, - limit: 20, - }, - ); + expect(postStub).toHaveBeenCalledWith(request); }); it('should not dispatch event if offlineDb returns null', async () => { client.offlineDb.getReactions.mockResolvedValue(null); - await client.queryReactions(messageId, filter, sort, options); + const request = { + id: messageId, + filter, + sort, + limit: 50, + }; + await client.queryReactionsAndHydrate(request); expect(client.offlineDb.getReactions).toHaveBeenCalledTimes(1); expect(dispatchSpy).not.toHaveBeenCalled(); - expect(postStub).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}/reactions`, - { - filter, - sort: normalizeQuerySort(sort), - limit: 50, - }, - ); + expect(postStub).toHaveBeenCalledWith(request); }); it('should log a warning if offlineDb.getReactions throws', async () => { client.offlineDb.getReactions.mockRejectedValue(new Error('DB error')); const loggerSpy = vi.fn(); - client.logger = loggerSpy; + chatLoggerSystem.configureLoggers({ + default: { sink: loggerSpy, level: 'trace' }, + }); - await client.queryReactions(messageId, filter, sort, options); + await client.queryReactionsAndHydrate({ + id: messageId, + filter, + sort, + limit: options.limit, + }); expect(loggerSpy).toHaveBeenCalledWith( 'warn', - 'An error has occurred while querying offline reactions', + expect.stringContaining('An error occurred while querying offline reactions'), expect.objectContaining({ error: expect.any(Error), }), ); expect(dispatchSpy).not.toHaveBeenCalled(); - expect(postStub).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}/reactions`, - { - filter, - sort: normalizeQuerySort(sort), - limit: 50, - }, - ); + expect(postStub).toHaveBeenCalledWith({ + id: messageId, + filter, + sort, + limit: 50, + }); + + chatLoggerSystem.restoreDefaults(); }); }); @@ -1297,7 +1129,6 @@ describe('message deletion', () => { let client; let loggerSpy; let queueTaskSpy; - let clientDeleteSpy; beforeEach(async () => { client = await getClientWithUser(); @@ -1306,12 +1137,15 @@ describe('message deletion', () => { client.setOfflineDBApi(offlineDb); await client.offlineDb.init(client.userID); - loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); - clientDeleteSpy = vi.spyOn(client, 'delete').mockResolvedValue({ message: {} }); + loggerSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: loggerSpy, level: 'trace' }, + }); queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); }); afterEach(() => { + chatLoggerSystem.restoreDefaults(); vi.resetAllMocks(); }); @@ -1326,209 +1160,126 @@ describe('message deletion', () => { vi.resetAllMocks(); }); - it.each([ - ['undefined', undefined, {}], - ['true', true, { hardDelete: true }], - ['false', false, {}], - ['{ hardDelete: false }', { hardDelete: false }, {}], - ['{ hardDelete: true }', { hardDelete: true }, { hardDelete: true }], - ['{ deleteForMe: true }', { deleteForMe: true }, { deleteForMe: true }], - ['{ deleteForMe: false }', { deleteForMe: false }, {}], - [ - '{ hardDelete: false, deleteForMe: true }', - { hardDelete: false, deleteForMe: true }, - { deleteForMe: true }, - ], - [ - '{ hardDelete: true, deleteForMe: true }', - { hardDelete: true, deleteForMe: true }, - { deleteForMe: true }, - ], - [ - '{ hardDelete: false, deleteForMe: false }', - { hardDelete: false, deleteForMe: false }, - {}, - ], - [ - '{ hardDelete: true, deleteForMe: false }', - { hardDelete: true, deleteForMe: false }, - { hardDelete: true }, - ], - ])('should parse delete message options %s', async (_, options, expectedOptions) => { - await client.deleteMessage(messageId, options); - if (expectedOptions.hardDelete) { - expect(client.offlineDb.hardDeleteMessage).toHaveBeenCalledTimes(1); - expect(client.offlineDb.hardDeleteMessage).toHaveBeenCalledWith({ - id: messageId, - }); - expect(client.offlineDb.softDeleteMessage).not.toHaveBeenCalled(); - } else { - expect(client.offlineDb.softDeleteMessage).toHaveBeenCalledTimes(1); - expect(client.offlineDb.softDeleteMessage).toHaveBeenCalledWith({ - id: messageId, - deleteForMe: expectedOptions.deleteForMe, - }); - expect(client.offlineDb.hardDeleteMessage).not.toHaveBeenCalled(); - } + it('routes soft delete through offlineDb.softDeleteMessage and queues the task', async () => { + const request = { id: messageId }; + + await client.deleteMessage(request); + + expect(client.offlineDb.softDeleteMessage).toHaveBeenCalledTimes(1); + expect(client.offlineDb.softDeleteMessage).toHaveBeenCalledWith({ + id: messageId, + }); + expect(client.offlineDb.hardDeleteMessage).not.toHaveBeenCalled(); expect(queueTaskSpy).toHaveBeenCalledTimes(1); + expect(queueTaskSpy).toHaveBeenCalledWith({ + task: { + messageId, + payload: [request], + type: 'delete-message', + }, + }); + expect(_deleteMessageSpy).not.toHaveBeenCalled(); + }); + + it('routes hard delete through offlineDb.hardDeleteMessage and queues the task', async () => { + const request = { id: messageId, hard: true }; - const taskArg = queueTaskSpy.mock.calls[0][0]; - expect(taskArg).to.deep.equal({ + await client.deleteMessage(request); + + expect(client.offlineDb.hardDeleteMessage).toHaveBeenCalledTimes(1); + expect(client.offlineDb.hardDeleteMessage).toHaveBeenCalledWith({ + id: messageId, + }); + expect(client.offlineDb.softDeleteMessage).not.toHaveBeenCalled(); + + expect(queueTaskSpy).toHaveBeenCalledTimes(1); + expect(queueTaskSpy).toHaveBeenCalledWith({ task: { messageId, - payload: [messageId, expectedOptions], + payload: [request], type: 'delete-message', }, }); expect(_deleteMessageSpy).not.toHaveBeenCalled(); }); - it.each([ - ['undefined', undefined, {}], - ['true', true, { hardDelete: true }], - ['false', false, {}], - ['{ hardDelete: false }', { hardDelete: false }, {}], - ['{ hardDelete: true }', { hardDelete: true }, { hardDelete: true }], - ['{ deleteForMe: true }', { deleteForMe: true }, { deleteForMe: true }], - ['{ deleteForMe: false }', { deleteForMe: false }, {}], - [ - '{ hardDelete: false, deleteForMe: true }', - { hardDelete: false, deleteForMe: true }, - { deleteForMe: true }, - ], - [ - '{ hardDelete: true, deleteForMe: true }', - { hardDelete: true, deleteForMe: true }, - { deleteForMe: true }, - ], - [ - '{ hardDelete: false, deleteForMe: false }', - { hardDelete: false, deleteForMe: false }, - {}, - ], - [ - '{ hardDelete: true, deleteForMe: false }', - { hardDelete: true, deleteForMe: false }, - { hardDelete: true }, - ], - ])( - 'should fall back to _deleteMessage if offlineDb is not set and delete options is %s', - async (_, options, expectedOptions) => { - client.offlineDb = undefined; - - await client.deleteMessage(messageId, options); - - expect(_deleteMessageSpy).toHaveBeenCalledTimes(1); - expect(_deleteMessageSpy).toHaveBeenCalledWith(messageId, expectedOptions); - }, - ); + it('forwards delete_for_me to offlineDb.softDeleteMessage', async () => { + const request = { id: messageId, delete_for_me: true }; + + await client.deleteMessage(request); + + expect(client.offlineDb.softDeleteMessage).toHaveBeenCalledTimes(1); + expect(client.offlineDb.softDeleteMessage).toHaveBeenCalledWith({ + id: messageId, + deleteForMe: true, + }); + expect(client.offlineDb.hardDeleteMessage).not.toHaveBeenCalled(); + }); + + it('falls back to _deleteMessage if offlineDb is not set', async () => { + client.offlineDb = undefined; + const request = { id: messageId }; + + await client.deleteMessage(request); + + expect(_deleteMessageSpy).toHaveBeenCalledTimes(1); + expect(_deleteMessageSpy).toHaveBeenCalledWith(request); + }); - it('should log and fall back to _deleteMessage if offline delete throws', async () => { + it('logs and falls back to _deleteMessage if offline delete throws', async () => { client.offlineDb.softDeleteMessage.mockRejectedValue(new Error('Offline failure')); + const request = { id: messageId }; - await client.deleteMessage(messageId, false); + await client.deleteMessage(request); expect(loggerSpy).toHaveBeenCalledTimes(1); expect(queueTaskSpy).not.toHaveBeenCalled(); expect(_deleteMessageSpy).toHaveBeenCalledTimes(1); - expect(_deleteMessageSpy).toHaveBeenCalledWith(messageId, {}); + expect(_deleteMessageSpy).toHaveBeenCalledWith(request); }); }); describe('_deleteMessage', () => { - it('should call delete with correct URL and no params when hardDelete is false/undefined', async () => { - await client._deleteMessage(messageId); + let sendRequestSpy; - expect(clientDeleteSpy).toHaveBeenCalledTimes(1); - expect(clientDeleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}`, - {}, - ); - }); - - it('should call delete with hard=true param when hardDelete is true', async () => { - await client._deleteMessage(messageId, true); - - expect(clientDeleteSpy).toHaveBeenCalledTimes(1); - expect(clientDeleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}`, - { hard: true }, - ); + beforeEach(() => { + sendRequestSpy = vi.spyOn(client.api, 'sendRequest').mockResolvedValue({ + body: { message: { id: messageId } }, + metadata: {}, + }); }); - it.each([ - ['{}', {}], - ['{ hardDelete: true }', { hardDelete: true }], - ['{ hardDelete: false }', { hardDelete: false }], - ['{ deleteForMe: true }', { deleteForMe: true }], - ['{ deleteForMe: false }', { deleteForMe: false }], - [ - '{ hardDelete: false, deleteForMe: true }', - { hardDelete: false, deleteForMe: true }, - ], - [ - '{ hardDelete: true, deleteForMe: true }', - { hardDelete: true, deleteForMe: true }, - ], - [ - '{ hardDelete: false, deleteForMe: false }', - { hardDelete: false, deleteForMe: false }, - ], - [ - '{ hardDelete: false, deleteForMe: false }', - { hardDelete: false, deleteForMe: false }, - ], - ])('should parse delete options %s accordingly', async (_, options) => { - await client._deleteMessage(messageId, options); - - const expectedParams = - Object.values(options).length === 2 && Object.values(options).every((val) => val) - ? { delete_for_me: true, hard: true } - : Object.keys(options).length === 0 - ? {} - : options.deleteForMe - ? { delete_for_me: true } - : options.hardDelete - ? { hard: true } - : {}; - expect(clientDeleteSpy).toHaveBeenCalledTimes(1); - expect(clientDeleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}`, - expectedParams, - ); + afterEach(() => { + vi.resetAllMocks(); }); - it('should call delete with both hard and delete_for_me params when both are true', async () => { - await client._deleteMessage(messageId, { deleteForMe: true, hardDelete: true }); + it('returns the response from the underlying deleteMessage call', async () => { + const result = await client._deleteMessage({ id: messageId }); - expect(clientDeleteSpy).toHaveBeenCalledTimes(1); - expect(clientDeleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}`, - { hard: true, delete_for_me: true }, - ); + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(result.message).toMatchObject({ id: messageId }); }); - it('should return the response from delete', async () => { - clientDeleteSpy.mockResolvedValue({ - message: { id: messageId }, + it('enriches the message with type="deleted" and deleted_for_me=true when delete_for_me is set', async () => { + const result = await client._deleteMessage({ + id: messageId, + delete_for_me: true, }); - const result = await client._deleteMessage(messageId); - expect(result).toStrictEqual({ - message: { id: messageId }, + expect(result.message).toMatchObject({ + id: messageId, + deleted_for_me: true, + type: 'deleted', }); }); - it('enriches the deleted-for-me message with type="deleted" and deleted_for_me=true', async () => { - clientDeleteSpy.mockResolvedValue({ - message: { id: messageId }, - }); - const result = await client._deleteMessage(messageId, { deleteForMe: true }); + it('does not enrich the message when delete_for_me is not set', async () => { + const result = await client._deleteMessage({ id: messageId, hard: true }); - expect(result).toStrictEqual({ - message: { deleted_for_me: true, id: messageId, type: 'deleted' }, - }); + expect(result.message).toMatchObject({ id: messageId }); + expect(result.message).not.toHaveProperty('deleted_for_me'); + expect(result.message).not.toHaveProperty('type'); }); }); }); @@ -1692,11 +1443,11 @@ describe('user.messages.deleted — quoted_message regression (#1736)', () => { const setupChannelWithSelfQuote = (type, id) => { const m1 = generateMsg({ - created_at: '2020-01-01T00:00:01.000Z', + created_at: new Date('2020-01-01T00:00:01.000Z'), user: bannedUser, }); const m2 = generateMsg({ - created_at: '2020-01-01T00:00:02.000Z', + created_at: new Date('2020-01-01T00:00:02.000Z'), user: bannedUser, quoted_message: m1, quoted_message_id: m1.id, @@ -1911,108 +1662,6 @@ describe('X-Stream-Client header', () => { expect(client.getUserAgent()).toBe(first); }); - - describe('getHookEvents', () => { - let clientGetSpy; - - beforeEach(() => { - clientGetSpy = vi.spyOn(client, 'get').mockResolvedValue({}); - }); - - it('should call get with correct URL and no params when no products specified', async () => { - await client.getHookEvents(); - - expect(clientGetSpy).toHaveBeenCalledTimes(1); - expect(clientGetSpy).toHaveBeenCalledWith(`${client.baseURL}/hook/events`, {}); - }); - - it('should call get with correct URL and empty params when empty products array specified', async () => { - await client.getHookEvents([]); - - expect(clientGetSpy).toHaveBeenCalledTimes(1); - expect(clientGetSpy).toHaveBeenCalledWith(`${client.baseURL}/hook/events`, {}); - }); - - it('should call get with product params when products specified', async () => { - await client.getHookEvents(['chat', 'video']); - - expect(clientGetSpy).toHaveBeenCalledTimes(1); - expect(clientGetSpy).toHaveBeenCalledWith(`${client.baseURL}/hook/events`, { - product: 'chat,video', - }); - }); - - it('should call get with single product param', async () => { - await client.getHookEvents(['chat']); - - expect(clientGetSpy).toHaveBeenCalledTimes(1); - expect(clientGetSpy).toHaveBeenCalledWith(`${client.baseURL}/hook/events`, { - product: 'chat', - }); - }); - - it('should return the response from get', async () => { - const mockResponse = { - events: [ - { - name: 'message.new', - description: 'When a new message is added', - products: ['chat'], - }, - { - name: 'call.created', - description: 'The call was created', - products: ['video'], - }, - ], - }; - clientGetSpy.mockResolvedValue(mockResponse); - - const result = await client.getHookEvents(['chat', 'video']); - - expect(result).toEqual(mockResponse); - }); - }); -}); - -describe('markChannelsDelivered', () => { - let client; - const user = { id: 'user' }; - - beforeEach(() => { - client = new StreamChat('', ''); - - vi.spyOn(client, 'post').mockResolvedValue({ - ok: true, - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('prevents triggering the request with empty payload', async () => { - await client.markChannelsDelivered(); - expect(client.post).not.toHaveBeenCalled(); - - await client.markChannelsDelivered({}); - expect(client.post).not.toHaveBeenCalled(); - - await client.markChannelsDelivered({ latest_delivered_messages: [] }); - expect(client.post).not.toHaveBeenCalled(); - - await client.markChannelsDelivered({ user, user_id: user.id }); - expect(client.post).not.toHaveBeenCalled(); - }); - - it('triggers the request with at least on channel to report', async () => { - const delivered = [{ cid: 'cid', id: 'message-id' }]; - await client.markChannelsDelivered({ latest_delivered_messages: delivered }); - expect(client.post).toHaveBeenCalledWith( - 'https://chat.stream-io-api.com/channels/delivered', - { latest_delivered_messages: delivered }, - ); - }); }); // Regression coverage for GetStream/stream-chat-react#2599. @@ -2096,16 +1745,18 @@ describe('activeChannels eviction when the current user is removed (#2599)', () it('does not re-watch the evicted channel on recoverState', async () => { const removed = client.channel('messaging', 'removed'); const kept = client.channel('messaging', 'kept'); - const queryChannelsStub = vi.spyOn(client, 'queryChannels').mockResolvedValue([]); + const queryChannelsStub = vi + .spyOn(client, 'queryChannels') + .mockResolvedValue({ channels: [] }); client.dispatchEvent(removedFromChannelEvent(removed)); await client.recoverState(); expect(queryChannelsStub).toHaveBeenCalledTimes(1); - const [filters] = queryChannelsStub.mock.calls[0]; - expect(filters.cid.$in).to.contain(kept.cid); - expect(filters.cid.$in).not.to.contain(removed.cid); + const [options] = queryChannelsStub.mock.calls[0]; + expect(options.filter_conditions.cid.$in).to.contain(kept.cid); + expect(options.filter_conditions.cid.$in).not.to.contain(removed.cid); }); it('does not evict when another user is removed (member.removed for a different user)', () => { diff --git a/test/unit/connection.test.js b/test/unit/connection.test.js index b5ad9739dd..6c5923ed2d 100644 --- a/test/unit/connection.test.js +++ b/test/unit/connection.test.js @@ -19,8 +19,6 @@ describe('connection', function () { client.wsBaseURL = wsBaseURL; client.tokenManager = tokenManager; client._user = user; - client.userID = user.id; - client.logger = () => null; client.options.enableInsights = true; client.userAgent = 'agent'; client.clientID = 'clientID'; diff --git a/test/unit/connection_fallback.test.js b/test/unit/connection_fallback.test.js index ac3862e2ef..3f4c154680 100644 --- a/test/unit/connection_fallback.test.js +++ b/test/unit/connection_fallback.test.js @@ -7,16 +7,18 @@ import { ConnectionState, WSConnectionFallback } from '../../src/connection_fall import { describe, it, expect, afterEach, vi, beforeAll, beforeEach } from 'vitest'; describe('connection_fallback', () => { - const newClient = (overrides) => ({ - baseURL: '', - logger: () => null, - doAxiosRequest: sinon.spy(), - _buildWSPayload: sinon.stub().returns('payload'), - dispatchEvent: sinon.spy(), - handleEvent: sinon.spy(), - recoverState: sinon.spy(), - ...overrides, - }); + const newClient = (overrides) => { + const doAxiosRequest = overrides?.doAxiosRequest ?? sinon.spy(); + return { + baseURL: '', + api: { doAxiosRequest }, + _buildWSPayload: sinon.stub().returns('payload'), + dispatchEvent: sinon.spy(), + handleEvent: sinon.spy(), + recoverState: sinon.spy(), + ...overrides, + }; + }; afterEach(() => { vi.restoreAllMocks(); @@ -221,9 +223,10 @@ describe('connection_fallback', () => { const config = { timeout: 100 }; await c._req(params, config); expect( - c.client.doAxiosRequest.calledOnceWithExactly('get', '/longpoll', undefined, { + c.client.api.doAxiosRequest.calledOnceWithExactly('get', '/longpoll', undefined, { + ...config, + cancelToken: c.cancelToken.token, params, - config: { ...config, cancelToken: c.cancelToken.token }, }), ).to.be.true; }); diff --git a/test/unit/draft.test.js b/test/unit/draft.test.js index 2424864112..977127e18d 100644 --- a/test/unit/draft.test.js +++ b/test/unit/draft.test.js @@ -1,144 +1,8 @@ -import sinon from 'sinon'; -import { StreamChat } from '../../src'; -import { generateChannel } from './test-utils/generateChannel'; import { getClientWithUser } from './test-utils/getClient'; import { MockOfflineDB } from './offline-support/MockOfflineDB'; +import { chatLoggerSystem } from '../../src/logger'; import { describe, afterEach, beforeEach, it, expect, vi } from 'vitest'; -describe('Draft Messages', () => { - let client; - let channel; - const apiKey = 'test-api-key'; - const channelType = 'messaging'; - const channelID = 'test-channel'; - const userID = 'test-user'; - const parentID = 'parent-message-id'; - - const draftMessage = { - text: 'Draft message text', - attachments: [{ type: 'image', url: 'https://example.com/image.jpg' }], - mentioned_users: ['user1', 'user2'], - }; - - const draftWithParent = { - text: 'Draft message text', - attachments: [{ type: 'image', url: 'https://example.com/image.jpg' }], - mentioned_users: ['user1', 'user2'], - parent_id: parentID, - }; - - const draftResponse = { - draft: { - channel_cid: `${channelType}:${channelID}`, - created_at: '2023-01-01T00:00:00Z', - message: { - id: 'draft-id', - ...draftMessage, - }, - parent_id: parentID, - }, - }; - - beforeEach(() => { - client = new StreamChat(apiKey); - client.userID = userID; - let channelResponse = generateChannel({ - channel: { id: channelID, name: 'Test channel', members: [] }, - }).channel; - channel = client.channel(channelResponse.type, channelResponse.id); - - // Mock the methods - sinon.stub(client, 'queryDrafts').resolves(draftResponse); - sinon.stub(channel, 'createDraft').resolves(draftResponse); - sinon.stub(channel, 'getDraft').resolves(draftResponse); - sinon.stub(channel, 'deleteDraft').resolves({ duration: '0.01ms' }); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should create a draft message', async () => { - const response = await channel.createDraft(draftMessage); - - expect(channel.createDraft.calledOnce).to.be.true; - expect(channel.createDraft.firstCall.args[0]).to.deep.equal(draftMessage); - expect(response).to.deep.equal(draftResponse); - }); - - it('should create a draft message with parent ID', async () => { - const response = await channel.createDraft(draftWithParent); - - expect(channel.createDraft.calledOnce).to.be.true; - expect(channel.createDraft.firstCall.args[0]).to.deep.equal(draftWithParent); - expect(response).to.deep.equal(draftResponse); - }); - - it('should get a draft message', async () => { - const response = await channel.getDraft(parentID); - - expect(channel.getDraft.calledOnce).to.be.true; - expect(channel.getDraft.firstCall.args[0]).to.deep.equal(parentID); - expect(response).to.deep.equal(draftResponse); - }); - - it('should get a draft message with parent ID', async () => { - const response = await channel.getDraft(parentID); - - expect(channel.getDraft.calledOnce).to.be.true; - expect(channel.getDraft.firstCall.args[0]).to.deep.equal(parentID); - expect(response).to.deep.equal(draftResponse); - }); - - it('should delete a draft message', async () => { - await channel.deleteDraft(); - - expect(channel.deleteDraft.calledOnce).to.be.true; - expect(channel.deleteDraft.firstCall.args[0]).to.be.undefined; - }); - - it('should delete a draft message with parent ID', async () => { - await channel.deleteDraft(parentID); - - expect(channel.deleteDraft.calledOnce).to.be.true; - expect(channel.deleteDraft.firstCall.args[0]).to.deep.equal(parentID); - }); - - it('should query drafts', async () => { - const queryOptions = { - filter: { created_at: { $gt: '2023-01-01T00:00:00Z' } }, - limit: 10, - }; - - const queryResponse = { - drafts: [ - draftResponse.draft, - { ...draftResponse.draft, channel_cid: 'messaging:other-channel' }, - ], - next: 'next-page-token', - }; - client.queryDrafts.resolves(queryResponse); - - const response = await client.queryDrafts(queryOptions); - - expect(client.queryDrafts.calledOnce).to.be.true; - expect(client.queryDrafts.firstCall.args[0]).to.deep.equal(queryOptions); - expect(response).to.deep.equal(queryResponse); - }); - - it('should query drafts with default options', async () => { - const queryResponse = { - drafts: [draftResponse.draft], - }; - client.queryDrafts.resolves(queryResponse); - - const response = await client.queryDrafts(); - expect(client.queryDrafts.calledOnce).to.be.true; - expect(client.queryDrafts.firstCall.args[0]).to.be.undefined; - expect(response).to.deep.equal(queryResponse); - }); -}); - describe('create draft flow', () => { const draftMessage = { id: 'msg-123', @@ -150,7 +14,6 @@ describe('create draft flow', () => { let channel; let loggerSpy; let queueTaskSpy; - let postSpy; beforeEach(async () => { client = await getClientWithUser(); @@ -161,14 +24,17 @@ describe('create draft flow', () => { channel = client.channel('messaging', 'test'); - loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); + loggerSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: loggerSpy, level: 'trace' }, + }); queueTaskSpy = vi .spyOn(client.offlineDb, 'queueTask') .mockResolvedValue({ draft: draftMessage }); - postSpy = vi.spyOn(client, 'post').mockResolvedValue({ draft: draftMessage }); }); afterEach(() => { + chatLoggerSystem.restoreDefaults(); vi.resetAllMocks(); }); @@ -178,7 +44,7 @@ describe('create draft flow', () => { }); it('queues task if offlineDb exists', async () => { - await channel.createDraft(draftMessage); + await channel.createDraft({ message: draftMessage }); expect(queueTaskSpy).toHaveBeenCalledTimes(1); @@ -188,7 +54,7 @@ describe('create draft flow', () => { channelId: 'test', channelType: 'messaging', threadId: draftMessage.parent_id, - payload: [draftMessage], + payload: [{ message: draftMessage }], type: 'create-draft', }, }); @@ -199,40 +65,20 @@ describe('create draft flow', () => { it('falls back to _createDraft if offlineDb throws', async () => { client.offlineDb.queueTask.mockRejectedValue(new Error('Offline failure')); - await channel.createDraft(draftMessage); + await channel.createDraft({ message: draftMessage }); expect(loggerSpy).toHaveBeenCalledTimes(1); expect(channel._createDraft).toHaveBeenCalledTimes(1); - expect(channel._createDraft).toHaveBeenCalledWith(draftMessage); + expect(channel._createDraft).toHaveBeenCalledWith({ message: draftMessage }); }); it('falls back to _createDraft if offlineDb is undefined', async () => { client.offlineDb = undefined; - await channel.createDraft(draftMessage); + await channel.createDraft({ message: draftMessage }); expect(channel._createDraft).toHaveBeenCalledTimes(1); - expect(channel._createDraft).toHaveBeenCalledWith(draftMessage); - }); - }); - - describe('_createDraft', () => { - it('calls post with correct URL and message payload', async () => { - await channel._createDraft(draftMessage); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels/messaging/test/draft`, - { message: draftMessage }, - ); - }); - - it('returns the response from post', async () => { - postSpy.mockResolvedValue({ draft: draftMessage }); - - const result = await channel._createDraft(draftMessage); - - expect(result).toEqual({ draft: draftMessage }); + expect(channel._createDraft).toHaveBeenCalledWith({ message: draftMessage }); }); }); }); @@ -244,7 +90,6 @@ describe('delete draft flow', () => { let channel; let loggerSpy; let queueTaskSpy; - let deleteSpy; beforeEach(async () => { client = await getClientWithUser(); @@ -255,12 +100,15 @@ describe('delete draft flow', () => { channel = client.channel('messaging', 'test'); - loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); + loggerSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: loggerSpy, level: 'trace' }, + }); queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); - deleteSpy = vi.spyOn(client, 'delete').mockResolvedValue({}); }); afterEach(() => { + chatLoggerSystem.restoreDefaults(); vi.resetAllMocks(); }); @@ -307,33 +155,4 @@ describe('delete draft flow', () => { expect(channel._deleteDraft).toHaveBeenCalledWith({ parent_id }); }); }); - - describe('_deleteDraft', () => { - it('calls delete with correct URL and params', async () => { - await channel._deleteDraft({ parent_id }); - - expect(deleteSpy).toHaveBeenCalledTimes(1); - expect(deleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels/messaging/test/draft`, - { parent_id }, - ); - }); - - it('calls delete with undefined parent_id if none provided', async () => { - await channel._deleteDraft(); - - expect(deleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels/messaging/test/draft`, - { parent_id: undefined }, - ); - }); - - it('returns the response from delete', async () => { - deleteSpy.mockResolvedValue({ success: true }); - - const result = await channel._deleteDraft({ parent_id }); - - expect(result).toEqual({ success: true }); - }); - }); }); diff --git a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts index 3228388aa9..781d976aa0 100644 --- a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts +++ b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts @@ -1,12 +1,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { getClientWithUser } from '../test-utils/getClient'; +import { generateChannel } from '../test-utils/generateChannel'; +import { generateThreadResponse } from '../test-utils/generateThreadResponse'; import { - type APIErrorResponse, + type APIError, Channel, - ErrorFromResponse, Event, - EventAPIResponse, + MarkDeliveredResponse, + StreamAPIError, StreamChat, + StreamResponse, + Thread, } from '../../../src'; import type { AxiosResponse } from 'axios'; @@ -66,8 +70,8 @@ describe('MessageDeliveryReporter', () => { }); it('announces delivery after the buffer window', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({ ok: true } as any); // last_read < last message @@ -75,13 +79,13 @@ describe('MessageDeliveryReporter', () => { (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; client.syncDeliveredCandidates([channel]); - expect(markChannelsDeliveredSpy).not.toHaveBeenCalled(); + expect(markDeliveredSpy).not.toHaveBeenCalled(); // throttle window (MessageDeliveryReporter uses 1000ms) vi.advanceTimersByTime(1000); // trailing request is not triggered as there are no delivery candidates to report - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); - expect(markChannelsDeliveredSpy).toHaveBeenCalledWith({ + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledWith({ latest_delivered_messages: [ { cid: channel.cid, @@ -92,8 +96,8 @@ describe('MessageDeliveryReporter', () => { }); it('announces at max 100 candidates per request', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({ ok: true } as any); // last_read < last message @@ -117,10 +121,8 @@ describe('MessageDeliveryReporter', () => { client.syncDeliveredCandidates(channels); vi.advanceTimersByTime(1000); // trailing request is not triggered as there are no delivery candidates to report - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); - expect( - markChannelsDeliveredSpy.mock.calls[0][0].latest_delivered_messages.length, - ).toBe(100); + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy.mock.calls[0][0].latest_delivered_messages.length).toBe(100); // @ts-expect-error accessing protected property deliveryReportCandidates expect(client.messageDeliveryReporter.deliveryReportCandidates.size).toBe(10); expect( @@ -130,18 +132,16 @@ describe('MessageDeliveryReporter', () => { await Promise.resolve(); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(2); - expect( - markChannelsDeliveredSpy.mock.calls[1][0].latest_delivered_messages.length, - ).toBe(10); + expect(markDeliveredSpy).toHaveBeenCalledTimes(2); + expect(markDeliveredSpy.mock.calls[1][0].latest_delivered_messages.length).toBe(10); // @ts-expect-error accessing protected property deliveryReportCandidates expect(client.messageDeliveryReporter.deliveryReportCandidates.size).toBe(0); }); it('does nothing when delivery receipts are disabled', async () => { (client as any).user.privacy_settings.delivery_receipts.enabled = false; - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({ ok: true } as any); setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); @@ -150,7 +150,7 @@ describe('MessageDeliveryReporter', () => { client.syncDeliveredCandidates([channel]); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).not.toHaveBeenCalled(); + expect(markDeliveredSpy).not.toHaveBeenCalled(); }); it('does nothing when delievry events are disabled in channel config', async () => { @@ -161,8 +161,8 @@ describe('MessageDeliveryReporter', () => { reminders: false, updated_at: '', }; - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({ ok: true } as any); setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); @@ -171,12 +171,12 @@ describe('MessageDeliveryReporter', () => { client.syncDeliveredCandidates([channel]); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).not.toHaveBeenCalled(); + expect(markDeliveredSpy).not.toHaveBeenCalled(); }); it('does not report if latest message is older than last_delivered_at in read state', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({ ok: true } as any); setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); @@ -188,12 +188,53 @@ describe('MessageDeliveryReporter', () => { client.syncDeliveredCandidates([channel]); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).not.toHaveBeenCalled(); + expect(markDeliveredSpy).not.toHaveBeenCalled(); + }); + + it('does not report delivery for threads (unsupported; branch early-returns)', () => { + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') + .mockResolvedValue({ ok: true } as any); + + const parent = mkMsg('parent', '2025-01-01T10:00:00Z'); + const channelResponse = generateChannel({ + channel: { id: 'thread-channel', members: [] }, + }).channel; + const thread = new Thread({ + client, + threadData: generateThreadResponse(channelResponse, parent), + }); + thread.channel.initialized = true; + // Grant delivery permission so we exercise the thread branch of + // `getNextDeliveryReportCandidate`, not the earlier permission gate. + client.configs[thread.channel.cid] = { + created_at: '', + delivery_events: true, + read_events: false, + reminders: false, + updated_at: '', + }; + // Seed the thread's head window with a newest reply that — on a channel — would be reported as a + // delivery candidate (see the channel tests above). + thread.messagePaginator.ingestPage({ + page: [mkMsg('t1', '2025-01-01T11:00:00Z')], + isHead: true, + isTail: true, + setActive: true, + }); + + client.messageDeliveryReporter.syncDeliveredCandidates([thread]); + vi.advanceTimersByTime(1000); + + // Thread delivery reporting is not yet supported: the thread branch returns before producing a + // candidate, so nothing is announced. (When enabled, it reads `messagePaginator.headItems` — the + // newest-loaded window — mirroring the channel branch.) + expect(markDeliveredSpy).not.toHaveBeenCalled(); }); it('coalesces multiple announceDeliveryBuffered calls into a single request', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); setLatest(channel, [mkMsg('m1', 1000)]); @@ -206,12 +247,12 @@ describe('MessageDeliveryReporter', () => { client.messageDeliveryReporter.announceDeliveryBuffered(); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); }); it('updates the candidate to the newest message before the throttle fires', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; @@ -228,7 +269,7 @@ describe('MessageDeliveryReporter', () => { vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledWith({ + expect(markDeliveredSpy).toHaveBeenCalledWith({ latest_delivered_messages: [ { cid: channel.cid, @@ -241,10 +282,13 @@ describe('MessageDeliveryReporter', () => { it('does not start a second request while one is in-flight; queues new candidate for after', async () => { // first call stays in-flight until we resolve it let resolveFirstMarkDelivered!: ( - value: EventAPIResponse | PromiseLike | undefined, + value: + | StreamResponse + | PromiseLike | undefined> + | undefined, ) => void; - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockImplementationOnce(() => new Promise((r) => (resolveFirstMarkDelivered = r))) .mockResolvedValueOnce({ ok: true } as any); // second request @@ -274,8 +318,8 @@ describe('MessageDeliveryReporter', () => { client.syncDeliveredCandidates([ch1]); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); - expect(markChannelsDeliveredSpy).toHaveBeenCalledWith({ + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledWith({ latest_delivered_messages: [ { cid: 'messaging:ch1', @@ -291,7 +335,7 @@ describe('MessageDeliveryReporter', () => { // Trying to announce during in-flight should be a no-op for sending vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); // Settle the first request resolveFirstMarkDelivered({ ok: true } as any); @@ -301,8 +345,8 @@ describe('MessageDeliveryReporter', () => { client.messageDeliveryReporter.announceDeliveryBuffered(); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(2); - expect(markChannelsDeliveredSpy).toHaveBeenCalledWith({ + expect(markDeliveredSpy).toHaveBeenCalledTimes(2); + expect(markDeliveredSpy).toHaveBeenCalledWith({ latest_delivered_messages: [ { cid: 'messaging:ch2', @@ -315,30 +359,30 @@ describe('MessageDeliveryReporter', () => { it('does not send a read when the user disabled read receipts', async () => { (client as any).user.privacy_settings = { read_receipts: { enabled: false } }; const markAsReadRequestSpy = vi - .spyOn(channel, 'markAsReadRequest') + .spyOn(channel, 'markRead') .mockResolvedValue({} as any); - const result = await channel.markRead(); + const result = await channel.markReadViaReporter(); expect(markAsReadRequestSpy).not.toHaveBeenCalled(); expect(result).toBeNull(); }); - it('removes the pending delivery candidate upon channel.markRead', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + it('removes the pending delivery candidate upon channel.markReadViaReporter', async () => { + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); - vi.spyOn(channel, 'markAsReadRequest').mockResolvedValue({} as any); + vi.spyOn(channel, 'markRead').mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; setLatest(channel, [mkMsg('m1', 1000)]); client.syncDeliveredCandidates([channel]); - await channel.markRead(); + await channel.markReadViaReporter(); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).not.toHaveBeenCalled(); + expect(markDeliveredSpy).not.toHaveBeenCalled(); }); const receiveMessages = (count: number, startId = 0) => { @@ -363,22 +407,22 @@ describe('MessageDeliveryReporter', () => { return channels; }; - const retryableError = new ErrorFromResponse('X', { + const retryableError = new StreamAPIError('X', { code: -1, response: {} as AxiosResponse, status: 400, }); - const notRetryableError = new ErrorFromResponse('X', { + const notRetryableError = new StreamAPIError('X', { code: 2, response: {} as AxiosResponse, status: 400, }); it('re-queues failed markChannelsDelivered request payloads', async () => { - const markChannelsDeliveredSpy = vi.spyOn(client, 'markChannelsDelivered'); + const markDeliveredSpy = vi.spyOn(client, 'markDelivered'); - markChannelsDeliveredSpy.mockRejectedValue(retryableError); + markDeliveredSpy.mockRejectedValue(retryableError); const channels1 = receiveMessages(110); // @ts-expect-error accessing protected property deliveryReportCandidates expect(client.messageDeliveryReporter.deliveryReportCandidates.size).toBe(110); @@ -389,7 +433,7 @@ describe('MessageDeliveryReporter', () => { // trigger mark delivered request that will fail vi.advanceTimersByTime(1000); await Promise.resolve(); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); // all the candidates have been returned back to deliveryReportCandidates // @ts-expect-error accessing protected property deliveryReportCandidates expect(client.messageDeliveryReporter.deliveryReportCandidates.size).toBe(110); @@ -420,7 +464,7 @@ describe('MessageDeliveryReporter', () => { // finish mark delivered request await Promise.resolve(); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(2); + expect(markDeliveredSpy).toHaveBeenCalledTimes(2); // all the candidates together now // @ts-expect-error accessing protected property deliveryReportCandidates expect(client.messageDeliveryReporter.deliveryReportCandidates.size).toBe(220); @@ -473,21 +517,21 @@ describe('MessageDeliveryReporter', () => { vi.advanceTimersByTime(8000); // finish mark delivered request await Promise.resolve(); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(4); + expect(markDeliveredSpy).toHaveBeenCalledTimes(4); // success resets the interval - markChannelsDeliveredSpy.mockResolvedValueOnce({ ok: true } as any); + markDeliveredSpy.mockResolvedValueOnce({ ok: true } as any); // the timeout does not increase anymore from the fourth failed retry vi.advanceTimersByTime(8000); // finish mark delivered request await Promise.resolve(); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(5); + expect(markDeliveredSpy).toHaveBeenCalledTimes(5); // after the previous success we are back to the base timeout vi.advanceTimersByTime(1000); // finish mark delivered request await Promise.resolve(); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(6); + expect(markDeliveredSpy).toHaveBeenCalledTimes(6); // @ts-expect-error accessing protected property deliveryReportCandidates expect(client.messageDeliveryReporter.deliveryReportCandidates.size).toBe(120); @@ -504,9 +548,9 @@ describe('MessageDeliveryReporter', () => { }); it('non retryable error does not schedule retry', async () => { - const markChannelsDeliveredSpy = vi.spyOn(client, 'markChannelsDelivered'); + const markDeliveredSpy = vi.spyOn(client, 'markDelivered'); - markChannelsDeliveredSpy.mockRejectedValue(notRetryableError); + markDeliveredSpy.mockRejectedValue(notRetryableError); const channels1 = receiveMessages(110); // @ts-expect-error accessing protected property deliveryReportCandidates expect(client.messageDeliveryReporter.deliveryReportCandidates.size).toBe(110); @@ -517,17 +561,17 @@ describe('MessageDeliveryReporter', () => { // trigger mark delivered request that will fail vi.advanceTimersByTime(1000); await Promise.resolve(); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); // will not retry vi.advanceTimersByTime(2000); await Promise.resolve(); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); }); it('does not remove the pending delivery candidate after failed markRead request', async () => { - const markChannelsDeliveredSpy = vi.spyOn(client, 'markChannelsDelivered'); - vi.spyOn(channel, 'markAsReadRequest').mockRejectedValue({} as any); + const markDeliveredSpy = vi.spyOn(client, 'markDelivered'); + vi.spyOn(channel, 'markRead').mockRejectedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; setLatest(channel, [mkMsg('m1', 1000)]); @@ -539,7 +583,7 @@ describe('MessageDeliveryReporter', () => { } catch (error) {} vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledWith({ + expect(markDeliveredSpy).toHaveBeenCalledWith({ latest_delivered_messages: [ { cid: channel.cid, @@ -549,9 +593,25 @@ describe('MessageDeliveryReporter', () => { }); }); + it('swallows rejections from the throttled (auto) markRead so they do not leak as unhandled rejections', async () => { + // Reproduces the fire-and-forget path: an active thread/channel auto-marks-read via + // `throttledMarkRead`, but `channel.markRead` rejects (e.g. read events disabled). The throttled + // wrapper must absorb it — otherwise it surfaces as an unhandled rejection and fails the run. + const markReadSpy = vi + .spyOn(channel, 'markRead') + .mockRejectedValue(new Error('Read events are disabled for this application')); + + expect(() => client.messageDeliveryReporter.throttledMarkRead(channel)).not.toThrow(); + + // Let the rejected markRead settle; the `.catch` in the throttled wrapper absorbs it. + // (1000ms === the reporter's MARK_AS_READ_THROTTLE_TIMEOUT.) + await vi.advanceTimersByTimeAsync(1000); + expect(markReadSpy).toHaveBeenCalledTimes(1); + }); + it('handles message.new via channel event: schedules and sends delivered for newest', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; @@ -560,7 +620,7 @@ describe('MessageDeliveryReporter', () => { // simulate incoming message.new event const ev: Event = { type: 'message.new', - created_at: new Date('2025-01-01T10:00:00Z').toISOString(), + created_at: new Date('2025-01-01T10:00:00Z'), user: otherUser, // cid must match the paginator filter so message.new ingests into an interval message: { ...mkMsg('m1', '2025-01-01T10:00:00Z'), cid: channel.cid } as any, @@ -570,8 +630,8 @@ describe('MessageDeliveryReporter', () => { vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); - expect(markChannelsDeliveredSpy).toHaveBeenCalledWith({ + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledWith({ latest_delivered_messages: [ { cid: channel.cid, @@ -582,8 +642,8 @@ describe('MessageDeliveryReporter', () => { }); it('prevents tracking own new messages', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; @@ -592,7 +652,7 @@ describe('MessageDeliveryReporter', () => { // simulate incoming message.new event const ev: Event = { type: 'message.new', - created_at: new Date('2025-01-01T10:00:00Z').toISOString(), + created_at: new Date('2025-01-01T10:00:00Z'), user: ownUser, message: mkMsg('m1', '2025-01-01T10:00:00Z') as any, }; @@ -601,12 +661,12 @@ describe('MessageDeliveryReporter', () => { vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).not.toHaveBeenCalled(); + expect(markDeliveredSpy).not.toHaveBeenCalled(); }); it('syncs delivery candidates upon own message.read event and prevents reporting delivery', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; @@ -616,7 +676,7 @@ describe('MessageDeliveryReporter', () => { const ev: Event = { type: 'message.read', - created_at: new Date('2025-01-01T10:00:00Z').toISOString(), + created_at: new Date('2025-01-01T10:00:00Z'), last_read_message_id: 'm1', message: mkMsg('m1', '2025-01-01T10:00:00Z') as any, user: ownUser, @@ -626,12 +686,12 @@ describe('MessageDeliveryReporter', () => { vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).not.toHaveBeenCalled(); + expect(markDeliveredSpy).not.toHaveBeenCalled(); }); it('does not sync delivery candidates upon other user message.read event and reports delivery', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; @@ -641,7 +701,7 @@ describe('MessageDeliveryReporter', () => { const ev: Event = { type: 'message.read', - created_at: new Date('2025-01-01T10:00:00Z').toISOString(), + created_at: new Date('2025-01-01T10:00:00Z'), last_read_message_id: 'm1', message: mkMsg('m1', '2025-01-01T10:00:00Z') as any, user: otherUser, @@ -651,8 +711,8 @@ describe('MessageDeliveryReporter', () => { vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); - expect(markChannelsDeliveredSpy).toHaveBeenCalledWith({ + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledWith({ latest_delivered_messages: [ { cid: channel.cid, @@ -663,7 +723,7 @@ describe('MessageDeliveryReporter', () => { }); it('throttles markRead (leading + trailing: fires immediately, then once more on the trailing edge)', async () => { - const spy = vi.spyOn(channel, 'markAsReadRequest').mockResolvedValue({} as any); + const spy = vi.spyOn(channel, 'markRead').mockResolvedValue({} as any); // burst client.messageDeliveryReporter.throttledMarkRead(channel); @@ -676,7 +736,7 @@ describe('MessageDeliveryReporter', () => { }); it('marks read immediately on a single throttledMarkRead call (leading edge)', async () => { - const spy = vi.spyOn(channel, 'markAsReadRequest').mockResolvedValue({} as any); + const spy = vi.spyOn(channel, 'markRead').mockResolvedValue({} as any); // A single call is the common case (e.g. scrolling to the bottom once). With `leading: true` it // fires immediately on the leading edge — no delay — and a lone call schedules no extra trailing diff --git a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts index 95a66cfad4..a43c055391 100644 --- a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts +++ b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { MessageReceiptsTracker, type MsgRef, - ReadResponse, + ReadStateResponse, UserResponse, } from '../../../src'; import { StateStore } from '../../../src/store'; @@ -10,6 +10,9 @@ import type { Channel } from '../../../src/channel'; const ownUserId = 'author'; const U = (id: string): UserResponse => ({ id, name: id }); // matches UserResponse shape for the service +// Read/delivery timestamps are `Date` in the OpenAPI-aligned tracker API; this helper builds one +// from a millisecond value (the tracker no longer accepts ISO strings). +const iso = (ms: number): Date => new Date(ms); // Timeline: 4 messages with ascending timestamps const msgs = [ @@ -52,9 +55,6 @@ const createChannelMock = ({ }; }; -// ISO builders (service parses Date strings) -const iso = (ts: number) => new Date(ts).toISOString(); - // Extract ids from user arrays for easier assertions const ids = (users: any[]) => users.map((u) => u.id); @@ -99,16 +99,16 @@ describe('MessageDeliveryReadTracker', () => { // Alice read m2, delivered m1 -> delivered must be bumped to m2 // Bob delivered m3, haven't read any message -> read stays MIN, delivered m3 - const snapshot: ReadResponse[] = [ + const snapshot: ReadStateResponse[] = [ { user: alice, - last_read: iso(2000), - last_delivered_at: iso(1000), + last_read: new Date(2000), + last_delivered_at: new Date(1000), }, { user: bob, - last_read: iso(500), - last_delivered_at: iso(3000), + last_read: new Date(500), + last_delivered_at: new Date(3000), }, ]; @@ -134,11 +134,11 @@ describe('MessageDeliveryReadTracker', () => { it('includes own read state', () => { const ownUser = U(ownUserId); - const snapshot: ReadResponse[] = [ + const snapshot: ReadStateResponse[] = [ { user: ownUser, - last_read: iso(2000), - last_delivered_at: iso(1000), + last_read: new Date(2000), + last_delivered_at: new Date(1000), }, ]; @@ -155,21 +155,21 @@ describe('MessageDeliveryReadTracker', () => { expect(p0).toBeNull(); // first read at m3 - tracker.onMessageRead({ user: carol, readAt: iso(3000) }); + tracker.onMessageRead({ user: carol, readAt: new Date(3000) }); const p1 = tracker.getUserProgress('carol')!; expect(p1.lastReadRef).toEqual(ref(3000)); expect(p1.lastDeliveredRef).toEqual(ref(3000)); // bumped // older/equal reads are no-ops - tracker.onMessageRead({ user: carol, readAt: iso(2000) }); - tracker.onMessageRead({ user: carol, readAt: iso(3000) }); + tracker.onMessageRead({ user: carol, readAt: new Date(2000) }); + tracker.onMessageRead({ user: carol, readAt: new Date(3000) }); const p2 = tracker.getUserProgress('carol')!; expect(p2.lastReadRef).toEqual(ref(3000)); expect(p2.lastDeliveredRef).toEqual(ref(3000)); // later read moves forward and bumps delivered - tracker.onMessageRead({ user: carol, readAt: iso(4000) }); + tracker.onMessageRead({ user: carol, readAt: new Date(4000) }); const p3 = tracker.getUserProgress('carol')!; expect(p3.lastReadRef).toEqual(ref(4000)); expect(p3.lastDeliveredRef).toEqual(ref(4000)); @@ -184,11 +184,11 @@ describe('MessageDeliveryReadTracker', () => { tracker = new MessageReceiptsTracker({ channel: channelMock.channel }); const dave = U('dave'); - tracker.onMessageRead({ user: dave, readAt: iso(4000) }); // unknown -> ignored + tracker.onMessageRead({ user: dave, readAt: new Date(4000) }); // unknown -> ignored expect(tracker.getUserProgress('dave')).toBeNull(); // but a known read creates progress - tracker.onMessageRead({ user: dave, readAt: iso(2000) }); + tracker.onMessageRead({ user: dave, readAt: new Date(2000) }); const pd = tracker.getUserProgress('dave')!; expect(pd.lastReadRef).toEqual(ref(2000)); expect(pd.lastDeliveredRef).toEqual(ref(2000)); @@ -199,7 +199,11 @@ describe('MessageDeliveryReadTracker', () => { channelMock = createChannelMock({ findMessageByTimestamp }); tracker = new MessageReceiptsTracker({ channel: channelMock.channel }); const user = U('frank'); - tracker.onMessageRead({ user, readAt: iso(3000), lastReadMessageId: 'X' }); // unknown -> ignored + tracker.onMessageRead({ + user, + readAt: new Date(3000), + lastReadMessageId: 'X', + }); // unknown -> ignored expect(findMessageByTimestamp).not.toHaveBeenCalled(); expect(tracker.getUserProgress('frank')).toStrictEqual({ lastDeliveredRef: { @@ -219,7 +223,7 @@ describe('MessageDeliveryReadTracker', () => { it('does not ignore own message.read events', () => { const ownUser = U(ownUserId); - tracker.onMessageRead({ user: ownUser, readAt: iso(2000) }); + tracker.onMessageRead({ user: ownUser, readAt: new Date(2000) }); expect(tracker.getUserProgress(ownUserId)!.user).toStrictEqual(ownUser); }); }); @@ -228,26 +232,26 @@ describe('MessageDeliveryReadTracker', () => { it('creates user on first delivered; uses max(read, delivered)', () => { const eve = U('eve'); - tracker.onMessageDelivered({ user: eve, deliveredAt: iso(2000) }); + tracker.onMessageDelivered({ user: eve, deliveredAt: new Date(2000) }); let progressEve = tracker.getUserProgress('eve')!; expect(progressEve.lastDeliveredRef).toEqual(ref(2000)); expect(progressEve.lastReadRef.timestampMs).toBe(Number.NEGATIVE_INFINITY); // deliver older/equal -> no-op - tracker.onMessageDelivered({ user: eve, deliveredAt: iso(1000) }); - tracker.onMessageDelivered({ user: eve, deliveredAt: iso(2000) }); + tracker.onMessageDelivered({ user: eve, deliveredAt: new Date(1000) }); + tracker.onMessageDelivered({ user: eve, deliveredAt: new Date(2000) }); progressEve = tracker.getUserProgress('eve')!; expect(progressEve.lastDeliveredRef).toEqual(ref(2000)); // if read goes ahead to m3, and a delivery arrives for m2, // newDelivered = max(read, deliveredEvent) = read (m3) - tracker.onMessageRead({ user: eve, readAt: iso(3000) }); + tracker.onMessageRead({ user: eve, readAt: new Date(3000) }); progressEve = tracker.getUserProgress('eve')!; expect(progressEve.lastReadRef).toEqual(ref(3000)); expect(progressEve.lastDeliveredRef).toEqual(ref(3000)); // bumped by read // deliver at m4 -> moves forward - tracker.onMessageDelivered({ user: eve, deliveredAt: iso(4000) }); + tracker.onMessageDelivered({ user: eve, deliveredAt: new Date(4000) }); progressEve = tracker.getUserProgress('eve')!; expect(progressEve.lastDeliveredRef).toEqual(ref(4000)); expect(progressEve.lastReadRef).toEqual(ref(3000)); @@ -261,10 +265,10 @@ describe('MessageDeliveryReadTracker', () => { tracker = new MessageReceiptsTracker({ channel: channelMock.channel }); const frank = U('frank'); - tracker.onMessageDelivered({ user: frank, deliveredAt: iso(3000) }); // unknown -> ignored + tracker.onMessageDelivered({ user: frank, deliveredAt: new Date(3000) }); // unknown -> ignored expect(tracker.getUserProgress('frank')).toBeNull(); - tracker.onMessageDelivered({ user: frank, deliveredAt: iso(2000) }); // known -> creates + tracker.onMessageDelivered({ user: frank, deliveredAt: new Date(2000) }); // known -> creates const pf = tracker.getUserProgress('frank')!; expect(pf.lastDeliveredRef).toEqual(ref(2000)); }); @@ -276,7 +280,7 @@ describe('MessageDeliveryReadTracker', () => { const user = U('frank'); tracker.onMessageDelivered({ user, - deliveredAt: iso(3000), + deliveredAt: new Date(3000), lastDeliveredMessageId: 'X', }); // unknown -> ignored expect(findMessageByTimestamp).not.toHaveBeenCalled(); @@ -298,7 +302,7 @@ describe('MessageDeliveryReadTracker', () => { it('does not ignore own message.delivered events', () => { const ownUser = U(ownUserId); - tracker.onMessageDelivered({ user: ownUser, deliveredAt: iso(2000) }); + tracker.onMessageDelivered({ user: ownUser, deliveredAt: new Date(2000) }); expect(tracker.getUserProgress(ownUserId)!.user).toStrictEqual(ownUser); }); }); @@ -306,11 +310,15 @@ describe('MessageDeliveryReadTracker', () => { describe('onNotificationMarkUnread', () => { const user = U('u'); it('moves lastRead backward to the event boundary and keeps delivered unchanged (no backward move)', () => { - tracker.onMessageRead({ user, readAt: iso(3000), lastReadMessageId: 'm3' }); + tracker.onMessageRead({ + user, + readAt: new Date(3000), + lastReadMessageId: 'm3', + }); tracker.onNotificationMarkUnread({ user, - lastReadAt: iso(2000), + lastReadAt: new Date(2000), lastReadMessageId: 'm2', }); @@ -330,10 +338,14 @@ describe('MessageDeliveryReadTracker', () => { // v delivered m4 and read m2 tracker.onMessageDelivered({ user, - deliveredAt: iso(4000), + deliveredAt: new Date(4000), lastDeliveredMessageId: 'm4', }); - tracker.onMessageRead({ user, readAt: iso(2000), lastReadMessageId: 'm2' }); + tracker.onMessageRead({ + user, + readAt: new Date(2000), + lastReadMessageId: 'm2', + }); let userProgress = tracker.getUserProgress(user.id)!; expect(userProgress.lastReadRef).toEqual(ref(2000)); @@ -352,12 +364,12 @@ describe('MessageDeliveryReadTracker', () => { }); it('is a no-op when the provided last_read equals current lastReadRef', () => { - tracker.onMessageRead({ user, readAt: iso(3000) }); + tracker.onMessageRead({ user, readAt: new Date(3000) }); const before = structuredClone(tracker.getUserProgress(user.id)!); tracker.onNotificationMarkUnread({ user, - lastReadAt: iso(3000), + lastReadAt: new Date(3000), lastReadMessageId: 'm3', }); @@ -375,7 +387,7 @@ describe('MessageDeliveryReadTracker', () => { tracker.onNotificationMarkUnread({ user, - lastReadAt: iso(2000), + lastReadAt: new Date(2000), lastReadMessageId: 'm2', }); @@ -430,11 +442,11 @@ describe('MessageDeliveryReadTracker', () => { const c = U('c'); // a: read m3, delivered m3 - tracker.onMessageRead({ user: a, readAt: iso(3000) }); + tracker.onMessageRead({ user: a, readAt: new Date(3000) }); // b: delivered m3 only (not read) - tracker.onMessageDelivered({ user: b, deliveredAt: iso(3000) }); + tracker.onMessageDelivered({ user: b, deliveredAt: new Date(3000) }); // c: read m4, delivered m4 - tracker.onMessageRead({ user: c, readAt: iso(4000) }); + tracker.onMessageRead({ user: c, readAt: new Date(4000) }); // Readers of m2 => a, c expect(ids(tracker.readersForMessage(ref(2000)))).toEqual(['a', 'c']); @@ -450,8 +462,8 @@ describe('MessageDeliveryReadTracker', () => { const u1 = U('u1'); const u2 = U('u2'); - tracker.onMessageDelivered({ user: u1, deliveredAt: iso(2000) }); // delivered m2 - tracker.onMessageRead({ user: u2, readAt: iso(3000) }); // read m3 (delivered m3) + tracker.onMessageDelivered({ user: u1, deliveredAt: new Date(2000) }); // delivered m2 + tracker.onMessageRead({ user: u2, readAt: new Date(3000) }); // read m3 (delivered m3) // For m2: expect(tracker.hasUserDelivered(ref(2000), 'u1')).toBe(true); @@ -477,21 +489,25 @@ describe('MessageDeliveryReadTracker', () => { const e = U('e'); // same for delivered side // a: read m2 -> delivered m2 - tracker.onMessageRead({ user: a, readAt: iso(2000) }); + tracker.onMessageRead({ user: a, readAt: new Date(2000) }); // b: read m3 -> delivered m3 - tracker.onMessageRead({ user: b, readAt: iso(3000) }); + tracker.onMessageRead({ user: b, readAt: new Date(3000) }); // c: delivered m3 only - tracker.onMessageDelivered({ user: c, deliveredAt: iso(3000) }); + tracker.onMessageDelivered({ user: c, deliveredAt: new Date(3000) }); // d: read at ts=3000 but with a different msgId "X" (tests plateau filtering by msgId) - tracker.onMessageRead({ user: d, readAt: iso(3000), lastReadMessageId: 'X' }); + tracker.onMessageRead({ + user: d, + readAt: new Date(3000), + lastReadMessageId: 'X', + }); // e: delivered at ts=3000 but with a different msgId "X" tracker.onMessageDelivered({ user: e, - deliveredAt: iso(3000), + deliveredAt: new Date(3000), lastDeliveredMessageId: 'X', }); @@ -512,12 +528,12 @@ describe('MessageDeliveryReadTracker', () => { const user = U('x'); // x reads m2 -> last read m2 (and delivered m2) - tracker.onMessageRead({ user, readAt: iso(2000) }); + tracker.onMessageRead({ user, readAt: new Date(2000) }); expect(ids(tracker.usersWhoseLastReadIs(ref(2000)))).toEqual(['x']); expect(ids(tracker.usersWhoseLastDeliveredIs(ref(2000)))).toEqual(['x']); // x later reads m4 -> moves out of m2 group and into m4 group - tracker.onMessageRead({ user, readAt: iso(4000) }); + tracker.onMessageRead({ user, readAt: new Date(4000) }); expect(ids(tracker.usersWhoseLastReadIs(ref(2000)))).toEqual([]); expect(ids(tracker.usersWhoseLastReadIs(ref(4000)))).toEqual(['x']); @@ -586,14 +602,14 @@ describe('MessageDeliveryReadTracker', () => { const y = U('y'); // x reads m2, y reads m3 - tracker.onMessageRead({ user: x, readAt: iso(2000) }); - tracker.onMessageRead({ user: y, readAt: iso(3000) }); + tracker.onMessageRead({ user: x, readAt: new Date(2000) }); + tracker.onMessageRead({ user: y, readAt: new Date(3000) }); // Readers of m2 -> x, y expect(ids(tracker.readersForMessage(ref(2000)))).toEqual(['x', 'y']); // now x reads m4 (moves past y) - tracker.onMessageRead({ user: x, readAt: iso(4000) }); + tracker.onMessageRead({ user: x, readAt: new Date(4000) }); // Readers of m3 -> x, y? Actually only x (m4) and y (m3) both >= m3 expect(ids(tracker.readersForMessage(ref(3000)))).toEqual(['y', 'x']); // and of m4 -> x only diff --git a/test/unit/offline-support/offline_support_api.test.ts b/test/unit/offline-support/offline_support_api.test.ts index af118c3a9d..b6af100c4c 100644 --- a/test/unit/offline-support/offline_support_api.test.ts +++ b/test/unit/offline-support/offline_support_api.test.ts @@ -1,21 +1,22 @@ import { describe, expect, it, beforeEach, afterEach, vi, MockInstance } from 'vitest'; import { AbstractOfflineDB, - ChannelAPIResponse, + APIError, ChannelManager, StreamChat, Event, Channel, - MessageResponse, - ReadResponse, ChannelMemberResponse, ChannelResponse, - PendingTask, - APIErrorResponse, + ChannelStateResponseFields, + MessageResponse, OfflineDBSyncManager, - StableWSConnection, OfflineError, + PendingTask, + ReadStateResponse, + StableWSConnection, } from '../../../src'; +import { chatLoggerSystem } from '../../../src/logger'; import { generateChannel } from '../test-utils/generateChannel'; import { generateReadResponse } from '../test-utils/generateReadResponse'; @@ -316,18 +317,23 @@ describe('OfflineSupportApi', () => { offlineDb.channelExists.mockResolvedValue(false); - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const sinkSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: sinkSpy, level: 'trace' }, + }); const result = await offlineDb.queriesWithChannelGuard({ event }, createQueries); - // TODO: testing against warning logs seems silly, please rethink this - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Received message.new event for a non initialized channel that is not in DB, skipping event', + expect(sinkSpy).toHaveBeenCalledWith( + 'warn', + expect.stringContaining( + 'Received a "message.new" event for a non-initialized channel that is not in the database. Skipping the event.', + ), { event }, ); expect(result).toEqual([]); - consoleWarnSpy.mockRestore(); + chatLoggerSystem.restoreDefaults(); }); it('returns createQueries result directly when channel exists and forceUpdate is false', async () => { @@ -436,8 +442,8 @@ describe('OfflineSupportApi', () => { let queriesWithChannelGuardSpy: MockInstance< typeof offlineDb.queriesWithChannelGuard >; - let channelResponse: ChannelAPIResponse; - let readResponse: ReadResponse; + let channelResponse: ChannelStateResponseFields; + let readResponse: ReadStateResponse; beforeEach(() => { queriesWithChannelGuardSpy = vi.spyOn(offlineDb, 'queriesWithChannelGuard'); @@ -446,7 +452,7 @@ describe('OfflineSupportApi', () => { channelResponse = generateChannel({ channel: { id: 'channel123', type: 'messaging' }, read: [readResponse], - } as ChannelAPIResponse); + } as ChannelStateResponseFields); client.hydrateActiveChannels([channelResponse]); // to make sure queriesWithChannelGuard always passes @@ -1254,7 +1260,7 @@ describe('OfflineSupportApi', () => { channelResponse = generateChannel({ channel: { id: 'to-truncate', type: 'messaging' }, read: [readResponse], - } as ChannelAPIResponse); + } as ChannelStateResponseFields); client.hydrateActiveChannels([channelResponse]); }); @@ -1310,7 +1316,7 @@ describe('OfflineSupportApi', () => { execute: false, reads: [ { - last_read: lastReadDate.toString(), + last_read: lastReadDate, last_read_message_id: lastReadMessageId, unread_messages: 2, user: client.user, @@ -1341,7 +1347,7 @@ describe('OfflineSupportApi', () => { execute: false, reads: [ { - last_read: lastReadDate.toString(), + last_read: lastReadDate, last_read_message_id: lastReadMessageId, unread_messages: 0, user: client.user, @@ -1383,7 +1389,7 @@ describe('OfflineSupportApi', () => { execute: false, reads: [ { - last_read: lastReadDate.toString(), + last_read: lastReadDate, last_read_message_id: lastReadMessageId, unread_messages: 0, user: client.user, @@ -1895,7 +1901,7 @@ describe('OfflineSupportApi', () => { const error = { isAxiosError: true, response: { data: { code: 999 } }, - } as AxiosError; + } as AxiosError; shouldSkipSpy.mockReturnValue(false); executeTaskSpy.mockRejectedValue(error); @@ -1910,7 +1916,7 @@ describe('OfflineSupportApi', () => { const error = { isAxiosError: true, response: { data: { code: 4 } }, - } as AxiosError; + } as AxiosError; shouldSkipSpy.mockReturnValue(true); executeTaskSpy.mockRejectedValue(error); @@ -1981,14 +1987,15 @@ describe('OfflineSupportApi', () => { }, }, ) as PendingTask; - const pendingSendOptions = { skip_enrich_url: true }; vi.spyOn(offlineDb, 'getPendingTasks').mockResolvedValue([ { id: 7, messageId: 'msg-123', payload: [ - { id: 'msg-123', status: 'sending', text: 'original' }, - pendingSendOptions, + { + message: { id: 'msg-123', status: 'sending', text: 'original' }, + skip_enrich_url: true, + }, ], type: 'send-message', } as PendingTask, @@ -2006,17 +2013,16 @@ describe('OfflineSupportApi', () => { type: 'send-message', }), }); - expect(updatePendingTaskSpy.mock.calls[0][0].task.payload[0]).toMatchObject({ + expect( + updatePendingTaskSpy.mock.calls[0][0].task.payload[0].message, + ).toMatchObject({ id: 'msg-123', status: 'sending', text: 'edited', }); expect( - updatePendingTaskSpy.mock.calls[0][0].task.payload[0], + updatePendingTaskSpy.mock.calls[0][0].task.payload[0].message, ).not.toHaveProperty('message_text_updated_at'); - expect(updatePendingTaskSpy.mock.calls[0][0].task.payload[1]).toBe( - pendingSendOptions, - ); expect(addPendingTaskSpy).not.toHaveBeenCalled(); }); @@ -2039,8 +2045,7 @@ describe('OfflineSupportApi', () => { { messageId: 'msg-123', payload: [ - { id: 'msg-123', status: 'sending', text: 'original' }, - undefined, + { message: { id: 'msg-123', status: 'sending', text: 'original' } }, ], type: 'send-message', } as PendingTask, @@ -2051,12 +2056,16 @@ describe('OfflineSupportApi', () => { await offlineDb.handleAddPendingTask({ task }); expect(updatePendingTaskSpy).not.toHaveBeenCalled(); - expect(addPendingTaskSpy).toHaveBeenCalledWith({ - messageId: 'msg-123', - payload: [{ id: 'msg-123', status: 'sending', text: 'edited' }, undefined], - type: 'send-message', - id: undefined, - }); + expect(addPendingTaskSpy).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: 'msg-123', + payload: [ + { message: { id: 'msg-123', status: 'sending', text: 'edited' } }, + ], + type: 'send-message', + id: undefined, + }), + ); }); it('does nothing for failed offline update-message tasks without a matching pending send task', async () => { @@ -2211,7 +2220,7 @@ describe('OfflineSupportApi', () => { const skippableError = { isAxiosError: true, response: { data: { code: 4 } }, - } as AxiosError; + } as AxiosError; beforeEach(() => { getPendingTasksSpy = vi @@ -2388,11 +2397,20 @@ describe('OfflineDBSyncManager', () => { const error = new Error('Sync failed'); syncAndExecutePendingTasksSpy.mockRejectedValueOnce(error); - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const sinkSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: sinkSpy, level: 'trace' }, + }); await syncManager.init(); - expect(consoleSpy).toHaveBeenCalledWith('Error in DBSyncManager.init: ', error); + expect(sinkSpy).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Failed to initialize the offline DB sync manager.'), + { error }, + ); + + chatLoggerSystem.restoreDefaults(); }); }); @@ -2658,12 +2676,10 @@ describe('OfflineDBSyncManager', () => { await (syncManager as any).sync(); - expect(syncApiSpy).toHaveBeenCalledWith( - ['channel-1'], - expect.stringMatching( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/, // ISO8601 regex, YYYY-MM-DDTHH:mm:ss.sssZ - ), - ); + expect(syncApiSpy).toHaveBeenCalledWith({ + channel_cids: ['channel-1'], + last_sync_at: expect.any(Date), + }); expect(handleEventSpy).toHaveBeenCalledTimes(mockEvents.length); expect(executeSqlBatchSpy).toHaveBeenCalledWith(['query1', 'query2']); expect(upsertUserSyncStatusSpy).toHaveBeenCalled(); @@ -2678,7 +2694,7 @@ describe('OfflineDBSyncManager', () => { isAxiosError: true, code: 'ECONNABORTED', response: { data: { code: 4 } }, - } as AxiosError; + } as AxiosError; syncApiSpy.mockRejectedValueOnce(axiosError); @@ -2695,7 +2711,7 @@ describe('OfflineDBSyncManager', () => { const axiosError = { response: { data: { code: 23 } }, - } as AxiosError; + } as AxiosError; syncApiSpy.mockRejectedValueOnce(axiosError); diff --git a/test/unit/pagination/UserGroupPaginator.test.ts b/test/unit/pagination/UserGroupPaginator.test.ts index b5c83ae388..74fe6160b9 100644 --- a/test/unit/pagination/UserGroupPaginator.test.ts +++ b/test/unit/pagination/UserGroupPaginator.test.ts @@ -9,8 +9,8 @@ const createUserGroup = ( ): UserGroupResponse => ({ id: 'group-1', name: 'Backend Support', - created_at: '2026-01-01T00:00:00.000000000Z', - updated_at: '2026-01-01T00:00:00.000000000Z', + created_at: new Date('2026-01-01T00:00:00.000Z'), + updated_at: new Date('2026-01-01T00:00:00.000Z'), ...overrides, }); @@ -32,25 +32,28 @@ describe('UserGroupPaginator', () => { it('paginates listed user groups using synthesized cursors', async () => { const firstPage = [ - createUserGroup({ id: 'group-1', created_at: '2026-01-01T00:00:00.000000000Z' }), + createUserGroup({ + id: 'group-1', + created_at: new Date('2026-01-01T00:00:00.000Z'), + }), createUserGroup({ id: 'group-2', name: 'Frontend Support', - created_at: '2026-01-02T00:00:00.000000000Z', - updated_at: '2026-01-02T00:00:00.000000000Z', + created_at: new Date('2026-01-02T00:00:00.000Z'), + updated_at: new Date('2026-01-02T00:00:00.000Z'), }), ]; const secondPage = [ createUserGroup({ id: 'group-3', name: 'QA Support', - created_at: '2026-01-03T00:00:00.000000000Z', - updated_at: '2026-01-03T00:00:00.000000000Z', + created_at: new Date('2026-01-03T00:00:00.000Z'), + updated_at: new Date('2026-01-03T00:00:00.000Z'), }), ]; const querySpy = vi - .spyOn(client, 'queryUserGroups') + .spyOn(client, 'listUserGroups') .mockResolvedValueOnce({ duration: '0.01s', user_groups: firstPage }) .mockResolvedValueOnce({ duration: '0.01s', user_groups: secondPage }); @@ -63,7 +66,7 @@ describe('UserGroupPaginator', () => { expect(paginator.hasNext).toBe(true); expect(paginator.hasPrev).toBe(false); expect(JSON.parse(paginator.cursor?.tailward ?? '{}')).toEqual({ - created_at_gt: firstPage[1].created_at, + created_at_gt: firstPage[1].created_at.toISOString(), id_gt: firstPage[1].id, }); @@ -71,7 +74,7 @@ describe('UserGroupPaginator', () => { expect(querySpy).toHaveBeenNthCalledWith(2, { limit: 2, - created_at_gt: firstPage[1].created_at, + created_at_gt: firstPage[1].created_at.toISOString(), id_gt: firstPage[1].id, }); expect(paginator.items).toEqual([...firstPage, ...secondPage]); @@ -83,7 +86,7 @@ describe('UserGroupPaginator', () => { }); it('resets paginator state when team id changes', async () => { - vi.spyOn(client, 'queryUserGroups').mockResolvedValue({ + vi.spyOn(client, 'listUserGroups').mockResolvedValue({ duration: '0.01s', user_groups: [createUserGroup()], }); @@ -101,7 +104,7 @@ describe('UserGroupPaginator', () => { }); it('ignores malformed stored cursors and retries from the first page options', async () => { - const querySpy = vi.spyOn(client, 'queryUserGroups').mockResolvedValue({ + const querySpy = vi.spyOn(client, 'listUserGroups').mockResolvedValue({ duration: '0.01s', user_groups: [createUserGroup()], }); @@ -117,7 +120,7 @@ describe('UserGroupPaginator', () => { }); it('does not execute prev pagination requests', async () => { - const querySpy = vi.spyOn(client, 'queryUserGroups'); + const querySpy = vi.spyOn(client, 'listUserGroups'); const paginator = new UserGroupPaginator(client); await paginator.prev(); diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index df81b79c14..072354e392 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -706,23 +706,20 @@ describe('ChannelPaginator', () => { }); await paginator.query(); - expect(queryChannelsSpy).toHaveBeenCalledWith( - { + expect(queryChannelsSpy).toHaveBeenCalledWith({ + filter_conditions: { muted: { $eq: true, }, name: 'A', }, - { + sort: { has_unread: -1, }, - { - limit: 22, - message_limit: 3, - offset: 0, - }, - undefined, // channelStateOptions - ); + limit: 22, + message_limit: 3, + offset: 0, + }); }); }); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 0dd4d24dee..9a4b533023 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -249,11 +249,12 @@ describe('MessagePaginator', () => { const result = await paginator.query({}); - expect(channel.getReplies).toHaveBeenCalledWith( - 'parent-1', - { id_gt: 'from-cursor', limit: 30 }, - [{ created_at: 1 }], - ); + expect(channel.getReplies).toHaveBeenCalledWith({ + parent_id: 'parent-1', + id_gt: 'from-cursor', + limit: 30, + sort: [{ field: 'created_at', direction: 1 }], + }); expect(channel.query).not.toHaveBeenCalled(); expect(result.tailward).toBe('first-reply'); expect(result.headward).toBe('last-reply'); @@ -282,11 +283,12 @@ describe('MessagePaginator', () => { const result = await paginator.query({}); - expect(channel.getReplies).toHaveBeenCalledWith( - 'parent-1', - { id_gt: 'from-cursor', limit: 30 }, - [{ created_at: -1 }], - ); + expect(channel.getReplies).toHaveBeenCalledWith({ + parent_id: 'parent-1', + id_gt: 'from-cursor', + limit: 30, + sort: [{ field: 'created_at', direction: -1 }], + }); expect(result.items.map((message) => message.id)).toEqual([ 'oldest-reply', 'middle-reply', @@ -1071,7 +1073,7 @@ describe('MessagePaginator', () => { expect(paginator.getItem(quoteCarrier.id)?.quoted_message?.text).toBe( 'after update', ); - expect(paginator.getItem(nonCarrier.id)?.quoted_message).toBeNull(); + expect(paginator.getItem(nonCarrier.id)?.quoted_message).toBeUndefined(); }); }); diff --git a/test/unit/pagination/paginators/UserGroupPaginator.test.ts b/test/unit/pagination/paginators/UserGroupPaginator.test.ts index 82d5929966..bc263f232e 100644 --- a/test/unit/pagination/paginators/UserGroupPaginator.test.ts +++ b/test/unit/pagination/paginators/UserGroupPaginator.test.ts @@ -7,8 +7,8 @@ import { getClientWithUser } from '../../test-utils/getClient'; const makeGroup = (id: string, createdAt: string): UserGroupResponse => ({ id, name: id, - created_at: createdAt, - updated_at: createdAt, + created_at: new Date(createdAt), + updated_at: new Date(createdAt), }); const response = (groups: UserGroupResponse[]) => ({ duration: '', user_groups: groups }); @@ -22,7 +22,7 @@ describe('UserGroupPaginator', () => { it('stores results in interval storage (index-addressable, headItems populated)', async () => { const paginator = new UserGroupPaginator(client, { pageSize: 2 }); - vi.spyOn(client, 'queryUserGroups').mockResolvedValue( + vi.spyOn(client, 'listUserGroups').mockResolvedValue( response([ makeGroup('a', '2020-01-01T00:00:00.000Z'), makeGroup('b', '2020-01-02T00:00:00.000Z'), @@ -43,7 +43,7 @@ describe('UserGroupPaginator', () => { it('appends forward pages and stops at a short (final) page', async () => { const paginator = new UserGroupPaginator(client, { pageSize: 2 }); - const spy = vi.spyOn(client, 'queryUserGroups'); + const spy = vi.spyOn(client, 'listUserGroups'); spy.mockResolvedValueOnce( response([ makeGroup('a', '2020-01-01T00:00:00.000Z'), @@ -70,7 +70,7 @@ describe('UserGroupPaginator', () => { it('dedupes by id when a group is returned again', async () => { const paginator = new UserGroupPaginator(client, { pageSize: 2 }); - const spy = vi.spyOn(client, 'queryUserGroups'); + const spy = vi.spyOn(client, 'listUserGroups'); spy.mockResolvedValueOnce( response([ makeGroup('a', '2020-01-01T00:00:00.000Z'), @@ -92,7 +92,7 @@ describe('UserGroupPaginator', () => { it('orders by created_at/id via the comparator even if the server returns out of order', async () => { const paginator = new UserGroupPaginator(client, { pageSize: 3 }); - vi.spyOn(client, 'queryUserGroups').mockResolvedValue( + vi.spyOn(client, 'listUserGroups').mockResolvedValue( response([ makeGroup('b', '2020-01-02T00:00:00.000Z'), makeGroup('a', '2020-01-01T00:00:00.000Z'), @@ -108,7 +108,7 @@ describe('UserGroupPaginator', () => { it('does not paginate backward (headward is exhausted)', async () => { const paginator = new UserGroupPaginator(client, { pageSize: 2 }); const spy = vi - .spyOn(client, 'queryUserGroups') + .spyOn(client, 'listUserGroups') .mockResolvedValue(response([makeGroup('a', '2020-01-01T00:00:00.000Z')])); await paginator.executeQuery({}); spy.mockClear(); diff --git a/test/unit/poll.test.js b/test/unit/poll.test.js index 500b020dfc..d582221f9e 100644 --- a/test/unit/poll.test.js +++ b/test/unit/poll.test.js @@ -146,7 +146,7 @@ const pollResponse = { // const client = sinon.createStubInstance(StreamChat); const client = new StreamChat('apiKey'); client.user = user1; -client.userID = user1.id; + describe('Poll', () => { afterEach(() => { sinon.reset(); @@ -303,7 +303,7 @@ describe('Poll', () => { }); it('should add own vote when handleVoteCasted is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const castedVote = { @@ -383,7 +383,7 @@ describe('Poll', () => { }); it('should add own answer when handleVoteCasted is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const castedVote = { @@ -457,7 +457,7 @@ describe('Poll', () => { }); it('should change own vote when handleVoteChanged is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const changedToOptionId = 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c'; @@ -504,7 +504,7 @@ describe('Poll', () => { }); it('should change an answer when handleVoteChanged is called', () => { - client.userID = user2.id; + client.user = user2; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const changedAnswer = { @@ -529,7 +529,7 @@ describe('Poll', () => { }); it('should change own answer when handleVoteChanged is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const changedAnswer = { @@ -554,7 +554,7 @@ describe('Poll', () => { }); it('should remove a vote when handleVoteRemoved is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const vote_counts_by_option = { @@ -593,7 +593,7 @@ describe('Poll', () => { }); it('should remove own vote when handleVoteRemoved is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const removedVote = user1Votes[0]; @@ -635,7 +635,7 @@ describe('Poll', () => { }); it('should remove an answer when handleVoteRemoved is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const removedAnswer = user2Answer; @@ -656,7 +656,7 @@ describe('Poll', () => { }); it('should remove own answer when handleVoteRemoved is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const removedAnswer = user1Answer; @@ -691,7 +691,7 @@ describe('Poll', () => { const originalState = poll.data; await poll.query(pollResponse.id); - expect(getPollStub.calledWith(pollResponse.id)).to.be.true; + expect(getPollStub.calledWith({ poll_id: pollResponse.id })).to.be.true; const { lastActivityAt: __, ...currentPollState } = poll.data; const { lastActivityAt: _, ...expectedPollState } = { ...originalState, @@ -709,7 +709,7 @@ describe('Poll', () => { const option_id = 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8'; const messageId = 'XXX'; const removePollVoteSpy = vi - .spyOn(client, 'removePollVote') + .spyOn(client, 'deletePollVote') .mockResolvedValue('removed'); const castPollVoteSpy = vi .spyOn(client, 'castPollVote') @@ -731,7 +731,7 @@ describe('Poll', () => { const option_id = 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8'; const messageId = 'XXX'; const removePollVoteSpy = vi - .spyOn(client, 'removePollVote') + .spyOn(client, 'deletePollVote') .mockResolvedValue('removed'); const castPollVoteSpy = vi .spyOn(client, 'castPollVote') @@ -741,8 +741,10 @@ describe('Poll', () => { await poll.castVote(option_id, messageId); expect(removePollVoteSpy).not.toHaveBeenCalled(); - expect(castPollVoteSpy).toHaveBeenCalledWith(messageId, pollResponse.id, { - option_id, + expect(castPollVoteSpy).toHaveBeenCalledWith({ + message_id: messageId, + poll_id: pollResponse.id, + vote: { option_id }, }); expect(addInfoNotificationSpy).not.toHaveBeenCalled(); }); @@ -755,7 +757,7 @@ describe('Poll', () => { const option_id = 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8'; const messageId = 'XXX'; const removePollVoteSpy = vi - .spyOn(client, 'removePollVote') + .spyOn(client, 'deletePollVote') .mockResolvedValue('removed'); const castPollVoteSpy = vi .spyOn(client, 'castPollVote') @@ -765,8 +767,10 @@ describe('Poll', () => { await poll.castVote(option_id, messageId); expect(removePollVoteSpy).not.toHaveBeenCalled(); - expect(castPollVoteSpy).toHaveBeenCalledWith(messageId, pollResponse.id, { - option_id, + expect(castPollVoteSpy).toHaveBeenCalledWith({ + message_id: messageId, + poll_id: pollResponse.id, + vote: { option_id }, }); expect(addInfoNotificationSpy).not.toHaveBeenCalled(); }); diff --git a/test/unit/poll_manager.test.ts b/test/unit/poll_manager.test.ts index 422f32d269..12c1978208 100644 --- a/test/unit/poll_manager.test.ts +++ b/test/unit/poll_manager.test.ts @@ -5,7 +5,7 @@ import { generateUUIDv4 as uuidv4 } from '../../src/utils'; import sinon from 'sinon'; import { - EventTypes, + EventType, FormatMessageResponse, MessageResponse, Poll, @@ -203,12 +203,11 @@ describe('PollManager', () => { generateChannel({ channel: { id: uuidv4() }, messages }), ); } - const mock = sinon.mock(client); const spy = sinon.spy(client.polls, 'hydratePollCache'); - mock - .expects('post') - .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); - await client.queryChannels({}); + sinon + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelsQueryResponse }); + await client.queryChannelsAndHydrate({}); expect(client.polls.data.size).to.equal(pollMessages.length); expect(spy.callCount).to.be.equal(5); for (let i = 0; i < 5; i++) { @@ -241,11 +240,10 @@ describe('PollManager', () => { const channelResponse = { ...channels[ci], messages }; mockedChannelsQueryResponse.push(channelResponse); } - const mock = sinon.mock(client); - mock - .expects('post') - .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); - await client.queryChannels({}); + sinon + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelsQueryResponse }); + await client.queryChannelsAndHydrate({}); expect(client.polls.data.size).to.equal(pollMessages.length); expect(spy.callCount).to.be.equal(10); for (let i = 0; i < 5; i++) { @@ -267,9 +265,8 @@ describe('PollManager', () => { ...mockChannelQueryResponse, messages, }; - const mock = sinon.mock(client); const spy = sinon.spy(client.polls, 'hydratePollCache'); - mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); + sinon.stub(channel, 'getOrCreate').resolves(mockedChannelQueryResponse); await channel.query(); expect(client.polls.data.size).to.equal(pollMessages.length); expect(spy.calledOnce).to.be.true; @@ -285,9 +282,8 @@ describe('PollManager', () => { ...mockChannelQueryResponse, messages, }; - const mock = sinon.mock(client); const spy = sinon.spy(client.polls, 'hydratePollCache'); - mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); + sinon.stub(channel, 'getOrCreate').resolves(mockedChannelQueryResponse); client.polls.hydratePollCache(prevMessages); await channel.query(); expect(client.polls.data.size).to.equal( @@ -541,7 +537,7 @@ describe('PollManager', () => { const updatedPoll = pollMessage1.poll as PollResponse; client.dispatchEvent({ - type: eventType as EventTypes, + type: eventType as EventType, poll: updatedPoll, }); diff --git a/test/unit/predefined_filters.test.ts b/test/unit/predefined_filters.test.ts deleted file mode 100644 index e01ebca026..0000000000 --- a/test/unit/predefined_filters.test.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { StreamChat } from '../../src/client'; -import type { - CreatePredefinedFilterOptions, - UpdatePredefinedFilterOptions, - ListPredefinedFiltersOptions, - ChannelOptions, - PredefinedFilterResponse, - ListPredefinedFiltersResponse, - APIResponse, - QueryChannelsAPIResponse, -} from '../../src/types'; - -describe('Predefined Filters', () => { - let client: StreamChat; - - beforeEach(() => { - client = new StreamChat('api_key', 'api_secret'); - }); - - describe('createPredefinedFilter', () => { - it('should create a predefined filter', async () => { - const mockResponse: PredefinedFilterResponse = { - duration: '0.01s', - predefined_filter: { - name: 'user_messaging', - operation: 'QueryChannels', - filter: { - type: 'messaging', - members: { $in: ['{{user_id}}'] }, - }, - sort: [{ field: 'last_message_at', direction: -1 }], - query_id: 12345678901234567890, - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:30:00Z', - }, - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: CreatePredefinedFilterOptions = { - name: 'user_messaging', - operation: 'QueryChannels', - filter: { - type: 'messaging', - members: { $in: ['{{user_id}}'] }, - }, - sort: [{ field: 'last_message_at', direction: -1 }], - }; - - const result = await client.createPredefinedFilter(options); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/predefined_filters`, - options, - ); - expect(result.predefined_filter.name).toBe('user_messaging'); - expect(result.predefined_filter.operation).toBe('QueryChannels'); - }); - - it('should throw error if called without server-side auth', async () => { - const clientWithoutSecret = new StreamChat('api_key'); - clientWithoutSecret.user = { id: 'test-user' }; - - const options: CreatePredefinedFilterOptions = { - name: 'test_filter', - operation: 'QueryChannels', - filter: { type: 'messaging' }, - }; - - await expect(clientWithoutSecret.createPredefinedFilter(options)).rejects.toThrow(); - }); - }); - - describe('getPredefinedFilter', () => { - it('should get a predefined filter by name', async () => { - const mockResponse: PredefinedFilterResponse = { - duration: '0.01s', - predefined_filter: { - name: 'user_messaging', - operation: 'QueryChannels', - filter: { type: 'messaging' }, - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:30:00Z', - }, - }; - - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - - const result = await client.getPredefinedFilter('user_messaging'); - - expect(getSpy).toHaveBeenCalledWith( - `${client.baseURL}/predefined_filters/user_messaging`, - ); - expect(result.predefined_filter.name).toBe('user_messaging'); - }); - - it('should properly encode filter names with special characters', async () => { - const mockResponse: PredefinedFilterResponse = { - duration: '0.01s', - predefined_filter: { - name: 'filter-with-dash', - operation: 'QueryChannels', - filter: { type: 'messaging' }, - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:30:00Z', - }, - }; - - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - - await client.getPredefinedFilter('filter-with-dash'); - - expect(getSpy).toHaveBeenCalledWith( - `${client.baseURL}/predefined_filters/filter-with-dash`, - ); - }); - }); - - describe('updatePredefinedFilter', () => { - it('should update a predefined filter', async () => { - const mockResponse: PredefinedFilterResponse = { - duration: '0.01s', - predefined_filter: { - name: 'user_messaging', - operation: 'QueryChannels', - filter: { type: 'team' }, - description: 'Updated description', - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-16T10:30:00Z', - }, - }; - - const putSpy = vi.spyOn(client, 'put').mockResolvedValue(mockResponse); - - const options: UpdatePredefinedFilterOptions = { - operation: 'QueryChannels', - filter: { type: 'team' }, - description: 'Updated description', - }; - - const result = await client.updatePredefinedFilter('user_messaging', options); - - expect(putSpy).toHaveBeenCalledWith( - `${client.baseURL}/predefined_filters/user_messaging`, - options, - ); - expect(result.predefined_filter.description).toBe('Updated description'); - }); - }); - - describe('deletePredefinedFilter', () => { - it('should delete a predefined filter', async () => { - const mockResponse: APIResponse = { - duration: '0.01s', - }; - - const deleteSpy = vi.spyOn(client, 'delete').mockResolvedValue(mockResponse); - - const result = await client.deletePredefinedFilter('user_messaging'); - - expect(deleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/predefined_filters/user_messaging`, - ); - expect(result.duration).toBe('0.01s'); - }); - }); - - describe('listPredefinedFilters', () => { - it('should list all predefined filters', async () => { - const mockResponse: ListPredefinedFiltersResponse = { - duration: '0.01s', - predefined_filters: [ - { - name: 'filter1', - operation: 'QueryChannels', - filter: { type: 'messaging' }, - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:30:00Z', - }, - { - name: 'filter2', - operation: 'QueryChannels', - filter: { type: 'team' }, - created_at: '2024-01-15T11:30:00Z', - updated_at: '2024-01-15T11:30:00Z', - }, - ], - }; - - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - - const result = await client.listPredefinedFilters(); - - expect(getSpy).toHaveBeenCalledWith(`${client.baseURL}/predefined_filters`, {}); - expect(result.predefined_filters).toHaveLength(2); - }); - - it('should pass pagination options', async () => { - const mockResponse: ListPredefinedFiltersResponse = { - duration: '0.01s', - predefined_filters: [], - next: 'next_cursor', - }; - - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - - const options: ListPredefinedFiltersOptions = { - limit: 10, - next: 'cursor', - }; - - await client.listPredefinedFilters(options); - - expect(getSpy).toHaveBeenCalledWith(`${client.baseURL}/predefined_filters`, { - limit: 10, - next: 'cursor', - }); - }); - - it('should serialize sort options as JSON', async () => { - const mockResponse: ListPredefinedFiltersResponse = { - duration: '0.01s', - predefined_filters: [], - }; - - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - - const options: ListPredefinedFiltersOptions = { - sort: [{ field: 'created_at', direction: -1 }], - limit: 20, - }; - - await client.listPredefinedFilters(options); - - expect(getSpy).toHaveBeenCalledWith(`${client.baseURL}/predefined_filters`, { - limit: 20, - sort: JSON.stringify([{ field: 'created_at', direction: -1 }]), - }); - }); - }); - - describe('queryChannels with predefined filter', () => { - beforeEach(() => { - // Mock wsPromise and connection - client.wsPromise = Promise.resolve(); - client.wsConnection = { connectionID: 'test-connection-id' } as never; - }); - - it('should query channels with a predefined filter using options', async () => { - const mockResponse: QueryChannelsAPIResponse = { - duration: '0.01s', - channels: [ - { - channel: { - id: 'channel1', - type: 'messaging', - cid: 'messaging:channel1', - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:30:00Z', - frozen: false, - disabled: false, - }, - members: [], - messages: [], - pinned_messages: [], - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: ChannelOptions = { - predefined_filter: 'user_messaging', - filter_values: { user_id: 'user123' }, - limit: 20, - }; - - // When using predefined filter, filterConditions can be empty - await client.queryChannels({}, [], options); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.objectContaining({ - predefined_filter: 'user_messaging', - filter_values: { user_id: 'user123' }, - limit: 20, - state: true, - watch: true, - presence: false, - }), - ); - // Should NOT include filter_conditions when using predefined filter - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.not.objectContaining({ - filter_conditions: expect.anything(), - }), - ); - }); - - it('should include sort_values in the request', async () => { - const mockResponse: QueryChannelsAPIResponse = { - duration: '0.01s', - channels: [], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: ChannelOptions = { - predefined_filter: 'team_channels', - filter_values: { channel_type: 'messaging', team_name: 'engineering' }, - sort_values: { sort_field: 'last_message_at' }, - limit: 50, - }; - - await client.queryChannels({}, [], options); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.objectContaining({ - predefined_filter: 'team_channels', - filter_values: { channel_type: 'messaging', team_name: 'engineering' }, - sort_values: { sort_field: 'last_message_at' }, - limit: 50, - }), - ); - }); - - it('should include traditional sort when using a predefined filter', async () => { - const mockResponse: QueryChannelsAPIResponse = { - duration: '0.01s', - channels: [], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - await client.queryChannels({}, [{ last_message_at: -1 }, { created_at: 1 }], { - predefined_filter: 'user_messaging', - filter_values: { user_id: 'user123' }, - limit: 20, - }); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.objectContaining({ - predefined_filter: 'user_messaging', - filter_values: { user_id: 'user123' }, - sort: [ - { field: 'last_message_at', direction: -1 }, - { field: 'created_at', direction: 1 }, - ], - limit: 20, - }), - ); - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.not.objectContaining({ - filter_conditions: expect.anything(), - }), - ); - }); - - it('should use traditional filter_conditions when no predefined_filter is provided', async () => { - const mockResponse: QueryChannelsAPIResponse = { - duration: '0.01s', - channels: [], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - await client.queryChannels( - { type: 'messaging', members: { $in: ['user123'] } }, - [{ last_message_at: -1 }], - { limit: 20 }, - ); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.objectContaining({ - filter_conditions: { type: 'messaging', members: { $in: ['user123'] } }, - sort: [{ field: 'last_message_at', direction: -1 }], - limit: 20, - }), - ); - // Should NOT include predefined_filter fields - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.not.objectContaining({ - predefined_filter: expect.anything(), - }), - ); - }); - - it('should set watch to false when no connection ID', async () => { - client.wsConnection = null as never; - - const mockResponse: QueryChannelsAPIResponse = { - duration: '0.01s', - channels: [], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - await client.queryChannels({}, [], { - predefined_filter: 'user_messaging', - }); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.objectContaining({ - watch: false, - }), - ); - }); - - it('should dispatch channels.queried event', async () => { - const mockResponse: QueryChannelsAPIResponse = { - duration: '0.01s', - channels: [ - { - channel: { - id: 'channel1', - type: 'messaging', - cid: 'messaging:channel1', - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:30:00Z', - frozen: false, - disabled: false, - }, - members: [], - messages: [], - pinned_messages: [], - }, - ], - }; - - vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - const dispatchSpy = vi.spyOn(client, 'dispatchEvent'); - - await client.queryChannels({}, [], { - predefined_filter: 'user_messaging', - }); - - expect(dispatchSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'channels.queried', - queriedChannels: expect.objectContaining({ - isLatestMessageSet: true, - }), - }), - ); - }); - }); -}); diff --git a/test/unit/reminders/Reminder.test.ts b/test/unit/reminders/Reminder.test.ts index ad1dad2c41..0657c5cb7c 100644 --- a/test/unit/reminders/Reminder.test.ts +++ b/test/unit/reminders/Reminder.test.ts @@ -47,7 +47,7 @@ describe('Reminder', () => { const data = generateReminderResponse({ scheduleOffsetMs }); const reminder = new Reminder({ data }); const timerInitSpy = vi.spyOn(reminder.timer, 'init'); - reminder.setState({ ...data, remind_at: new Date().toISOString() }); + reminder.setState({ ...data, remind_at: new Date() }); expect(reminder.timeLeftMs).toBe(0); expect(timerInitSpy).toHaveBeenCalledTimes(1); }); @@ -61,9 +61,7 @@ describe('Reminder', () => { vi.advanceTimersByTime(scheduleOffsetMs + DEFAULT_STOP_REFRESH_BOUNDARY_MS); reminder.setState({ ...data, - remind_at: new Date( - new Date(orignalRemindAt as string).getTime() - 1000, - ).toISOString(), + remind_at: new Date(orignalRemindAt!.getTime() - 1000), }); expect(reminder.timer.timeout).toBeNull(); expect(reminder.timeLeftMs).toBe(-1 * (DEFAULT_STOP_REFRESH_BOUNDARY_MS + 1000)); diff --git a/test/unit/reminders/ReminderManager.test.ts b/test/unit/reminders/ReminderManager.test.ts index bf69e8a793..2da41191e6 100644 --- a/test/unit/reminders/ReminderManager.test.ts +++ b/test/unit/reminders/ReminderManager.test.ts @@ -1,11 +1,14 @@ import { DEFAULT_REMINDER_MANAGER_CONFIG, DEFAULT_STOP_REFRESH_BOUNDARY_MS, - EventTypes, + EventPayload, + ListenerKeys, + MessageResponse, Reminder, ReminderManager, - ReminderResponse, + ReminderResponseData, ReminderState, + RequestMetadata, StreamChat, } from '../../../src'; import { describe, expect, it, vi } from 'vitest'; @@ -21,21 +24,19 @@ export const generateReminderResponse = ({ data, scheduleOffsetMs, }: { - data?: Partial; + data?: Partial; scheduleOffsetMs?: number; -} = {}): ReminderResponse => { - const created_at = new Date().toISOString(); - const basePayload: ReminderResponse = { +} = {}): ReminderResponseData => { + const created_at = new Date(); + const basePayload = { ...baseData, created_at, message: { id: baseData.message_id, type: 'regular' }, updated_at: created_at, user: { id: baseData.user_id }, - }; + } as ReminderResponseData; if (typeof scheduleOffsetMs === 'number') { - basePayload.remind_at = new Date( - new Date(created_at).getTime() + scheduleOffsetMs, - ).toISOString(); + basePayload.remind_at = new Date(created_at.getTime() + scheduleOffsetMs); } return { ...basePayload, @@ -43,13 +44,14 @@ export const generateReminderResponse = ({ }; }; -const generateReminderEvent = (type: EventTypes, reminder: ReminderResponse) => ({ - ...baseData, - cid: baseData.channel_cid, - created_at: new Date().toISOString(), - reminder, - type, -}); +const generateReminderEvent = (type: ListenerKeys, reminder: ReminderResponseData) => + ({ + ...baseData, + cid: baseData.channel_cid, + created_at: new Date(), + reminder, + type, + }) as EventPayload; describe('ReminderManager', () => { describe('constructor', () => { @@ -156,8 +158,7 @@ describe('ReminderManager', () => { }); it('does not add new reminders if client cache is disabled', () => { - const secret = 'secret'; - const client = new StreamChat('api-key', secret, { disableCache: true }); + const client = new StreamChat('api-key', { disableCache: true }); const manager = new ReminderManager({ client }); const reminderResponse = generateReminderResponse(); @@ -251,7 +252,7 @@ describe('ReminderManager', () => { }), type: 'regular' as const, }, - ]; + ] as MessageResponse[]; manager.hydrateState(messages); expect(manager.reminders.size).toBe(2); @@ -266,7 +267,7 @@ describe('ReminderManager', () => { const manager = new ReminderManager({ client }); manager.registerSubscriptions(); const reminderResponse = generateReminderResponse(); - const type: EventTypes = 'reminder.created'; + const type: ListenerKeys = 'reminder.created'; client.dispatchEvent(generateReminderEvent(type, reminderResponse)); expect(manager.reminders.size).toBe(1); expect(manager.reminders.get(reminderResponse.message_id)).toBeInstanceOf(Reminder); @@ -289,7 +290,7 @@ describe('ReminderManager', () => { const scheduleOffsetMs = 62 * 1000; const now = new Date().getTime(); const reminderResponse = generateReminderResponse({ scheduleOffsetMs }); - const type: EventTypes = 'reminder.created'; + const type: ListenerKeys = 'reminder.created'; client.dispatchEvent(generateReminderEvent(type, reminderResponse)); const reminder = manager.getFromState(reminderResponse.message_id); expect(reminder).toBeInstanceOf(Reminder); @@ -317,8 +318,8 @@ describe('ReminderManager', () => { const reminderResponse = generateReminderResponse(); manager.upsertToState({ data: reminderResponse }); - reminderResponse.remind_at = '1970-01-01'; - const type: EventTypes = 'reminder.updated'; + reminderResponse.remind_at = new Date('1970-01-01'); + const type: ListenerKeys = 'reminder.updated'; const now = new Date(); client.dispatchEvent(generateReminderEvent(type, reminderResponse)); expect(manager.reminders.size).toBe(1); @@ -344,7 +345,7 @@ describe('ReminderManager', () => { manager.upsertToState({ data: reminderResponse }); manager.registerSubscriptions(); - const type: EventTypes = 'reminder.deleted'; + const type: ListenerKeys = 'reminder.deleted'; client.dispatchEvent(generateReminderEvent(type, reminderResponse)); expect(manager.reminders.size).toBe(0); @@ -354,7 +355,7 @@ describe('ReminderManager', () => { const manager = new ReminderManager({ client }); manager.registerSubscriptions(); let reminderResponse = undefined; - let type: EventTypes = 'reminder.created'; + let type: ListenerKeys = 'reminder.created'; // @ts-expect-error passing undefined to mandatory param client.dispatchEvent(generateReminderEvent(type, reminderResponse)); expect(manager.reminders.size).toBe(0); @@ -369,16 +370,16 @@ describe('ReminderManager', () => { it('creates a reminder server-side and updates the state', async () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); - const reminderResponse = generateReminderResponse(); - const postSpy = vi - .spyOn(client, 'post') - .mockResolvedValueOnce({ reminder: reminderResponse }); + const reminderResponse = { + ...generateReminderResponse(), + metadata: {} as RequestMetadata, + }; + vi.spyOn(client, 'createReminder').mockResolvedValueOnce(reminderResponse); const stateUpdateSpy = vi .spyOn(manager, 'upsertToState') .mockReturnValueOnce(undefined); await manager.createReminder({ - messageId: reminderResponse.message_id, - user_id: reminderResponse.user_id, + message_id: reminderResponse.message_id, }); expect(stateUpdateSpy).toHaveBeenCalledWith({ data: reminderResponse, @@ -389,15 +390,16 @@ describe('ReminderManager', () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); const reminderResponse = generateReminderResponse(); - const postSpy = vi - .spyOn(client, 'patch') - .mockResolvedValueOnce({ reminder: reminderResponse }); + vi.spyOn(client, 'updateReminder').mockResolvedValueOnce({ + duration: '0ms', + reminder: reminderResponse, + metadata: {} as RequestMetadata, + }); const stateUpdateSpy = vi .spyOn(manager, 'upsertToState') .mockReturnValueOnce(undefined); await manager.updateReminder({ - messageId: reminderResponse.message_id, - user_id: reminderResponse.user_id, + message_id: reminderResponse.message_id, }); expect(stateUpdateSpy).toHaveBeenCalledWith({ data: reminderResponse }); }); @@ -405,7 +407,10 @@ describe('ReminderManager', () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); const messageId = 'messageId'; - const postSpy = vi.spyOn(client, 'delete').mockResolvedValueOnce(undefined); + vi.spyOn(client, 'deleteReminder').mockResolvedValueOnce({ + duration: '0ms', + metadata: {} as RequestMetadata, + }); const stateUpdateSpy = vi .spyOn(manager, 'removeFromState') .mockReturnValueOnce(undefined); @@ -416,7 +421,7 @@ describe('ReminderManager', () => { it('creates a reminder if not present in state', async () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); - const payload = { messageId: 'message_id', user_id: 'user_id' }; + const payload = { message_id: 'message_id' }; const createReminderSpy = vi .spyOn(manager, 'createReminder') .mockResolvedValue(undefined); @@ -427,15 +432,13 @@ describe('ReminderManager', () => { expect(createReminderSpy).toHaveBeenCalledWith(payload); expect(updateReminderSpy).not.toHaveBeenCalledWith(payload); }); - it('updates a reminder after failed create request if exists server-side', async () => { + it('updates a reminder after failed create request when a reminder already exists for the message', async () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); - const payload = { messageId: 'message_id', user_id: 'user_id' }; - const createReminderSpy = vi - .spyOn(manager, 'createReminder') - .mockRejectedValue( - new Error('already has reminder created for this message_id'), - ); + const payload = { message_id: 'message_id' }; + vi.spyOn(manager, 'createReminder').mockRejectedValue( + new Error('already has reminder created for this message_id'), + ); const updateReminderSpy = vi .spyOn(manager, 'updateReminder') .mockResolvedValue(undefined); @@ -447,7 +450,7 @@ describe('ReminderManager', () => { const manager = new ReminderManager({ client }); const reminder = generateReminderResponse(); manager.upsertToState({ data: reminder }); - const payload = { messageId: reminder.message_id, user_id: reminder.user_id }; + const payload = { message_id: reminder.message_id }; const createReminderSpy = vi .spyOn(manager, 'createReminder') .mockResolvedValue(undefined); @@ -458,12 +461,12 @@ describe('ReminderManager', () => { expect(createReminderSpy).not.toHaveBeenCalledWith(payload); expect(updateReminderSpy).toHaveBeenCalledWith(payload); }); - it('creates a reminder after failed update request if does not exist server-side', async () => { + it('creates a reminder after failed update request when the reminder no longer exists', async () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); const reminder = generateReminderResponse(); manager.upsertToState({ data: reminder }); - const payload = { messageId: reminder.message_id, user_id: reminder.user_id }; + const payload = { message_id: reminder.message_id }; const createReminderSpy = vi .spyOn(manager, 'createReminder') .mockResolvedValue(undefined); @@ -484,7 +487,7 @@ describe('ReminderManager', () => { const reminders = Array.from({ length: 4 }, (_, i) => generateReminderResponse({ data: { message_id: `message_id_${i}` } }), ); - const queryReturnValue: PaginationQueryReturnValue = { + const queryReturnValue: PaginationQueryReturnValue = { items: reminders, }; vi.spyOn(manager.paginator, 'query').mockResolvedValue(queryReturnValue); @@ -501,7 +504,7 @@ describe('ReminderManager', () => { const reminders = Array.from({ length: 4 }, (_, i) => generateReminderResponse({ data: { message_id: `messag_id_${i}` } }), ); - const queryReturnValue: PaginationQueryReturnValue = { + const queryReturnValue: PaginationQueryReturnValue = { items: reminders, }; vi.spyOn(manager.paginator, 'query').mockResolvedValue(queryReturnValue); diff --git a/test/unit/reminders/reminder.api.test.js b/test/unit/reminders/reminder.api.test.js deleted file mode 100644 index e0314ceb29..0000000000 --- a/test/unit/reminders/reminder.api.test.js +++ /dev/null @@ -1,428 +0,0 @@ -import { StreamChat } from '../../../src'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const user1 = { - id: 'user1', - role: 'user', - created_at: '2024-01-01T00:00:00.000Z', - updated_at: '2024-01-01T00:00:00.000Z', - name: 'Test User 1', -}; - -const reminderResponse = { - id: 'reminder1', - remind_at: '2025-04-12T23:20:50.52Z', - user_id: user1.id, - user: user1, - channel_cid: 'messaging:123', - message_id: 'message123', - created_at: '2024-01-01T00:00:00.000Z', - updated_at: '2024-01-01T00:00:00.000Z', -}; - -describe('Reminder', () => { - let client; - - beforeEach(() => { - client = new StreamChat('api_key'); - client.user = user1; - client.userID = user1.id; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('createReminder', () => { - it('should create a reminder successfully', async () => { - const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce(reminderResponse); - - const result = await client.createReminder({ - messageId: 'message123', - remind_at: '2025-04-12T23:20:50.52Z', - }); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/message123/reminders`, - ); - expect(postSpy.mock.calls[0][1]).toEqual({ - remind_at: '2025-04-12T23:20:50.52Z', - }); - expect(result).toEqual(reminderResponse); - }); - - it('should create a reminder without remind_at', async () => { - const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce(reminderResponse); - - await client.createReminder({ messageId: 'message123' }); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][1]).toEqual({}); - }); - - it('should create a reminder with null remind_at', async () => { - const reminderWithNullDate = { - ...reminderResponse, - remind_at: null, - }; - const postSpy = vi - .spyOn(client, 'post') - .mockResolvedValueOnce(reminderWithNullDate); - - const result = await client.createReminder({ - messageId: 'message123', - remind_at: null, - }); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/message123/reminders`, - ); - expect(postSpy.mock.calls[0][1]).toEqual({ - remind_at: null, - }); - expect(result).toEqual(reminderWithNullDate); - }); - - it('should create a reminder with undefined remind_at', async () => { - const reminderWithoutDate = { - ...reminderResponse, - remind_at: undefined, - }; - const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce(reminderWithoutDate); - - const result = await client.createReminder({ - messageId: 'message123', - remind_at: undefined, - }); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/message123/reminders`, - ); - expect(postSpy.mock.calls[0][1]).toEqual({ - remind_at: undefined, - }); - expect(result).toEqual(reminderWithoutDate); - }); - }); - - describe('updateReminder', () => { - it('should update a reminder successfully', async () => { - const updatedReminder = { - ...reminderResponse, - remind_at: '2025-05-12T23:20:50.52Z', - }; - const patchStub = vi.spyOn(client, 'patch').mockResolvedValueOnce(updatedReminder); - - const result = await client.updateReminder({ - messageId: 'message123', - remind_at: '2025-05-12T23:20:50.52Z', - }); - - expect(patchStub).toHaveBeenCalledTimes(1); - expect(patchStub.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/message123/reminders`, - ); - expect(patchStub.mock.calls[0][1]).toEqual({ - remind_at: '2025-05-12T23:20:50.52Z', - }); - expect(result).toEqual(updatedReminder); - }); - - it('should update a reminder to remove remind_at', async () => { - const updatedReminder = { - ...reminderResponse, - remind_at: null, - }; - const patchStub = vi.spyOn(client, 'patch').mockResolvedValueOnce(updatedReminder); - - const result = await client.updateReminder({ - messageId: 'message123', - remind_at: null, - }); - - expect(patchStub).toHaveBeenCalledTimes(1); - expect(patchStub.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/message123/reminders`, - ); - expect(patchStub.mock.calls[0][1]).toEqual({ - remind_at: null, - }); - expect(result).toEqual(updatedReminder); - }); - - it('should update a reminder with undefined remind_at', async () => { - const updatedReminder = { - ...reminderResponse, - remind_at: undefined, - }; - const patchStub = vi.spyOn(client, 'patch').mockResolvedValueOnce(updatedReminder); - - const result = await client.updateReminder({ - messageId: 'message123', - remind_at: undefined, - }); - - expect(patchStub).toHaveBeenCalledTimes(1); - expect(patchStub.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/message123/reminders`, - ); - expect(patchStub.mock.calls[0][1]).toEqual({ - remind_at: undefined, - }); - expect(result).toEqual(updatedReminder); - }); - }); - - describe('deleteReminder', () => { - it('should delete a reminder successfully', async () => { - const deleteStub = vi.spyOn(client, 'delete').mockResolvedValueOnce({}); - - await client.deleteReminder('message123'); - - expect(deleteStub).toHaveBeenCalledTimes(1); - expect(deleteStub.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/message123/reminders`, - ); - expect(deleteStub.mock.calls[0][1]).toEqual({}); - }); - - it('should delete a reminder with user_id', async () => { - const deleteStub = vi.spyOn(client, 'delete').mockResolvedValueOnce({}); - - await client.deleteReminder('message123', 'user1'); - - expect(deleteStub).toHaveBeenCalledTimes(1); - expect(deleteStub.mock.calls[0][1]).toEqual({ user_id: 'user1' }); - }); - }); - - describe('queryReminders', () => { - it('should query reminders successfully', async () => { - const queryResponse = { - reminders: [reminderResponse], - next: 'next_page_token', - }; - const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce(queryResponse); - - const result = await client.queryReminders({ - filter_conditions: { - channel_cid: 'messaging:123', - remind_at: { $gt: '2024-01-01T00:00:00.000Z' }, - }, - sort: [{ remind_at: 1 }], - limit: 10, - }); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][0]).toBe(`${client.baseURL}/reminders/query`); - expect(postSpy.mock.calls[0][1]).toEqual({ - filter_conditions: { - channel_cid: 'messaging:123', - remind_at: { $gt: '2024-01-01T00:00:00.000Z' }, - }, - sort: [{ field: 'remind_at', direction: 1 }], - limit: 10, - }); - expect(result).toEqual(queryResponse); - }); - - it('should query reminders with empty options', async () => { - const queryResponse = { reminders: [] }; - const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce(queryResponse); - - const result = await client.queryReminders(); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][1]).toEqual({}); - expect(result).toEqual(queryResponse); - }); - }); - - describe('Reminder Events', () => { - it('should handle reminder.created event', () => { - const eventHandler = vi.fn(); - client.on('reminder.created', eventHandler); - - const reminderEvent = { - type: 'reminder.created', - reminder: reminderResponse, - }; - - client.dispatchEvent(reminderEvent); - - expect(eventHandler).toHaveBeenCalledTimes(1); - expect(eventHandler.mock.calls[0][0]).toEqual(reminderEvent); - }); - - it('should handle reminder.updated event', () => { - const eventHandler = vi.fn(); - client.on('reminder.updated', eventHandler); - - const reminderEvent = { - type: 'reminder.updated', - reminder: { - ...reminderResponse, - remind_at: '2025-05-12T23:20:50.52Z', - }, - }; - - client.dispatchEvent(reminderEvent); - - expect(eventHandler).toHaveBeenCalledTimes(1); - expect(eventHandler.mock.calls[0][0]).toEqual(reminderEvent); - }); - - it('should handle reminder.deleted event', () => { - const eventHandler = vi.fn(); - client.on('reminder.deleted', eventHandler); - - const reminderEvent = { - type: 'reminder.deleted', - reminder: reminderResponse, - }; - - client.dispatchEvent(reminderEvent); - - expect(eventHandler).toHaveBeenCalledTimes(1); - expect(eventHandler.mock.calls[0][0]).toEqual(reminderEvent); - }); - - it('should handle notification.reminder_due event', () => { - const eventHandler = vi.fn(); - client.on('notification.reminder_due', eventHandler); - - const reminderEvent = { - type: 'notification.reminder_due', - reminder: reminderResponse, - }; - - client.dispatchEvent(reminderEvent); - - expect(eventHandler).toHaveBeenCalledTimes(1); - expect(eventHandler.mock.calls[0][0]).toEqual(reminderEvent); - }); - }); - - describe('reminder feature flag in channel config', () => { - let channelType; - let channel; - let message; - - beforeEach(async () => { - // Create a unique channel type name - channelType = 'reminders-test-' + Math.random().toString(36).substring(2, 10); - - // Create a new channel type - vi.spyOn(client, 'createChannelType').mockResolvedValueOnce({ - name: channelType, - user_message_reminders: false, // Initially disabled - }); - - await client.createChannelType({ - name: channelType, - user_message_reminders: false, - }); - - // Create a channel with this type - channel = client.channel(channelType, 'test-channel'); - - // Mock the channel.create method - vi.spyOn(channel, 'create').mockResolvedValueOnce({ - channel: { - id: 'test-channel', - type: channelType, - cid: `${channelType}:test-channel`, - config: { - user_message_reminders: false, // Feature flag disabled - }, - }, - }); - - await channel.create(); - - // Mock the client.configs to return the channel config - client.configs = { - [`${channelType}:test-channel`]: { - user_message_reminders: false, // Feature flag disabled - }, - }; - - // Create a test message - message = { - id: 'test-message', - text: 'Hello, world!', - user: user1, - }; - }); - - it('should fail to create a reminder when user_message_reminders is disabled', async () => { - // Mock the post method to simulate an error response - const postSpy = vi.spyOn(client, 'post').mockRejectedValueOnce({ - code: 403, - message: 'User message reminders are not enabled for this channel', - status: 403, - }); - - try { - await client.createReminder({ - messageId: 'test-message', - remind_at: '2025-04-12T23:20:50.52Z', - }); - // If we reach here, the test should fail - expect.fail('Expected createReminder to throw an error'); - } catch (error) { - expect(error.code).toBe(403); - expect(error.message).toBe( - 'User message reminders are not enabled for this channel', - ); - } - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/test-message/reminders`, - ); - }); - - it('should successfully create a reminder after enabling user_message_reminders', async () => { - // Update the channel type to enable user_message_reminders - vi.spyOn(client, 'updateChannelType').mockResolvedValueOnce({ - name: channelType, - user_message_reminders: true, // Now enabled - }); - - await client.updateChannelType(channelType, { - user_message_reminders: true, - }); - - // Update the client.configs to reflect the updated channel config - client.configs = { - [`${channelType}:test-channel`]: { - user_message_reminders: true, // Feature flag enabled - }, - }; - - // Mock the post method to simulate a successful response - const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce({ - ...reminderResponse, - message_id: 'test-message', - }); - - const result = await client.createReminder({ - messageId: 'test-message', - remind_at: '2025-04-12T23:20:50.52Z', - }); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/test-message/reminders`, - ); - expect(postSpy.mock.calls[0][1]).toEqual({ - remind_at: '2025-04-12T23:20:50.52Z', - }); - expect(result.message_id).toBe('test-message'); - }); - }); -}); diff --git a/test/unit/retention_policy.test.ts b/test/unit/retention_policy.test.ts deleted file mode 100644 index f8827680ab..0000000000 --- a/test/unit/retention_policy.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { StreamChat } from '../../src/client'; -import type { - GetRetentionPolicyRunsOptions, - GetRetentionPolicyRunsResponse, -} from '../../src/types'; - -describe('Retention Policy Runs', () => { - let client: StreamChat; - - beforeEach(() => { - client = new StreamChat('api_key', 'api_secret'); - }); - - describe('getRetentionPolicyRuns', () => { - it('should query runs with default options', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.05s', - runs: [ - { - app_pk: 1, - policy: 'old-messages', - date: '2026-03-30', - stats: { messages_deleted: 150 }, - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const result = await client.getRetentionPolicyRuns(); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, {}); - expect(result.runs).toHaveLength(1); - expect(result.runs[0].policy).toBe('old-messages'); - expect(result.runs[0].stats.messages_deleted).toBe(150); - }); - - it('should query runs with filter_conditions on policy', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.03s', - runs: [ - { - app_pk: 1, - policy: 'inactive-channels', - date: '2026-03-29', - stats: { channels_deleted: 42 }, - }, - { - app_pk: 1, - policy: 'inactive-channels', - date: '2026-03-28', - stats: { channels_deleted: 38 }, - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: GetRetentionPolicyRunsOptions = { - filter_conditions: { policy: { $eq: 'inactive-channels' } }, - }; - - const result = await client.getRetentionPolicyRuns(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, { - filter_conditions: { policy: { $eq: 'inactive-channels' } }, - }); - expect(result.runs).toHaveLength(2); - expect(result.runs.every((r) => r.policy === 'inactive-channels')).toBe(true); - }); - - it('should query runs with filter_conditions on date range', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.04s', - runs: [ - { - app_pk: 1, - policy: 'old-messages', - date: '2026-03-15', - stats: { messages_deleted: 200 }, - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: GetRetentionPolicyRunsOptions = { - filter_conditions: { - $and: [ - { date: { $gte: '2026-03-01T00:00:00Z' } }, - { date: { $lte: '2026-03-31T00:00:00Z' } }, - ], - }, - }; - - const result = await client.getRetentionPolicyRuns(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, { - filter_conditions: { - $and: [ - { date: { $gte: '2026-03-01T00:00:00Z' } }, - { date: { $lte: '2026-03-31T00:00:00Z' } }, - ], - }, - }); - expect(result.runs).toHaveLength(1); - expect(result.runs[0].date).toBe('2026-03-15'); - }); - - it('should query runs with sort', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.03s', - runs: [ - { - app_pk: 1, - policy: 'old-messages', - date: '2026-03-30', - stats: { messages_deleted: 100 }, - }, - { - app_pk: 1, - policy: 'old-messages', - date: '2026-03-29', - stats: { messages_deleted: 120 }, - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: GetRetentionPolicyRunsOptions = { - sort: [{ field: 'date', direction: -1 }], - }; - - const result = await client.getRetentionPolicyRuns(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, { - sort: [{ field: 'date', direction: -1 }], - }); - expect(result.runs).toHaveLength(2); - }); - - it('should query runs with pagination using next cursor', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.02s', - runs: [ - { - app_pk: 1, - policy: 'old-messages', - date: '2026-03-20', - stats: { messages_deleted: 80 }, - }, - ], - next: 'next_cursor_value', - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: GetRetentionPolicyRunsOptions = { - limit: 1, - }; - - const result = await client.getRetentionPolicyRuns(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, { - limit: 1, - }); - expect(result.runs).toHaveLength(1); - expect(result.next).toBe('next_cursor_value'); - }); - - it('should paginate using next cursor from previous response', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.02s', - runs: [ - { - app_pk: 1, - policy: 'old-messages', - date: '2026-03-19', - stats: { messages_deleted: 60 }, - }, - ], - prev: 'prev_cursor_value', - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: GetRetentionPolicyRunsOptions = { - limit: 1, - next: 'next_cursor_value', - }; - - const result = await client.getRetentionPolicyRuns(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, { - limit: 1, - next: 'next_cursor_value', - }); - expect(result.runs).toHaveLength(1); - expect(result.prev).toBe('prev_cursor_value'); - }); - - it('should combine filter_conditions, sort, and pagination', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.04s', - runs: [ - { - app_pk: 1, - policy: 'old-messages', - date: '2026-03-30', - stats: { messages_deleted: 300 }, - }, - ], - next: 'abc123', - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: GetRetentionPolicyRunsOptions = { - filter_conditions: { policy: { $eq: 'old-messages' } }, - sort: [{ field: 'date', direction: -1 }], - limit: 5, - }; - - const result = await client.getRetentionPolicyRuns(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, { - filter_conditions: { policy: { $eq: 'old-messages' } }, - sort: [{ field: 'date', direction: -1 }], - limit: 5, - }); - expect(result.runs).toHaveLength(1); - expect(result.next).toBe('abc123'); - }); - - it('should handle empty runs response', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.01s', - runs: [], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: GetRetentionPolicyRunsOptions = { - filter_conditions: { policy: { $eq: 'old-messages' } }, - }; - - const result = await client.getRetentionPolicyRuns(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, { - filter_conditions: { policy: { $eq: 'old-messages' } }, - }); - expect(result.runs).toHaveLength(0); - expect(result.next).toBeUndefined(); - expect(result.prev).toBeUndefined(); - }); - - it('should throw error if called without server-side auth', async () => { - const clientWithoutSecret = new StreamChat('api_key'); - clientWithoutSecret.user = { id: 'test-user' }; - - await expect(clientWithoutSecret.getRetentionPolicyRuns()).rejects.toThrow(); - }); - }); -}); diff --git a/test/unit/search/ChannelMemberSearchSource.test.ts b/test/unit/search/ChannelMemberSearchSource.test.ts index 8cc1e884dd..b36584737b 100644 --- a/test/unit/search/ChannelMemberSearchSource.test.ts +++ b/test/unit/search/ChannelMemberSearchSource.test.ts @@ -161,7 +161,7 @@ describe('ChannelMemberSearchSource', () => { it('passes filters, sort, and options to channel.queryMembers', async () => { const filters: MemberFilters = { user_id: 'user-2' }; - const sort: MemberSort = [{ name: 1 }]; + const sort: MemberSort = [{ field: 'name', direction: 1 }]; searchSource.filters = filters; searchSource.sort = sort; searchSource.searchOptions = { user_id_gt: 'user-0' }; @@ -169,18 +169,18 @@ describe('ChannelMemberSearchSource', () => { // @ts-expect-error accessing protected method await searchSource.query('John'); - expect(channel.queryMembers).toHaveBeenCalledWith( - { - ...getAutocompleteFilters('John'), - user_id: 'user-2', - }, - sort, - { + expect(channel.queryMembers).toHaveBeenCalledWith({ + payload: { + filter_conditions: { + ...getAutocompleteFilters('John'), + user_id: 'user-2', + }, + sort, user_id_gt: 'user-0', limit: searchSource.pageSize, offset: searchSource.offset, }, - ); + }); }); it('returns items from query', async () => { @@ -198,9 +198,8 @@ describe('ChannelMemberSearchSource', () => { expect(searchSource.items).toEqual(mockMembers); expect(searchSource.searchQuery).toBe(''); - expect(channel.queryMembers).toHaveBeenCalledWith({}, [], { - limit: 10, - offset: 0, + expect(channel.queryMembers).toHaveBeenCalledWith({ + payload: { filter_conditions: {}, sort: [], limit: 10, offset: 0 }, }); }); @@ -209,11 +208,14 @@ describe('ChannelMemberSearchSource', () => { await vi.advanceTimersByTimeAsync(300); expect(searchSource.searchQuery).toBe('john'); - expect(channel.queryMembers).toHaveBeenCalledWith( - getAutocompleteFilters('john'), - [], - { limit: 10, offset: 0 }, - ); + expect(channel.queryMembers).toHaveBeenCalledWith({ + payload: { + filter_conditions: getAutocompleteFilters('john'), + sort: [], + limit: 10, + offset: 0, + }, + }); }); it('debounces rapid search calls and only executes the last query', async () => { @@ -224,11 +226,14 @@ describe('ChannelMemberSearchSource', () => { await vi.advanceTimersByTimeAsync(300); expect(channel.queryMembers).toHaveBeenCalledTimes(1); - expect(channel.queryMembers).toHaveBeenCalledWith( - getAutocompleteFilters('john'), - [], - { limit: 10, offset: 0 }, - ); + expect(channel.queryMembers).toHaveBeenCalledWith({ + payload: { + filter_conditions: getAutocompleteFilters('john'), + sort: [], + limit: 10, + offset: 0, + }, + }); }); it('resets state for a new search query', async () => { @@ -239,11 +244,14 @@ describe('ChannelMemberSearchSource', () => { await vi.advanceTimersByTimeAsync(300); expect(searchSource.searchQuery).toBe('second'); - expect(channel.queryMembers).toHaveBeenLastCalledWith( - getAutocompleteFilters('second'), - [], - { limit: 10, offset: 0 }, - ); + expect(channel.queryMembers).toHaveBeenLastCalledWith({ + payload: { + filter_conditions: getAutocompleteFilters('second'), + sort: [], + limit: 10, + offset: 0, + }, + }); }); it('paginates without starting a new search query', async () => { @@ -270,9 +278,8 @@ describe('ChannelMemberSearchSource', () => { paginatedSource.search(); await vi.advanceTimersByTimeAsync(300); - expect(queryMembersMock).toHaveBeenNthCalledWith(2, {}, [], { - limit: 2, - offset: 2, + expect(queryMembersMock).toHaveBeenNthCalledWith(2, { + payload: { filter_conditions: {}, sort: [], limit: 2, offset: 2 }, }); expect(paginatedSource.items).toEqual([...firstPage, ...secondPage]); expect(paginatedSource.hasNext).toBe(false); diff --git a/test/unit/search/ChannelSearchSource.test.ts b/test/unit/search/ChannelSearchSource.test.ts index 1a84c2fa92..129cb1c04f 100644 --- a/test/unit/search/ChannelSearchSource.test.ts +++ b/test/unit/search/ChannelSearchSource.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, MockInstance } from 'v import { ChannelSearchSource } from '../../../src/search/ChannelSearchSource'; import type { Channel } from '../../../src/channel'; import type { StreamChat } from '../../../src/client'; -import type { ChannelAPIResponse, ChannelFilters } from '../../../src/types'; +import type { ChannelStateResponseFields, ChannelFilters } from '../../../src/types'; import { generateChannel } from '../test-utils/generateChannel'; import { getClientWithUser } from '../test-utils/getClient'; @@ -10,16 +10,21 @@ describe('ChannelSearchSource', () => { const user = { id: 'user-123' }; let client: StreamChat; let searchSource: ChannelSearchSource; - let queryChannelsMock: MockInstance; + let queryChannelsMock: MockInstance; let channels: Channel[]; - const mockChannels: ChannelAPIResponse[] = [generateChannel(), generateChannel()]; + const mockChannels: ChannelStateResponseFields[] = [ + generateChannel(), + generateChannel(), + ]; beforeEach(() => { client = getClientWithUser(user); channels = mockChannels.map((data) => client.channel(data.channel.type, data.channel.id), ); - queryChannelsMock = vi.spyOn(client, 'queryChannels').mockResolvedValue(channels); + queryChannelsMock = vi + .spyOn(client, 'queryChannelsAndHydrate') + .mockResolvedValue(channels); searchSource = new ChannelSearchSource(client); }); @@ -138,7 +143,7 @@ describe('ChannelSearchSource', () => { searchQuery ? { 'member.user.name': { $autocomplete: searchQuery } } : null, }, }); - searchSource.sort = { last_message_at: -1 }; + searchSource.sort = [{ field: 'last_message_at', direction: -1 }]; searchSource.searchOptions = { message_limit: 5 }; // @ts-expect-error accessing protected property @@ -146,14 +151,19 @@ describe('ChannelSearchSource', () => { expect(queryChannelsMock).toHaveBeenCalledWith( { - 'member.user.name': { - $autocomplete: 'channel search', - }, // custom - members: { $in: [user.id] }, // static default - name: { $autocomplete: 'channel search' }, // dynamic default + filter_conditions: { + 'member.user.name': { + $autocomplete: 'channel search', + }, + members: { $in: [user.id] }, + name: { $autocomplete: 'channel search' }, + }, + sort: [{ field: 'last_message_at', direction: -1 }], + message_limit: 5, + limit: searchSource.pageSize, + offset: searchSource.offset, }, - { last_message_at: -1 }, - { message_limit: 5, limit: searchSource.pageSize, offset: searchSource.offset }, + { withResponse: false }, ); }); @@ -171,7 +181,7 @@ describe('ChannelSearchSource', () => { }); it('works without client.userID', async () => { - searchSource.client.userID = undefined; + searchSource.client.user = undefined; const spyBuildFilters = vi .spyOn(searchSource.filterBuilder, 'buildFilters') .mockReturnValue({}); diff --git a/test/unit/search/MessageSearchSource.test.ts b/test/unit/search/MessageSearchSource.test.ts index 0246d48646..2befc8a804 100644 --- a/test/unit/search/MessageSearchSource.test.ts +++ b/test/unit/search/MessageSearchSource.test.ts @@ -10,7 +10,7 @@ describe('MessageSearchSource', () => { let client: StreamChat; let searchSource: MessageSearchSource; let searchMock: MockInstance; - let queryChannelsMock: MockInstance; + let queryChannelsMock: MockInstance; let messages: MessageResponse[]; let searchResponse: SearchAPIResponse; @@ -22,7 +22,7 @@ describe('MessageSearchSource', () => { next: 'next-token', } as any; searchMock = vi.spyOn(client, 'search').mockResolvedValue(searchResponse); - queryChannelsMock = vi.spyOn(client, 'queryChannels').mockResolvedValue([]); + queryChannelsMock = vi.spyOn(client, 'queryChannelsAndHydrate').mockResolvedValue([]); searchSource = new MessageSearchSource(client); }); @@ -203,7 +203,7 @@ describe('MessageSearchSource', () => { }); it('returns empty items when client.userID is missing', async () => { - searchSource['client'].userID = undefined; + searchSource['client'].user = undefined; // @ts-expect-error protected access const result = await searchSource.query('test'); expect(result).toEqual({ items: [] }); @@ -224,17 +224,17 @@ describe('MessageSearchSource', () => { // @ts-expect-error protected access const result = await searchSource.query(''); - expect(searchMock).toHaveBeenCalledWith( - expect.objectContaining({ - members: { $in: [user.id] }, - }), - { type: 'regular' }, - expect.objectContaining({ + expect(searchMock).toHaveBeenCalledWith({ + payload: expect.objectContaining({ + filter_conditions: { + members: { $in: [user.id] }, + }, + message_filter_conditions: { type: 'regular' }, limit: searchSource.pageSize, next: undefined, - sort: { created_at: -1 }, + sort: [{ field: 'created_at', direction: -1 }], }), - ); + }); expect(result.items).toEqual(messages); expect(result.next).toBe('next-token'); }); @@ -243,28 +243,31 @@ describe('MessageSearchSource', () => { searchSource.messageSearchFilters = { 'mentioned_users.id': { $contains: 'abc' } }; searchSource.messageSearchChannelFilters = { type: 'messaging' }; searchSource.channelQueryFilters = { type: 'abc' }; - searchSource.messageSearchSort = { created_at: 1 }; + searchSource.messageSearchSort = [{ field: 'created_at', direction: 1 }]; searchSource.state.partialNext({ next: 'next-token-old' }); // @ts-expect-error protected access await searchSource.query('hello'); - expect(searchMock).toHaveBeenCalledWith( - expect.objectContaining({ - members: { $in: [user.id] }, - type: 'messaging', - }), - expect.objectContaining({ - 'mentioned_users.id': { $contains: 'abc' }, - type: 'regular', - text: 'hello', - }), - expect.objectContaining({ + expect(searchMock).toHaveBeenCalledWith({ + payload: expect.objectContaining({ + filter_conditions: { + members: { $in: [user.id] }, + type: 'messaging', + }, + message_filter_conditions: { + 'mentioned_users.id': { $contains: 'abc' }, + type: 'regular', + text: 'hello', + }, limit: searchSource.pageSize, next: 'next-token-old', - sort: { created_at: 1 }, // note: merges created_at with default -1, order may vary + sort: [ + { field: 'created_at', direction: -1 }, + { field: 'created_at', direction: 1 }, + ], }), - ); + }); }); it('overrides the static filters with dynamic ones', async () => { @@ -288,29 +291,32 @@ describe('MessageSearchSource', () => { searchQuery ? { type: { $in: [searchQuery] } } : null, }, }); - searchSource.messageSearchSort = { created_at: 1 }; + searchSource.messageSearchSort = [{ field: 'created_at', direction: 1 }]; searchSource.state.partialNext({ next: 'next-token-old' }); const searchQuery = 'hello'; // @ts-expect-error protected access await searchSource.query(searchQuery); - expect(searchMock).toHaveBeenCalledWith( - expect.objectContaining({ - members: { $in: [user.id] }, - type: { $in: [searchQuery] }, - }), - expect.objectContaining({ - 'mentioned_users.id': { $contains: searchQuery }, - type: 'regular', - text: searchQuery, - }), - expect.objectContaining({ + expect(searchMock).toHaveBeenCalledWith({ + payload: expect.objectContaining({ + filter_conditions: { + members: { $in: [user.id] }, + type: { $in: [searchQuery] }, + }, + message_filter_conditions: { + 'mentioned_users.id': { $contains: searchQuery }, + type: 'regular', + text: searchQuery, + }, limit: searchSource.pageSize, next: 'next-token-old', - sort: { created_at: 1 }, // note: merges created_at with default -1, order may vary + sort: [ + { field: 'created_at', direction: -1 }, + { field: 'created_at', direction: 1 }, + ], }), - ); + }); }); it('overrides the message type', async () => { @@ -320,20 +326,20 @@ describe('MessageSearchSource', () => { // @ts-expect-error protected access await searchSource.query('hello'); - expect(searchMock).toHaveBeenCalledWith( - expect.objectContaining({ - members: { $in: [user.id] }, - }), - expect.objectContaining({ - type: 'deleted', - text: 'hello', - }), - expect.objectContaining({ + expect(searchMock).toHaveBeenCalledWith({ + payload: expect.objectContaining({ + filter_conditions: { + members: { $in: [user.id] }, + }, + message_filter_conditions: { + type: 'deleted', + text: 'hello', + }, limit: searchSource.pageSize, next: 'next-token-old', - sort: { created_at: -1 }, // note: merges created_at with default -1, order may vary + sort: [{ field: 'created_at', direction: -1 }], }), - ); + }); }); it('calls queryChannels when some cids are missing locally', async () => { @@ -349,11 +355,10 @@ describe('MessageSearchSource', () => { // @ts-expect-error protected access await searchSource.query('query'); - expect(queryChannelsMock).toHaveBeenCalledWith( - { cid: { $in: ['cid2'] }, type: 'abc' }, - { last_message_at: -1 }, - undefined, - ); + expect(queryChannelsMock).toHaveBeenCalledWith({ + filter_conditions: { cid: { $in: ['cid2'] }, type: 'abc' }, + sort: [{ direction: -1, field: 'last_message_at' }], + }); }); it('does not call queryChannels if all channels are loaded locally', async () => { @@ -389,11 +394,10 @@ describe('MessageSearchSource', () => { // @ts-expect-error protected access await searchSource.query('query'); - expect(queryChannelsMock).toHaveBeenCalledWith( - { cid: { $in: ['cid2'] }, type: 'efg' }, - { last_message_at: -1 }, - undefined, - ); + expect(queryChannelsMock).toHaveBeenCalledWith({ + filter_conditions: { cid: { $in: ['cid2'] }, type: 'efg' }, + sort: [{ direction: -1, field: 'last_message_at' }], + }); }); it('returns items and next from search', async () => { diff --git a/test/unit/search/SearchController.test.js b/test/unit/search/SearchController.test.js index 3a37ff93f2..52851dd745 100644 --- a/test/unit/search/SearchController.test.js +++ b/test/unit/search/SearchController.test.js @@ -11,7 +11,7 @@ import { generateUser } from '../test-utils/generateUser'; import { generateChannel } from '../test-utils/generateChannel'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { ErrorFromResponse } from '../../../src'; +import { StreamAPIError, StreamChat } from '../../../src'; import { APIErrorCodes } from '../../../src/errors'; describe('SearchController', () => { @@ -229,14 +229,11 @@ describe('BaseSearchSource and implementations', () => { const results = [{ id: 'result' }]; beforeEach(() => { - mockClient = { - user: { id: 'current-user' }, - userID: 'current-user', - queryUsers: sinon.stub().resolves({ users }), - queryChannels: sinon.stub().resolves(channels), - search: sinon.stub().resolves({ results, next: null }), - activeChannels: {}, - }; + mockClient = new StreamChat(''); + mockClient.user = { id: 'current-user' }; + sinon.stub(mockClient, 'queryUsers').resolves({ users }); + sinon.stub(mockClient, 'queryChannels').resolves(channels); + sinon.stub(mockClient, 'search').resolves({ results, next: null }); }); describe('BaseSearchSource', () => { @@ -434,7 +431,7 @@ describe('BaseSearchSource and implementations', () => { }); vi.spyOn(searchSource, 'query').mockRejectedValue( - new ErrorFromResponse('anything', { + new StreamAPIError('anything', { code: APIErrorCodes[4], response: {}, status: 400, @@ -935,14 +932,16 @@ describe('BaseSearchSource and implementations', () => { userSource.activate(); await userSource.executeQuery('test'); - sinon.assert.calledWith( - mockClient.queryUsers, - { - $or: [{ id: { $autocomplete: 'test' } }, { name: { $autocomplete: 'test' } }], + sinon.assert.calledWith(mockClient.queryUsers, { + payload: { + filter_conditions: { + $or: [{ id: { $autocomplete: 'test' } }, { name: { $autocomplete: 'test' } }], + }, + sort: [{ field: 'id', direction: 1 }], + limit: 10, + offset: 0, }, - { id: 1 }, - { limit: 10, offset: 0 }, - ); + }); }); }); @@ -959,12 +958,14 @@ describe('BaseSearchSource and implementations', () => { sinon.assert.calledWith( mockClient.queryChannels, - { - members: { $in: ['current-user'] }, - name: { $autocomplete: 'test' }, - }, - {}, - { limit: 10, offset: 0 }, + sinon.match({ + filter_conditions: { + members: { $in: ['current-user'] }, + name: { $autocomplete: 'test' }, + }, + limit: 10, + offset: 0, + }), ); }); }); @@ -977,7 +978,7 @@ describe('BaseSearchSource and implementations', () => { }); it('returns empty results if no user ID', async () => { - mockClient.userID = null; + mockClient.user = undefined; messageSource.activate(); await messageSource.executeQuery('test'); expect(messageSource.items).to.be.empty; @@ -989,9 +990,13 @@ describe('BaseSearchSource and implementations', () => { sinon.assert.calledWith( mockClient.search, - { members: { $in: ['current-user'] } }, - { text: 'test', type: 'regular' }, - { limit: 10, next: undefined, sort: { created_at: -1 } }, + sinon.match({ + payload: { + filter_conditions: { members: { $in: ['current-user'] } }, + message_filter_conditions: { type: 'regular', text: 'test' }, + limit: 10, + }, + }), ); }); @@ -1006,8 +1011,9 @@ describe('BaseSearchSource and implementations', () => { sinon.assert.calledWith( mockClient.queryChannels, - { cid: { $in: ['missing-channel'] } }, - { last_message_at: -1 }, + sinon.match({ + filter_conditions: { cid: { $in: ['missing-channel'] } }, + }), ); }); diff --git a/test/unit/search/UserSearchSource.test.ts b/test/unit/search/UserSearchSource.test.ts index 17d6721f05..a5ee4a0815 100644 --- a/test/unit/search/UserSearchSource.test.ts +++ b/test/unit/search/UserSearchSource.test.ts @@ -160,61 +160,77 @@ describe('UserSearchSource', () => { searchQuery ? { name: { $autocomplete: searchQuery } } : null, }, }); - searchSource.sort = { created_at: -1 } as UserSort; + searchSource.sort = [{ field: 'created_at', direction: -1 }]; searchSource.searchOptions = { presence: true }; // @ts-expect-error accessing protected method await searchSource.query('John'); - expect(queryUsersMock).toHaveBeenCalledWith( - { - $or: [{ id: { $autocomplete: 'John' } }, { name: { $autocomplete: 'John' } }], - name: { $autocomplete: 'John' }, - role: { $eq: 'admin' }, + expect(queryUsersMock).toHaveBeenCalledWith({ + payload: { + filter_conditions: { + $or: [{ id: { $autocomplete: 'John' } }, { name: { $autocomplete: 'John' } }], + name: { $autocomplete: 'John' }, + role: { $eq: 'admin' }, + }, + sort: [ + { field: 'created_at', direction: -1 }, + { field: 'id', direction: 1 }, + ], + presence: true, + limit: searchSource.pageSize, + offset: searchSource.offset, }, - { id: 1, created_at: -1 }, - { presence: true, limit: searchSource.pageSize, offset: searchSource.offset }, - ); + }); }); it('appends a default id sort when sort is an array without an id key', async () => { - searchSource.sort = [{ created_at: -1 }] as UserSort; + searchSource.sort = [{ field: 'created_at', direction: -1 }]; // @ts-expect-error accessing protected method await searchSource.query('John'); - expect(queryUsersMock).toHaveBeenCalledWith( - expect.anything(), - [{ created_at: -1 }, { id: 1 }], - expect.anything(), - ); + expect(queryUsersMock).toHaveBeenCalledWith({ + payload: expect.objectContaining({ + sort: [ + { field: 'created_at', direction: -1 }, + { field: 'id', direction: 1 }, + ], + }), + }); }); it('leaves the sort array unchanged when it already contains an id key', async () => { - const sort = [{ id: -1 }, { created_at: -1 }] as UserSort; + const sort: UserSort = [ + { field: 'id', direction: -1 }, + { field: 'created_at', direction: -1 }, + ]; searchSource.sort = sort; // @ts-expect-error accessing protected method await searchSource.query('John'); - expect(queryUsersMock).toHaveBeenCalledWith( - expect.anything(), - [{ id: -1 }, { created_at: -1 }], - expect.anything(), - ); + expect(queryUsersMock).toHaveBeenCalledWith({ + payload: expect.objectContaining({ + sort: [ + { field: 'id', direction: -1 }, + { field: 'created_at', direction: -1 }, + ], + }), + }); }); it('uses only the default id sort when sort is an empty array', async () => { - searchSource.sort = [] as UserSort; + searchSource.sort = []; // @ts-expect-error accessing protected method await searchSource.query('John'); - expect(queryUsersMock).toHaveBeenCalledWith( - expect.anything(), - [{ id: 1 }], - expect.anything(), - ); + expect(queryUsersMock).toHaveBeenCalledWith({ + payload: expect.objectContaining({ + sort: [{ field: 'id', direction: 1 }], + }), + }); }); it('returns items from query', async () => { diff --git a/test/unit/team_usage_stats.test.ts b/test/unit/team_usage_stats.test.ts deleted file mode 100644 index 0fa53ac5e2..0000000000 --- a/test/unit/team_usage_stats.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { StreamChat } from '../../src/client'; -import type { - QueryTeamUsageStatsOptions, - QueryTeamUsageStatsResponse, -} from '../../src/types'; - -describe('Team Usage Stats', () => { - let client: StreamChat; - - beforeEach(() => { - client = new StreamChat('api_key', 'api_secret'); - }); - - describe('queryTeamUsageStats', () => { - it('should query team usage stats with default options', async () => { - const mockResponse: QueryTeamUsageStatsResponse = { - duration: '0.05s', - teams: [ - { - team: 'team-1', - users_daily: { total: 100 }, - messages_daily: { total: 500 }, - translations_daily: { total: 10 }, - image_moderations_daily: { total: 5 }, - concurrent_users: { total: 25 }, - concurrent_connections: { total: 30 }, - users_total: { total: 1000 }, - users_last_24_hours: { total: 50 }, - users_last_30_days: { total: 200 }, - users_month_to_date: { total: 150 }, - users_engaged_last_30_days: { total: 180 }, - users_engaged_month_to_date: { total: 120 }, - messages_total: { total: 50000 }, - messages_last_24_hours: { total: 250 }, - messages_last_30_days: { total: 5000 }, - messages_month_to_date: { total: 3500 }, - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const result = await client.queryTeamUsageStats(); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/stats/team_usage`, {}); - expect(result.teams).toHaveLength(1); - expect(result.teams[0].team).toBe('team-1'); - expect(result.teams[0].users_daily.total).toBe(100); - }); - - it('should query team usage stats with month option', async () => { - const mockResponse: QueryTeamUsageStatsResponse = { - duration: '0.05s', - teams: [ - { - team: 'team-1', - users_daily: { total: 100 }, - messages_daily: { total: 500 }, - translations_daily: { total: 10 }, - image_moderations_daily: { total: 5 }, - concurrent_users: { total: 25 }, - concurrent_connections: { total: 30 }, - users_total: { total: 1000 }, - users_last_24_hours: { total: 50 }, - users_last_30_days: { total: 200 }, - users_month_to_date: { total: 150 }, - users_engaged_last_30_days: { total: 180 }, - users_engaged_month_to_date: { total: 120 }, - messages_total: { total: 50000 }, - messages_last_24_hours: { total: 250 }, - messages_last_30_days: { total: 5000 }, - messages_month_to_date: { total: 3500 }, - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: QueryTeamUsageStatsOptions = { - month: '2026-01', - }; - - const result = await client.queryTeamUsageStats(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/stats/team_usage`, { - month: '2026-01', - }); - expect(result.teams).toHaveLength(1); - }); - - it('should query team usage stats with date range for daily breakdown', async () => { - const mockResponse: QueryTeamUsageStatsResponse = { - duration: '0.05s', - teams: [ - { - team: 'team-1', - users_daily: { - daily: [ - { date: '2026-01-03', value: 35 }, - { date: '2026-01-02', value: 32 }, - { date: '2026-01-01', value: 30 }, - ], - total: 97, - }, - messages_daily: { - daily: [ - { date: '2026-01-03', value: 180 }, - { date: '2026-01-02', value: 170 }, - { date: '2026-01-01', value: 150 }, - ], - total: 500, - }, - translations_daily: { total: 10 }, - image_moderations_daily: { total: 5 }, - concurrent_users: { total: 25 }, - concurrent_connections: { total: 30 }, - users_total: { total: 1000 }, - users_last_24_hours: { total: 50 }, - users_last_30_days: { total: 200 }, - users_month_to_date: { total: 150 }, - users_engaged_last_30_days: { total: 180 }, - users_engaged_month_to_date: { total: 120 }, - messages_total: { total: 50000 }, - messages_last_24_hours: { total: 250 }, - messages_last_30_days: { total: 5000 }, - messages_month_to_date: { total: 3500 }, - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: QueryTeamUsageStatsOptions = { - start_date: '2026-01-01', - end_date: '2026-01-03', - }; - - const result = await client.queryTeamUsageStats(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/stats/team_usage`, { - start_date: '2026-01-01', - end_date: '2026-01-03', - }); - expect(result.teams).toHaveLength(1); - expect(result.teams[0].users_daily.daily).toHaveLength(3); - expect(result.teams[0].users_daily.daily![0].date).toBe('2026-01-03'); - expect(result.teams[0].users_daily.daily![0].value).toBe(35); - }); - - it('should query team usage stats with pagination options', async () => { - const mockResponse: QueryTeamUsageStatsResponse = { - duration: '0.05s', - teams: [ - { - team: 'team-2', - users_daily: { total: 50 }, - messages_daily: { total: 250 }, - translations_daily: { total: 5 }, - image_moderations_daily: { total: 2 }, - concurrent_users: { total: 10 }, - concurrent_connections: { total: 15 }, - users_total: { total: 500 }, - users_last_24_hours: { total: 25 }, - users_last_30_days: { total: 100 }, - users_month_to_date: { total: 75 }, - users_engaged_last_30_days: { total: 90 }, - users_engaged_month_to_date: { total: 60 }, - messages_total: { total: 25000 }, - messages_last_24_hours: { total: 125 }, - messages_last_30_days: { total: 2500 }, - messages_month_to_date: { total: 1750 }, - }, - ], - next: 'next_cursor_value', - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: QueryTeamUsageStatsOptions = { - limit: 10, - next: 'cursor_value', - }; - - const result = await client.queryTeamUsageStats(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/stats/team_usage`, { - limit: 10, - next: 'cursor_value', - }); - expect(result.teams).toHaveLength(1); - expect(result.next).toBe('next_cursor_value'); - }); - - it('should handle multiple teams in response', async () => { - const mockResponse: QueryTeamUsageStatsResponse = { - duration: '0.05s', - teams: [ - { - team: 'team-1', - users_daily: { total: 100 }, - messages_daily: { total: 500 }, - translations_daily: { total: 10 }, - image_moderations_daily: { total: 5 }, - concurrent_users: { total: 25 }, - concurrent_connections: { total: 30 }, - users_total: { total: 1000 }, - users_last_24_hours: { total: 50 }, - users_last_30_days: { total: 200 }, - users_month_to_date: { total: 150 }, - users_engaged_last_30_days: { total: 180 }, - users_engaged_month_to_date: { total: 120 }, - messages_total: { total: 50000 }, - messages_last_24_hours: { total: 250 }, - messages_last_30_days: { total: 5000 }, - messages_month_to_date: { total: 3500 }, - }, - { - team: 'team-2', - users_daily: { total: 50 }, - messages_daily: { total: 250 }, - translations_daily: { total: 5 }, - image_moderations_daily: { total: 2 }, - concurrent_users: { total: 10 }, - concurrent_connections: { total: 15 }, - users_total: { total: 500 }, - users_last_24_hours: { total: 25 }, - users_last_30_days: { total: 100 }, - users_month_to_date: { total: 75 }, - users_engaged_last_30_days: { total: 90 }, - users_engaged_month_to_date: { total: 60 }, - messages_total: { total: 25000 }, - messages_last_24_hours: { total: 125 }, - messages_last_30_days: { total: 2500 }, - messages_month_to_date: { total: 1750 }, - }, - { - team: '', // Users not assigned to any team - users_daily: { total: 20 }, - messages_daily: { total: 100 }, - translations_daily: { total: 1 }, - image_moderations_daily: { total: 0 }, - concurrent_users: { total: 5 }, - concurrent_connections: { total: 5 }, - users_total: { total: 100 }, - users_last_24_hours: { total: 10 }, - users_last_30_days: { total: 40 }, - users_month_to_date: { total: 30 }, - users_engaged_last_30_days: { total: 35 }, - users_engaged_month_to_date: { total: 25 }, - messages_total: { total: 5000 }, - messages_last_24_hours: { total: 50 }, - messages_last_30_days: { total: 500 }, - messages_month_to_date: { total: 350 }, - }, - ], - next: 'next_page_cursor', - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const result = await client.queryTeamUsageStats({ limit: 30 }); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/stats/team_usage`, { - limit: 30, - }); - expect(result.teams).toHaveLength(3); - expect(result.teams[0].team).toBe('team-1'); - expect(result.teams[1].team).toBe('team-2'); - expect(result.teams[2].team).toBe(''); // Empty string for unassigned users - expect(result.next).toBe('next_page_cursor'); - }); - - it('should handle empty teams response', async () => { - const mockResponse: QueryTeamUsageStatsResponse = { - duration: '0.01s', - teams: [], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const result = await client.queryTeamUsageStats({ month: '2020-01' }); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/stats/team_usage`, { - month: '2020-01', - }); - expect(result.teams).toHaveLength(0); - expect(result.next).toBeUndefined(); - }); - - it('should throw error if called without server-side auth', async () => { - const clientWithoutSecret = new StreamChat('api_key'); - clientWithoutSecret.user = { id: 'test-user' }; - - await expect(clientWithoutSecret.queryTeamUsageStats()).rejects.toThrow(); - }); - }); -}); diff --git a/test/unit/test-utils/generateChannel.ts b/test/unit/test-utils/generateChannel.ts index 4d8449fbb7..f7f2fce676 100644 --- a/test/unit/test-utils/generateChannel.ts +++ b/test/unit/test-utils/generateChannel.ts @@ -1,14 +1,18 @@ import { generateUUIDv4 as uuidv4 } from '../../../src/utils'; -import { ChannelAPIResponse, ChannelConfigWithInfo, ChannelResponse } from '../../../src'; +import { + ChannelStateResponseFields, + ChannelConfigWithInfo, + ChannelResponse, +} from '../../../src'; export const generateChannel = ( options: Partial< - Omit & { + Omit & { channel?: Partial; config?: ChannelConfigWithInfo; } > = { channel: {} }, -): ChannelAPIResponse => { +): ChannelStateResponseFields => { const { channel: optionsChannel, config, ...optionsBesidesChannel } = options; const idFromOptions = optionsChannel && optionsChannel.id; const type = (optionsChannel && optionsChannel.type) || 'messaging'; @@ -26,22 +30,22 @@ export const generateChannel = ( id, type, cid: `${type}:${id}`, - created_at: '2020-04-28T11:20:48.578147Z', - updated_at: '2020-04-28T11:20:48.578147Z', + created_at: new Date('2020-04-28T11:20:48.578147Z'), + updated_at: new Date('2020-04-28T11:20:48.578147Z'), created_by: { id: 'vishal', role: 'user', - created_at: '2020-04-27T13:05:13.847572Z', - updated_at: '2020-04-28T11:21:08.357468Z', - last_active: '2020-04-28T11:21:08.353026Z', + created_at: new Date('2020-04-27T13:05:13.847572Z'), + updated_at: new Date('2020-04-28T11:21:08.357468Z'), + last_active: new Date('2020-04-28T11:21:08.353026Z'), banned: false, online: false, }, frozen: false, disabled: false, config: { - created_at: '2020-04-24T11:36:43.859020368Z', - updated_at: '2020-04-24T11:36:43.859022903Z', + created_at: new Date('2020-04-24T11:36:43.859020368Z'), + updated_at: new Date('2020-04-24T11:36:43.859022903Z'), name: 'messaging', typing_events: true, read_events: true, diff --git a/test/unit/test-utils/generateMessage.ts b/test/unit/test-utils/generateMessage.ts index 6834e01830..7d61af48ce 100644 --- a/test/unit/test-utils/generateMessage.ts +++ b/test/unit/test-utils/generateMessage.ts @@ -1,20 +1,20 @@ import { generateUUIDv4 as uuidv4 } from '../../../src/utils'; -import type { MessageResponse } from '../../../src'; +import type { MessageResponse, UserResponse } from '../../../src'; export const generateMsg = ( - msg: Partial & { date?: string } = {}, + msg: Partial & { date?: Date } = {}, ): MessageResponse => { - const date = msg?.date || new Date().toISOString(); + const date = msg?.date ?? new Date(); return { id: uuidv4(), text: uuidv4(), html: '

x

\n', type: 'regular', - user: { id: 'id' }, + user: { id: 'id' } as UserResponse, attachments: [], latest_reactions: [], own_reactions: [], - reaction_counts: null, + reaction_counts: {}, reaction_scores: {}, reply_count: 0, created_at: date, diff --git a/test/unit/test-utils/generateMessageDraft.ts b/test/unit/test-utils/generateMessageDraft.ts index 8e0612485b..57151ea125 100644 --- a/test/unit/test-utils/generateMessageDraft.ts +++ b/test/unit/test-utils/generateMessageDraft.ts @@ -12,7 +12,7 @@ export const generateMessageDraft = ({ return { channel, channel_cid: channel.cid, - created_at: new Date().toISOString(), + created_at: new Date(), message: generateMsg(), ...customMsgDraft, } as DraftResponse; diff --git a/test/unit/test-utils/generatePendingTask.js b/test/unit/test-utils/generatePendingTask.js index 2a78e46624..c8eb86aecd 100644 --- a/test/unit/test-utils/generatePendingTask.js +++ b/test/unit/test-utils/generatePendingTask.js @@ -15,26 +15,27 @@ export const generatePendingTask = (type, id = 1, options = {}, payloadOptions = export const generatePendingTaskPayload = (type, options = {}) => { if (type === 'send-reaction') { const messageId = options.messageId ?? '123'; - const reaction = options.reaction ?? { type: 'wow', message_id: messageId }; - return { type, payload: [messageId, reaction] }; + const reaction = options.reaction ?? { type: 'wow' }; + return { type, payload: [{ id: messageId, reaction }] }; } if (type === 'delete-reaction') { const messageId = options.messageId ?? '123'; const reactionType = options.reactionType ?? 'wow'; - return { type, payload: [messageId, reactionType] }; + return { type, payload: [{ id: messageId, type: reactionType }] }; } if (type === 'delete-message') { const messageId = options.messageId ?? '123'; - return { type, payload: [messageId] }; + return { type, payload: [{ id: messageId }] }; } if (type === 'update-message') { const message = options.message ?? generateMsg({ id: options.messageId ?? '123' }); - return { type, payload: [message, options.user, options.updateOptions] }; + const request = { id: message.id, message, ...(options.updateOptions ?? {}) }; + return { type, payload: [request] }; } const message = options.message ?? generateMsg(); - return { type, payload: [message] }; + return { type, payload: [{ message }] }; }; diff --git a/test/unit/test-utils/generateReadResponse.js b/test/unit/test-utils/generateReadResponse.js index 352ff87b31..92cdc7ee1d 100644 --- a/test/unit/test-utils/generateReadResponse.js +++ b/test/unit/test-utils/generateReadResponse.js @@ -3,7 +3,7 @@ import { generateUser } from './generateUser'; export const generateReadResponse = (options = {}) => { const userResponse = options.user ?? generateUser(); return { - last_read: new Date().toISOString(), + last_read: new Date(), user: userResponse, last_read_message_id: '123321', unread_messages: 0, diff --git a/test/unit/test-utils/generateThreadResponse.js b/test/unit/test-utils/generateThreadResponse.js index 2866f6542c..108a16bad9 100644 --- a/test/unit/test-utils/generateThreadResponse.js +++ b/test/unit/test-utils/generateThreadResponse.js @@ -4,10 +4,10 @@ export const generateThreadResponse = (channel, parent, opts = {}) => { parent_message: parent, channel, title: 'title', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + created_at: new Date(), + updated_at: new Date(), channel_cid: channel.cid, - last_message_at: new Date().toISOString(), + last_message_at: new Date(), deleted_at: undefined, read: [], reply_count: 0, diff --git a/test/unit/test-utils/generateUser.js b/test/unit/test-utils/generateUser.js index 01843d4f54..bbaaa9c783 100644 --- a/test/unit/test-utils/generateUser.js +++ b/test/unit/test-utils/generateUser.js @@ -6,8 +6,8 @@ export const generateUser = (options = {}) => { name: uuidv4(), image: uuidv4(), role: 'user', - created_at: '2020-04-27T13:39:49.331742Z', - updated_at: '2020-04-27T13:39:49.332087Z', + created_at: new Date('2020-04-27T13:39:49.331742Z'), + updated_at: new Date('2020-04-27T13:39:49.332087Z'), banned: false, online: false, ...options, diff --git a/test/unit/test-utils/getClient.js b/test/unit/test-utils/getClient.js index f78871b2d6..462e72d3e3 100644 --- a/test/unit/test-utils/getClient.js +++ b/test/unit/test-utils/getClient.js @@ -3,12 +3,12 @@ import { generateUUIDv4 as uuidv4 } from '../../../src/utils'; export const getClientWithUser = (user) => { const chatClient = new StreamChat(''); - + chatClient.tokenManager.getToken = () => 'mock-token'; const clientUser = user || { id: uuidv4() }; chatClient.connectUser = () => { chatClient.user = clientUser; - chatClient.userID = clientUser.id; + chatClient.wsPromise = Promise.resolve(); // sending a promise, since connectUser in actual SDK is an async function. diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index bda52ca123..6d53670b7f 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -12,12 +12,11 @@ import { StreamChat, Thread, ThreadManager, - ThreadResponse, + ThreadStateResponse, THREAD_MANAGER_INITIAL_STATE, ThreadFilters, ThreadSort, } from '../../src'; -import { THREAD_RESPONSE_RESERVED_KEYS } from '../../src/thread'; import { describe, it, beforeEach, expect, afterEach } from 'vitest'; @@ -34,7 +33,7 @@ describe('Threads 2.0', () => { channelOverrides = {}, parentMessageOverrides = {}, ...overrides - }: Partial & { + }: Partial & { channelOverrides?: Partial; parentMessageOverrides?: Partial; } = {}) { @@ -86,7 +85,7 @@ describe('Threads 2.0', () => { client = new StreamChat('apiKey'); client._setUser({ id: TEST_USER_ID }); channelResponse = generateChannel({ - channel: { id: uuidv4(), name: 'Test channel', members: [] }, + channel: { id: uuidv4(), members: [], custom: { name: 'Test channel' } }, }).channel as ChannelResponse; channel = client.channel(channelResponse.type, channelResponse.id); channel.initialized = true; @@ -264,13 +263,13 @@ describe('Threads 2.0', () => { it('updates optimistically added message', () => { const optimisticMessage = makeReply({ text: 'aaa', - created_at: '2020-01-01T00:00:00Z', - }); + created_at: new Date('2020-01-01T00:00:00Z'), + }) as MessageResponse; const message = makeReply({ text: 'bbb', - created_at: '2020-01-01T00:00:10Z', - }); + created_at: new Date('2020-01-01T00:00:10Z'), + }) as MessageResponse; const thread = createTestThread({ latest_replies: [optimisticMessage, message], @@ -279,7 +278,7 @@ describe('Threads 2.0', () => { const updatedMessage: MessageResponse = { ...optimisticMessage, text: 'ccc', - created_at: '2020-01-01T00:00:20Z', + created_at: new Date('2020-01-01T00:00:20Z'), }; const repliesBefore = repliesOf(thread); @@ -318,7 +317,7 @@ describe('Threads 2.0', () => { { id: 'participant-1' }, ] as unknown as ThreadResponse['thread_participants']; const updatedMessage = generateMsg({ - deleted_at: new Date().toISOString(), + deleted_at: new Date(), id: parentMessageResponse.id, reply_count: 10, text: 'aaa', @@ -329,7 +328,9 @@ describe('Threads 2.0', () => { const stateAfter = thread.state.getLatestValue(); expect(stateAfter.deletedAt).to.be.not.null; - expect(stateAfter.deletedAt!.toISOString()).to.equal(updatedMessage.deleted_at); + expect(stateAfter.deletedAt!.toISOString()).to.equal( + updatedMessage.deleted_at!.toISOString(), + ); expect(stateAfter.replyCount).to.equal(updatedMessage.reply_count); expect(stateAfter.participants).to.have.lengthOf(1); expect(stateAfter.participants?.[0].user_id).to.equal('participant-1'); @@ -472,14 +473,16 @@ describe('Threads 2.0', () => { describe('reload', () => { it('sizes getThread reply_limit to the loaded reply count, falling back to pageSize when unloaded', async () => { - const stub = sinon.stub(client, 'getThread').resolves(createTestThread()); + const stub = sinon.stub(client, 'getThread').resolves({ + thread: generateThreadResponse(channelResponse, parentMessageResponse), + }); // Unloaded (minimal) thread → falls back to pageSize. const minimalThread = createMinimalThread(); expect(minimalThread.messagePaginator.state.getLatestValue().items).to.be .undefined; await minimalThread.reload(); - expect(stub.firstCall.args[1]?.reply_limit).to.equal( + expect(stub.firstCall.args[0]?.reply_limit).to.equal( minimalThread.messagePaginator.pageSize, ); @@ -494,7 +497,7 @@ describe('Threads 2.0', () => { reply_count: 20, }); await loadedThread.reload(); - expect(stub.secondCall.args[1]?.reply_limit).to.equal(7); + expect(stub.secondCall.args[0]?.reply_limit).to.equal(7); expect(loadedThread.messagePaginator.pageSize).to.not.equal(7); }); }); @@ -507,7 +510,7 @@ describe('Threads 2.0', () => { { length: 5 }, (_, i) => generateMsg({ - created_at: new Date(createdAt + 1000 * i).toISOString(), + created_at: new Date(createdAt + 1000 * i), }) as MessageResponse, ); const thread = createTestThread({ latest_replies: messages }); @@ -532,12 +535,12 @@ describe('Threads 2.0', () => { describe('markAsRead', () => { let stubbedChannelMarkRead: sinon.SinonStub< - Parameters, - ReturnType + Parameters, + ReturnType >; beforeEach(() => { - stubbedChannelMarkRead = sinon.stub(channel, 'markAsReadRequest').resolves(); + stubbedChannelMarkRead = sinon.stub(channel, 'markRead').resolves(); }); it('does nothing if unread count of the current user is zero', async () => { @@ -593,7 +596,7 @@ describe('Threads 2.0', () => { expect(repliesOf(thread).map((reply) => reply.id)).to.include(older.id); // ...and the request was made against this thread's parent (the replies endpoint). expect(getRepliesStub.calledOnce).to.be.true; - expect(getRepliesStub.firstCall.args[0]).to.equal(thread.id); + expect(getRepliesStub.firstCall.args[0].parent_id).to.equal(thread.id); }); it('clears hasMoreTail once toTail() reaches the start of the reply list', async () => { @@ -670,7 +673,7 @@ describe('Threads 2.0', () => { thread.registerSubscriptions(); const reloadedReply = makeReply({ created_at: '2020-03-01T00:00:01.000Z' }); - const stubbedGetThread = sinon.stub(client, 'getThread').resolves( + const stubbedGetThread = sinon.stub(client, 'getThreadAndHydrate').resolves( createTestThread({ latest_replies: [initialReply, reloadedReply], reply_count: 2, @@ -736,14 +739,13 @@ describe('Threads 2.0', () => { const customKey1 = uuidv4(); const customKey2 = uuidv4(); - const thread = createTestThread({ [customKey1]: 1, [customKey2]: { key: 1 } }); + const thread = createTestThread({ + custom: { [customKey1]: 1, [customKey2]: { key: 1 } }, + }); thread.registerSubscriptions(); const stateBefore = thread.state.getLatestValue(); - expect(stateBefore.custom).to.not.have.keys( - Object.keys(THREAD_RESPONSE_RESERVED_KEYS), - ); expect(stateBefore.custom).to.have.keys([customKey1, customKey2]); expect(stateBefore.custom[customKey1]).to.equal(1); @@ -753,16 +755,13 @@ describe('Threads 2.0', () => { channelResponse, generateMsg({ id: parentMessageResponse.id }), { - [customKey1]: 2, + custom: { [customKey1]: 2 }, }, ), }); const stateAfter = thread.state.getLatestValue(); - expect(stateAfter.custom).to.not.have.keys( - Object.keys(THREAD_RESPONSE_RESERVED_KEYS), - ); expect(stateAfter.custom).to.not.have.property(customKey2); expect(stateAfter.custom[customKey1]).to.equal(2); }); @@ -798,6 +797,7 @@ describe('Threads 2.0', () => { client.dispatchEvent({ type: 'user.watching.stop', + cid: channelResponse.cid, channel: channelResponse, user: { id: TEST_USER_ID }, }); @@ -830,7 +830,7 @@ describe('Threads 2.0', () => { thread: generateThreadResponse( channelResponse, generateMsg(), - ) as ThreadResponse, + ) as ThreadStateResponse, }); const stateAfter = thread.state.getLatestValue(); @@ -861,7 +861,7 @@ describe('Threads 2.0', () => { thread: generateThreadResponse( channelResponse, generateMsg({ id: parentMessageResponse.id }), - ) as ThreadResponse, + ) as ThreadStateResponse, created_at: createdAt.toISOString(), }); @@ -1200,7 +1200,7 @@ describe('Threads 2.0', () => { (_, i) => generateMsg({ parent_id: parentMessageResponse.id, - created_at: new Date(createdAt + 1000 * i).toISOString(), + created_at: new Date(createdAt + 1000 * i), }) as MessageResponse, ); const thread = createTestThread({ latest_replies: messages }); @@ -1241,7 +1241,7 @@ describe('Threads 2.0', () => { message: { ...messageToDelete, type: 'deleted', - deleted_at: deletedAt.toISOString(), + deleted_at: deletedAt, }, }); @@ -1265,7 +1265,7 @@ describe('Threads 2.0', () => { const parentMessage = generateMsg({ id: thread.id, - deleted_at: new Date().toISOString(), + deleted_at: new Date(), type: 'deleted', }) as MessageResponse; @@ -1277,10 +1277,12 @@ describe('Threads 2.0', () => { const stateAfter = thread.state.getLatestValue(); expect(stateAfter.deletedAt).to.be.a('date'); - expect(stateAfter.deletedAt!.toISOString()).to.equal(parentMessage.deleted_at); + expect(stateAfter.deletedAt!.toISOString()).to.equal( + parentMessage.deleted_at!.toISOString(), + ); expect(stateAfter.parentMessage.deleted_at).to.be.a('date'); expect(stateAfter.parentMessage.deleted_at!.toISOString()).to.equal( - parentMessage.deleted_at, + parentMessage.deleted_at!.toISOString(), ); }); @@ -1756,9 +1758,12 @@ describe('Threads 2.0', () => { }); }); - it('reloads after connection drop', () => { + it('reloads after connection drop if the thread list was activated at least once', () => { const thread = createTestThread(); - threadManager.state.partialNext({ threads: [thread] }); + threadManager.state.partialNext({ + threads: [thread], + wasActivatedAtLeastOnce: true, + }); threadManager.registerSubscriptions(); const stub = sinon.stub(client, 'queryThreads').resolves({ threads: [], @@ -1783,6 +1788,33 @@ describe('Threads 2.0', () => { clock.restore(); }); + it('does not reload after connection drop if the thread list was never activated', () => { + const thread = createTestThread(); + threadManager.state.partialNext({ threads: [thread] }); + threadManager.registerSubscriptions(); + const stub = sinon.stub(client, 'queryThreadsAndHydrate').resolves({ + threads: [], + next: undefined, + }); + const clock = sinon.useFakeTimers(); + + client.dispatchEvent({ + type: 'connection.changed', + online: false, + }); + + const { lastConnectionDropAt } = threadManager.state.getLatestValue(); + expect(lastConnectionDropAt).to.be.a('date'); + + client.dispatchEvent({ type: 'connection.recovered' }); + clock.runAll(); + + expect(stub.called).to.be.false; + + threadManager.unregisterSubscriptions(); + clock.restore(); + }); + it('reloads list on activation', () => { const stub = sinon.stub(threadManager, 'reload').resolves(); threadManager.activate(); @@ -1832,7 +1864,7 @@ describe('Threads 2.0', () => { >; beforeEach(() => { - stubbedQueryThreads = sinon.stub(client, 'queryThreads').resolves({ + stubbedQueryThreads = sinon.stub(client, 'queryThreadsAndHydrate').resolves({ threads: [], next: undefined, }); @@ -1968,7 +2000,7 @@ describe('Threads 2.0', () => { const newThread = createTestThread({ thread_participants: [ { user_id: 'u1' }, - ] as ThreadResponse['thread_participants'], + ] as ThreadStateResponse['thread_participants'], }); threadManager.state.partialNext({ threads: [existingThread], @@ -2156,7 +2188,10 @@ describe('Threads 2.0', () => { }); it('applies sort parameters correctly', async () => { - const sort: ThreadSort = [{ created_at: -1 }, { last_message_at: 1 }]; + const sort: ThreadSort = [ + { field: 'created_at', direction: -1 }, + { field: 'last_message_at', direction: 1 }, + ]; await threadManager.queryThreads({ sort }); @@ -2176,7 +2211,7 @@ describe('Threads 2.0', () => { created_by_user_id: { $eq: 'user1' }, updated_at: { $gte: '2024-01-01T00:00:00Z' }, }; - const sort: ThreadSort = [{ last_message_at: -1 }]; + const sort: ThreadSort = [{ field: 'last_message_at', direction: -1 }]; await threadManager.queryThreads({ filter, sort }); diff --git a/test/unit/user_groups.test.ts b/test/unit/user_groups.test.ts deleted file mode 100644 index ccba4a00e7..0000000000 --- a/test/unit/user_groups.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { StreamChat } from '../../src/client'; -import type { - AddUserGroupMembersOptions, - AddUserGroupMembersResponse, - APIResponse, - CreateUserGroupOptions, - CreateUserGroupResponse, - DeleteUserGroupOptions, - GetUserGroupOptions, - GetUserGroupResponse, - QueryUserGroupsOptions, - QueryUserGroupsResponse, - RemoveUserGroupMembersOptions, - RemoveUserGroupMembersResponse, - SearchUserGroupsOptions, - SearchUserGroupsResponse, - UpdateUserGroupOptions, - UpdateUserGroupResponse, - UserGroupResponse, -} from '../../src/types'; - -const createUserGroup = ( - overrides: Partial = {}, -): UserGroupResponse => ({ - id: 'group-1', - name: 'Backend Support', - created_at: '2026-01-01T00:00:00.000000000Z', - updated_at: '2026-01-01T00:00:00.000000000Z', - ...overrides, -}); - -describe('User Groups', () => { - let client: StreamChat; - - beforeEach(() => { - client = new StreamChat('api_key'); - }); - - describe('queryUserGroups', () => { - it('should query user groups with cursor options', async () => { - const mockResponse: QueryUserGroupsResponse = { - duration: '0.01s', - user_groups: [createUserGroup()], - }; - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - const options: QueryUserGroupsOptions = { - limit: 10, - id_gt: 'group-0', - created_at_gt: '2025-12-31T23:59:59.000000000Z', - team_id: 'engineering', - }; - - const result = await client.queryUserGroups(options); - - expect(getSpy).toHaveBeenCalledWith(`${client.baseURL}/usergroups`, options); - expect(result.user_groups).toHaveLength(1); - expect(result.user_groups[0].id).toBe('group-1'); - }); - }); - - describe('createUserGroup', () => { - it('should create a user group', async () => { - const mockResponse: CreateUserGroupResponse = { - duration: '0.01s', - user_group: createUserGroup(), - }; - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - const options: CreateUserGroupOptions = { - id: 'backend-support', - name: 'Backend Support', - description: 'On-call backend engineers', - team_id: 'engineering', - member_ids: ['tom', 'sara'], - }; - - const result = await client.createUserGroup(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/usergroups`, options); - expect(result.user_group.id).toBe('group-1'); - }); - }); - - describe('getUserGroup', () => { - it('should get a user group by id', async () => { - const mockResponse: GetUserGroupResponse = { - duration: '0.01s', - user_group: createUserGroup(), - }; - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - const options: GetUserGroupOptions = { - team_id: 'engineering', - }; - - const result = await client.getUserGroup('backend-support', options); - - expect(getSpy).toHaveBeenCalledWith( - `${client.baseURL}/usergroups/backend-support`, - options, - ); - expect(result.user_group.name).toBe('Backend Support'); - }); - }); - - describe('searchUserGroups', () => { - it('should search user groups with prefix cursor options', async () => { - const mockResponse: SearchUserGroupsResponse = { - duration: '0.01s', - user_groups: [createUserGroup()], - }; - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - const options: SearchUserGroupsOptions = { - query: 'backend', - limit: 5, - name_gt: 'Backend Ops', - id_gt: 'group-0', - team_id: 'engineering', - }; - - const result = await client.searchUserGroups(options); - - expect(getSpy).toHaveBeenCalledWith(`${client.baseURL}/usergroups/search`, options); - expect(result.user_groups).toHaveLength(1); - expect(result.user_groups[0].name).toBe('Backend Support'); - }); - }); - - describe('updateUserGroup', () => { - it('should update a user group', async () => { - const mockResponse: UpdateUserGroupResponse = { - duration: '0.01s', - user_group: createUserGroup({ description: 'Updated description' }), - }; - const putSpy = vi.spyOn(client, 'put').mockResolvedValue(mockResponse); - const options: UpdateUserGroupOptions = { - description: 'Updated description', - name: 'Backend Support', - team_id: 'engineering', - }; - - const result = await client.updateUserGroup('backend-support', options); - - expect(putSpy).toHaveBeenCalledWith( - `${client.baseURL}/usergroups/backend-support`, - options, - ); - expect(result.user_group.description).toBe('Updated description'); - }); - }); - - describe('deleteUserGroup', () => { - it('should delete a user group', async () => { - const mockResponse: APIResponse = { - duration: '0.01s', - }; - const deleteSpy = vi.spyOn(client, 'delete').mockResolvedValue(mockResponse); - const options: DeleteUserGroupOptions = { - team_id: 'engineering', - }; - - const result = await client.deleteUserGroup('backend-support', options); - - expect(deleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/usergroups/backend-support`, - options, - ); - expect(result.duration).toBe('0.01s'); - }); - }); - - describe('addUserGroupMembers', () => { - it('should add members to a user group', async () => { - const mockResponse: AddUserGroupMembersResponse = { - duration: '0.01s', - user_group: createUserGroup(), - }; - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - const options: AddUserGroupMembersOptions = { - member_ids: ['tom', 'sara'], - as_admin: true, - team_id: 'engineering', - }; - - const result = await client.addUserGroupMembers('backend-support', options); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/usergroups/backend-support/members`, - options, - ); - expect(result.user_group.id).toBe('group-1'); - }); - }); - - describe('removeUserGroupMembers', () => { - it('should remove members from a user group', async () => { - const mockResponse: RemoveUserGroupMembersResponse = { - duration: '0.01s', - user_group: createUserGroup(), - }; - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - const options: RemoveUserGroupMembersOptions = { - member_ids: ['tom', 'sara'], - team_id: 'engineering', - }; - - const result = await client.removeUserGroupMembers('backend-support', options); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/usergroups/backend-support/members/delete`, - options, - ); - expect(result.user_group.id).toBe('group-1'); - }); - }); -}); diff --git a/test/unit/utils.test.js b/test/unit/utils.test.js index 52fb78056c..64abfed9f5 100644 --- a/test/unit/utils.test.js +++ b/test/unit/utils.test.js @@ -24,56 +24,6 @@ describe.skip('generateUUIDv4', () => { // }); }); -describe('test if sort is deterministic', () => { - it('test sort object', () => { - let sort = normalizeQuerySort({ - created_at: 1, - has_unread: -1, - }); - expect(sort).to.have.length(2); - expect(sort[0].field).to.be.equal('created_at'); - expect(sort[0].direction).to.be.equal(1); - expect(sort[1].field).to.be.equal('has_unread'); - expect(sort[1].direction).to.be.equal(-1); - sort = normalizeQuerySort({ - has_unread: -1, - created_at: 1, - }); - expect(sort[0].field).to.be.equal('has_unread'); - expect(sort[0].direction).to.be.equal(-1); - expect(sort[1].field).to.be.equal('created_at'); - expect(sort[1].direction).to.be.equal(1); - }); - it('test sort array', () => { - let sort = normalizeQuerySort([{ created_at: 1 }, { has_unread: -1 }]); - expect(sort).to.have.length(2); - expect(sort[0].field).to.be.equal('created_at'); - expect(sort[0].direction).to.be.equal(1); - expect(sort[1].field).to.be.equal('has_unread'); - expect(sort[1].direction).to.be.equal(-1); - sort = normalizeQuerySort([{ has_unread: -1 }, { created_at: 1 }]); - expect(sort[0].field).to.be.equal('has_unread'); - expect(sort[0].direction).to.be.equal(-1); - expect(sort[1].field).to.be.equal('created_at'); - expect(sort[1].direction).to.be.equal(1); - }); - it('test sort array with multi-field objects', () => { - let sort = normalizeQuerySort([ - { created_at: 1, has_unread: -1 }, - { last_active: 1, deleted_at: -1 }, - ]); - expect(sort).to.have.length(4); - expect(sort[0].field).to.be.equal('created_at'); - expect(sort[0].direction).to.be.equal(1); - expect(sort[1].field).to.be.equal('has_unread'); - expect(sort[1].direction).to.be.equal(-1); - expect(sort[2].field).to.be.equal('last_active'); - expect(sort[2].direction).to.be.equal(1); - expect(sort[3].field).to.be.equal('deleted_at'); - expect(sort[3].direction).to.be.equal(-1); - }); -}); - describe('axiosParamsSerializer', () => { const testCases = [ { @@ -130,7 +80,7 @@ describe('reaction groups fallback', () => { reaction_scores: scores, }); - expect(message.reaction_groups).to.deep.equal({ + expect(message.reaction_groups).toMatchObject({ love: { count: 1, sum_scores: 1, diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index a5f3b854d0..f0f5f84726 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -28,8 +28,9 @@ import { sleep, } from '../../src/utils'; -import type { ChannelFilters, ChannelSortBase, MessageResponse } from '../../src'; +import type { ChannelFilters, ChannelOwnCapability, ChannelSort } from '../../src'; import { StreamChat, Channel } from '../../src'; +import { chatLoggerSystem } from '../../src/logger'; describe('findIndexInSortedArray', () => { it('finds index in the middle of haystack (asc)', () => { @@ -224,10 +225,9 @@ describe('getAndWatchChannel', () => { ...Array.from({ length: 2 }, () => generateChannel()), generateChannel({ channel: { type: 'messaging' }, members: mockedMembers }), ]; - const mock = sandbox.mock(client); - mock - .expects('post') - .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); + sandbox + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelsQueryResponse }); }); afterEach(() => { @@ -235,14 +235,14 @@ describe('getAndWatchChannel', () => { }); it('should throw an error if neither channel nor type is provided', async () => { - await client.queryChannels({}); + await client.queryChannelsAndHydrate({}); await expect( getAndWatchChannel({ client, id: 'test-id', members: [] }), ).rejects.toThrow('Channel or channel type have to be provided to query a channel.'); }); it('should throw an error if neither channel ID nor members array is provided', async () => { - await client.queryChannels({}); + await client.queryChannelsAndHydrate({}); await expect( getAndWatchChannel({ client, type: 'test-type', id: undefined, members: [] }), ).rejects.toThrow( @@ -251,7 +251,7 @@ describe('getAndWatchChannel', () => { }); it('should return an existing channel if provided', async () => { - const channels = await client.queryChannels({}); + const channels = await client.queryChannelsAndHydrate({}); const channel = channels[0]; const watchStub = sandbox.stub(channel, 'watch'); const result = await getAndWatchChannel({ @@ -266,7 +266,7 @@ describe('getAndWatchChannel', () => { }); it('should return the channel if only type and id are provided', async () => { - const channels = await client.queryChannels({}); + const channels = await client.queryChannelsAndHydrate({}); const channel = channels[0]; const { id, type } = channel; const watchStub = sandbox.stub(channel, 'watch'); @@ -286,7 +286,7 @@ describe('getAndWatchChannel', () => { }); it('should return the channel if only type and members are provided', async () => { - const channels = await client.queryChannels({}); + const channels = await client.queryChannelsAndHydrate({}); const channel = channels[2]; const { type } = channel; const members = Object.keys(channel.state.members); @@ -299,14 +299,17 @@ describe('getAndWatchChannel', () => { options: {}, }); expect(channelSpy.calledOnce).to.be.true; - // @ts-ignore - expect(channelSpy.calledWith(type, undefined, { members })).to.be.true; + expect( + channelSpy.calledWith(type, undefined, { + members: members.map((userId) => ({ user_id: userId })), + }), + ).to.be.true; expect(watchStub.calledOnce).to.be.true; expect(result).to.equal(channel); }); it('should not call watch again if a query is already in progress', async () => { - const channels = await client.queryChannels({}); + const channels = await client.queryChannelsAndHydrate({}); const channel = channels[0]; const { id, type, cid } = channel; // @ts-ignore @@ -404,21 +407,17 @@ describe('Channel pinning and archiving utils', () => { }); it('should extract correct sort value from an array', () => { - const sort = [{ pinned_at: -1 }, { created_at: 1 }] as unknown as ChannelSortBase; + const sort: ChannelSort = [ + { field: 'pinned_at', direction: -1 }, + { field: 'created_at', direction: 1 }, + ]; expect(extractSortValue({ atIndex: 0, targetKey: 'pinned_at', sort })).to.equal( -1, ); }); - it('should extract correct sort value from an object', () => { - const sort = { pinned_at: 1 } as unknown as ChannelSortBase; - expect(extractSortValue({ atIndex: 0, targetKey: 'pinned_at', sort })).to.equal( - 1, - ); - }); - it('should return null if key does not match targetKey', () => { - const sort = { created_at: 1 } as unknown as ChannelSortBase; + const sort: ChannelSort = [{ field: 'created_at', direction: 1 }]; expect(extractSortValue({ atIndex: 0, targetKey: 'pinned_at', sort })).to.be.null; }); }); @@ -429,18 +428,21 @@ describe('Channel pinning and archiving utils', () => { }); it('should return false if pinned_at is not a number', () => { - const sort = [{ pinned_at: 'invalid' }]; + const sort = [{ field: 'pinned_at', direction: 'invalid' }]; expect(shouldConsiderPinnedChannels(sort as any)).to.be.false; }); it('should return false if pinned_at is not first in sort', () => { - const sort = [{ created_at: 1 }, { pinned_at: 1 }] as unknown as ChannelSortBase; + const sort: ChannelSort = [ + { field: 'created_at', direction: 1 }, + { field: 'pinned_at', direction: 1 }, + ]; expect(shouldConsiderPinnedChannels(sort)).to.be.false; }); it('should return true if pinned_at is 1 or -1 at index 0', () => { - const sort1 = [{ pinned_at: 1 }] as unknown as ChannelSortBase; - const sort2 = [{ pinned_at: -1 }] as unknown as ChannelSortBase; + const sort1: ChannelSort = [{ field: 'pinned_at', direction: 1 }]; + const sort2: ChannelSort = [{ field: 'pinned_at', direction: -1 }]; expect(shouldConsiderPinnedChannels(sort1)).to.be.true; expect(shouldConsiderPinnedChannels(sort2)).to.be.true; }); @@ -448,22 +450,17 @@ describe('Channel pinning and archiving utils', () => { describe('findPinnedAtSortOrder', () => { it('should return null if sort is undefined', () => { - expect(findPinnedAtSortOrder({ sort: null as unknown as ChannelSortBase })).to.be + expect(findPinnedAtSortOrder({ sort: null as unknown as ChannelSort })).to.be .null; }); it('should return null if pinned_at is not present', () => { - const sort = [{ created_at: 1 }] as unknown as ChannelSortBase; + const sort: ChannelSort = [{ field: 'created_at', direction: 1 }]; expect(findPinnedAtSortOrder({ sort })).to.be.null; }); - it('should return pinned_at if found in an object', () => { - const sort = { pinned_at: -1 } as unknown as ChannelSortBase; - expect(findPinnedAtSortOrder({ sort })).to.equal(-1); - }); - it('should return pinned_at if found in an array', () => { - const sort = [{ pinned_at: 1 }] as unknown as ChannelSortBase; + const sort: ChannelSort = [{ field: 'pinned_at', direction: 1 }]; expect(findPinnedAtSortOrder({ sort })).to.equal(1); }); }); @@ -571,7 +568,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove: channels[0], - sort: {}, + sort: [], }); expect(result).to.deep.equal(channels); @@ -592,7 +589,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove, - sort: [{ pinned_at: 1 }], + sort: [{ field: 'pinned_at', direction: 1 }], }); expect(result).to.deep.equal(channels); @@ -614,7 +611,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove, - sort: {}, + sort: [], }); expect(result.map((c) => c.id)).to.deep.equal(['channel3', 'channel1', 'channel2']); @@ -636,7 +633,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove, - sort: {}, + sort: [], channelToMoveIndexWithinChannels: 2, }); @@ -660,7 +657,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove, - sort: {}, + sort: [], }); expect(result.map((c) => c.id)).to.deep.equal([ @@ -688,7 +685,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove, - sort: {}, + sort: [], channelToMoveIndexWithinChannels: -1, }); @@ -723,7 +720,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove, - sort: [{ pinned_at: -1 }], + sort: [{ field: 'pinned_at', direction: -1 }], }); expect(result.map((c) => c.id)).to.deep.equal([ @@ -757,7 +754,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove, - sort: {}, + sort: [], }); expect(result.map((c) => c.id)).to.deep.equal([ @@ -956,15 +953,22 @@ describe('runDetached', () => { it('calls default onError when no onErrorCallback is provided', async () => { const error = new Error('oops'); const callback = Promise.reject(error); - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const sinkSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: sinkSpy, level: 'trace' }, + }); runDetached(callback, { context: 'MyContext' }); await new Promise((resolve) => setImmediate(resolve)); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('An error has occurred in context MyContext'), + expect(sinkSpy).toHaveBeenCalledWith( + 'error', + expect.stringContaining('An error occurred in context "MyContext"'), + expect.objectContaining({ error }), ); + + chatLoggerSystem.restoreDefaults(); }); it('does not fail if onSuccessCallback is missing', async () => { @@ -1036,10 +1040,9 @@ describe('sleep', () => { }); describe('channelHasReadEvents', () => { - const makeChannel = (own_capabilities?: string[]) => { + const makeChannel = (own_capabilities?: ChannelOwnCapability[]) => { const client = new StreamChat('apiKey'); client.user = { id: 'user' }; - client.userID = 'user'; const channel = client.channel('messaging', 'cap-id'); channel.data = { own_capabilities }; return channel; @@ -1068,11 +1071,10 @@ describe('channelTracksReadLocally', () => { own_capabilities, }: { isLocalUnreadCountEnabled?: boolean; - own_capabilities?: string[]; + own_capabilities?: ChannelOwnCapability[]; }) => { const client = new StreamChat('apiKey', { isLocalUnreadCountEnabled }); client.user = { id: 'user' }; - client.userID = 'user'; const channel = client.channel('messaging', 'cap-id'); channel.data = { own_capabilities }; return { client, channel }; diff --git a/test/unit/webhook-compression.test.ts b/test/unit/webhook-compression.test.ts deleted file mode 100644 index d6cfdbf742..0000000000 --- a/test/unit/webhook-compression.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import crypto from 'crypto'; -import zlib from 'zlib'; - -import { describe, it, expect, beforeEach } from 'vitest'; - -import { StreamChat } from '../../src/client'; -import { - decodeSnsPayload, - decodeSqsPayload, - gunzipPayload, - parseEvent, - parseSns, - parseSqs, - verifyAndParseWebhook, - verifySignature, - InvalidWebhookError, - InvalidWebhookErrorMessages, -} from '../../src/signing'; - -const JSON_BODY = '{"type":"message.new","message":{"text":"the quick brown fox"}}'; -const API_SECRET = 'tsec2'; - -const sign = (body: Buffer | string) => - crypto.createHmac('sha256', Buffer.from(API_SECRET, 'utf8')).update(body).digest('hex'); - -const gzip = (body: Buffer | string) => - zlib.gzipSync(Buffer.isBuffer(body) ? body : Buffer.from(body)); - -const base64 = (body: Buffer | string) => - (Buffer.isBuffer(body) ? body : Buffer.from(body)).toString('base64'); - -const snsEnvelope = (innerMessage: string) => - JSON.stringify({ - Type: 'Notification', - MessageId: '22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324', - TopicArn: 'arn:aws:sns:us-east-1:123456789012:stream-webhooks', - Message: innerMessage, - Timestamp: '2026-05-11T10:00:00.000Z', - SignatureVersion: '1', - MessageAttributes: { - 'X-Signature': { Type: 'String', Value: '' }, - }, - }); - -describe('Webhook verification + parsing', () => { - let client: StreamChat; - - beforeEach(() => { - client = new StreamChat('api_key', API_SECRET); - }); - - describe('verifyWebhook (legacy boolean helper, unchanged)', () => { - it('validates a plain JSON body with its HMAC signature', () => { - expect(client.verifyWebhook(JSON_BODY, sign(JSON_BODY))).toBe(true); - }); - - it('rejects when signature is wrong', () => { - expect(client.verifyWebhook(JSON_BODY, 'deadbeef')).toBe(false); - }); - }); - - describe('verifySignature', () => { - it('returns true for matching HMAC', () => { - expect(verifySignature(JSON_BODY, sign(JSON_BODY), API_SECRET)).toBe(true); - }); - - it('returns false for mismatched signature', () => { - expect(verifySignature(JSON_BODY, '0'.repeat(64), API_SECRET)).toBe(false); - }); - - it('returns false for wrong secret', () => { - const sig = crypto.createHmac('sha256', 'other').update(JSON_BODY).digest('hex'); - expect(verifySignature(JSON_BODY, sig, API_SECRET)).toBe(false); - }); - - it('rejects signatures computed over compressed bytes', () => { - const compressed = gzip(JSON_BODY); - expect(verifySignature(JSON_BODY, sign(compressed), API_SECRET)).toBe(false); - }); - }); - - describe('gunzipPayload', () => { - it('passes through plain bytes unchanged', () => { - const out = gunzipPayload(JSON_BODY); - expect(out.toString('utf8')).toBe(JSON_BODY); - }); - - it('passes through Buffer input unchanged', () => { - const out = gunzipPayload(Buffer.from(JSON_BODY)); - expect(out.toString('utf8')).toBe(JSON_BODY); - }); - - it('inflates gzip-magic bytes', () => { - const out = gunzipPayload(gzip(JSON_BODY)); - expect(out.toString('utf8')).toBe(JSON_BODY); - }); - - it('returns Buffer in all cases', () => { - expect(Buffer.isBuffer(gunzipPayload(JSON_BODY))).toBe(true); - expect(Buffer.isBuffer(gunzipPayload(gzip(JSON_BODY)))).toBe(true); - }); - - it('handles empty input', () => { - expect(gunzipPayload(Buffer.alloc(0)).length).toBe(0); - }); - - it('throws InvalidWebhookError on truncated gzip with magic', () => { - const bad = Buffer.concat([Buffer.from([0x1f, 0x8b]), Buffer.from([0, 0, 0])]); - expect(() => gunzipPayload(bad)).toThrow(InvalidWebhookError); - expect(() => gunzipPayload(bad)).toThrow(InvalidWebhookErrorMessages.gzipFailed); - }); - }); - - describe('decodeSqsPayload', () => { - it('decodes base64 only (no compression)', () => { - expect(decodeSqsPayload(base64(JSON_BODY)).toString('utf8')).toBe(JSON_BODY); - }); - - it('decodes base64 + gzip', () => { - expect(decodeSqsPayload(base64(gzip(JSON_BODY))).toString('utf8')).toBe(JSON_BODY); - }); - - it('throws InvalidWebhookError on malformed base64', () => { - expect(() => decodeSqsPayload('!!!not-base64!!!')).toThrow(InvalidWebhookError); - expect(() => decodeSqsPayload('!!!not-base64!!!')).toThrow( - InvalidWebhookErrorMessages.invalidBase64, - ); - }); - }); - - describe('decodeSnsPayload', () => { - it('treats a pre-extracted Message identically to decodeSqsPayload', () => { - const wrapped = base64(gzip(JSON_BODY)); - expect(decodeSnsPayload(wrapped).equals(decodeSqsPayload(wrapped))).toBe(true); - }); - - it('round-trips base64 + gzip (pre-extracted Message)', () => { - expect(decodeSnsPayload(base64(gzip(JSON_BODY))).toString('utf8')).toBe(JSON_BODY); - }); - - it('unwraps a full SNS HTTP notification envelope', () => { - const wrapped = base64(gzip(JSON_BODY)); - const envelope = snsEnvelope(wrapped); - expect(decodeSnsPayload(envelope).toString('utf8')).toBe(JSON_BODY); - }); - - it('handles whitespace before the envelope JSON', () => { - const wrapped = base64(gzip(JSON_BODY)); - const envelope = `\n ${snsEnvelope(wrapped)}`; - expect(decodeSnsPayload(envelope).toString('utf8')).toBe(JSON_BODY); - }); - }); - - describe('parseEvent', () => { - it('parses Buffer payload into a typed event', () => { - const ev = parseEvent(Buffer.from(JSON_BODY)); - expect(ev.type).toBe('message.new'); - expect(ev.message?.text).toBe('the quick brown fox'); - }); - - it('parses string payload', () => { - const ev = parseEvent(JSON_BODY); - expect(ev.type).toBe('message.new'); - }); - - it('still parses unknown event types at runtime', () => { - const ev = parseEvent('{"type":"a.future.event","custom":42}'); - expect(ev.type).toBe('a.future.event'); - }); - - it('throws InvalidWebhookError on malformed JSON', () => { - expect(() => parseEvent('not json')).toThrow(InvalidWebhookError); - expect(() => parseEvent('not json')).toThrow( - InvalidWebhookErrorMessages.invalidJson, - ); - }); - }); - - describe('verifyAndParseWebhook', () => { - it('parses a plain HTTP webhook with a valid signature', () => { - const ev = client.verifyAndParseWebhook(JSON_BODY, sign(JSON_BODY)); - expect(ev.type).toBe('message.new'); - expect(ev.message?.text).toBe('the quick brown fox'); - }); - - it('parses a gzip-compressed HTTP webhook', () => { - const ev = client.verifyAndParseWebhook(gzip(JSON_BODY), sign(JSON_BODY)); - expect(ev.type).toBe('message.new'); - }); - - it('throws InvalidWebhookError on signature mismatch', () => { - expect(() => client.verifyAndParseWebhook(JSON_BODY, 'deadbeef')).toThrow( - InvalidWebhookError, - ); - expect(() => client.verifyAndParseWebhook(JSON_BODY, 'deadbeef')).toThrow( - InvalidWebhookErrorMessages.signatureMismatch, - ); - }); - - it('rejects a gzip body when the signature was computed over compressed bytes', () => { - const compressed = gzip(JSON_BODY); - expect(() => client.verifyAndParseWebhook(compressed, sign(compressed))).toThrow( - InvalidWebhookError, - ); - }); - - it('throws InvalidWebhookError when the client has no API secret', () => { - const secretless = new StreamChat('api_key'); - expect(() => secretless.verifyAndParseWebhook(JSON_BODY, 'sig')).toThrow( - InvalidWebhookError, - ); - }); - - it('also works as a package-level function', () => { - const ev = verifyAndParseWebhook(JSON_BODY, sign(JSON_BODY), API_SECRET); - expect(ev.type).toBe('message.new'); - }); - }); - - describe('parseSqs', () => { - it('parses a base64-only SQS body', () => { - const ev = client.parseSqs(base64(JSON_BODY)); - expect(ev.type).toBe('message.new'); - }); - - it('parses a base64 + gzip SQS body', () => { - const wrapped = base64(gzip(JSON_BODY)); - const ev = client.parseSqs(wrapped); - expect(ev.type).toBe('message.new'); - }); - - it('also works as a package-level function', () => { - const wrapped = base64(gzip(JSON_BODY)); - const ev = parseSqs(wrapped); - expect(ev.type).toBe('message.new'); - }); - - it('surfaces malformed base64 as InvalidWebhookError', () => { - expect(() => client.parseSqs('!!!not-base64!!!')).toThrow(InvalidWebhookError); - }); - - it('does not require an API secret on the client', () => { - const secretless = new StreamChat('api_key'); - const wrapped = base64(gzip(JSON_BODY)); - expect(secretless.parseSqs(wrapped).type).toBe('message.new'); - }); - }); - - describe('parseSns', () => { - it('parses a pre-extracted base64 + gzip SNS message', () => { - const wrapped = base64(gzip(JSON_BODY)); - const ev = client.parseSns(wrapped); - expect(ev.type).toBe('message.new'); - }); - - it('produces the same event as parseSqs (pre-extracted Message)', () => { - const wrapped = base64(gzip(JSON_BODY)); - expect(client.parseSns(wrapped)).toEqual(client.parseSqs(wrapped)); - }); - - it('parses a full SNS HTTP notification envelope', () => { - const wrapped = base64(gzip(JSON_BODY)); - const envelope = snsEnvelope(wrapped); - const ev = client.parseSns(envelope); - expect(ev.type).toBe('message.new'); - }); - - it('also works as a package-level function', () => { - const wrapped = base64(gzip(JSON_BODY)); - const ev = parseSns(wrapped); - expect(ev.type).toBe('message.new'); - }); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 74b01d4861..46b3ff41e8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,7 @@ "outDir": "./dist/types", "rootDir": "./src", - "lib": ["ES2020", "DOM", "ES2022.Error"], + "lib": ["ES2022", "DOM", "ES2022.Error"], "moduleResolution": "bundler", "module": "Preserve", "target": "ES2020" diff --git a/v9-to-v10-migration-guide-client-construction.md b/v9-to-v10-migration-guide-client-construction.md new file mode 100644 index 0000000000..a27d447d5e --- /dev/null +++ b/v9-to-v10-migration-guide-client-construction.md @@ -0,0 +1,149 @@ +# v9 → v10 Migration Guide — Client Construction + +> Scope: this guide covers **only** changes to `StreamChat` construction (`new StreamChat(...)` and `StreamChat.getInstance(...)`) and the shape of `StreamChatOptions`. Other v10 changes will be documented separately. + +## TL;DR + +- `secret` is gone. The constructor and `getInstance` no longer accept it. **v10 does not support server-side use.** +- The constructor and `getInstance` are now a single signature: `(key, options?)`. The `(key, secret, options?)` overload has been removed. +- `StreamChatOptions` no longer extends `AxiosRequestConfig`. Axios-level fields (`timeout`, `httpsAgent`, `withCredentials`, headers, etc.) must now be passed via the dedicated `axiosRequestConfig` property. +- The same axios defaults (`timeout: 3000`, `withCredentials: false`, keep-alive `httpsAgent` in node) are still applied, but in v10 they can be overridden through `axiosRequestConfig`. In v9 they could not be — `axiosRequestConfig` only affected per-request calls. +- `paramsSerializer` cannot be overridden. Any `paramsSerializer` passed in `axiosRequestConfig` is ignored; the client always uses its internal `axiosParamsSerializer`. + +## Server-side users — stop here + +v10 removes all server-side functionality (secret-based auth, server-side JWT signing, etc.). If your integration uses `stream-chat` with a `secret` on a backend, **do not migrate to v10**. Switch to the dedicated server SDK: + +- https://github.com/GetStream/stream-node + +For client-side / React Native / browser apps that previously called `new StreamChat(key)` without a secret, keep reading. + +## Constructor signature + +### Removed: the `secret` parameter and its overload + +```ts +// v9 — all of these worked +new StreamChat(API_KEY); +new StreamChat(API_KEY, 'a-secret'); +new StreamChat(API_KEY, { timeout: 5000 }); +new StreamChat(API_KEY, 'a-secret', { timeout: 5000 }); +new StreamChat(API_KEY, undefined, { timeout: 5000 }); +new StreamChat(API_KEY, ''); // empty string was treated as "no secret" +``` + +```ts +// v10 — only this shape is valid +new StreamChat(API_KEY); +new StreamChat(API_KEY, options); +``` + +Same change applies to `StreamChat.getInstance`: + +```ts +// v9 +StreamChat.getInstance(API_KEY, 'a-secret', { timeout: 5000 }); + +// v10 +StreamChat.getInstance(API_KEY, { axiosRequestConfig: { timeout: 5000 } }); +``` + +### Removed: `client.secret` + +The `secret` field on the client instance no longer exists. The internal `_isUsingServerAuth()` method has also been removed; any guard that branched on it should be deleted (the branch was always the server-side path). + +## `StreamChatOptions` no longer extends `AxiosRequestConfig` + +In v9, `StreamChatOptions = AxiosRequestConfig & { ... }`. That meant you could pass axios fields directly at the top level: + +```ts +// v9 +new StreamChat(API_KEY, { + timeout: 5000, + withCredentials: true, + httpsAgent: customAgent, + headers: { 'Cache-Control': 'no-cache' }, +}); +``` + +In v10, axios fields must go through the dedicated `axiosRequestConfig` property: + +```ts +// v10 +new StreamChat(API_KEY, { + axiosRequestConfig: { + timeout: 5000, + withCredentials: true, + httpsAgent: customAgent, + headers: { 'Cache-Control': 'no-cache' }, + }, +}); +``` + +The full mapping for top-level axios fields previously accepted in v9 → `axiosRequestConfig.` in v10. + +### `axiosRequestConfig` now actually configures the axios instance + +In v9, `axiosRequestConfig` was stored on `client.options` but **not** applied to `axios.create` during construction — it was only spread into per-request calls. As a result, defaults like `timeout: 3000` could not be overridden through it. + +In v10, `axiosRequestConfig` is spread into the `axios.create` call during construction, so it can override the baked-in defaults: + +```ts +const client = new StreamChat(API_KEY, { + axiosRequestConfig: { timeout: 9999, withCredentials: true }, +}); +client.axiosInstance.defaults.timeout; // 9999 +client.axiosInstance.defaults.withCredentials; // true +``` + +The defaults (`timeout: 3000`, `withCredentials: false`, keep-alive `https.Agent` in node) still apply when `axiosRequestConfig` does not set them. + +### `httpsAgent` location moved + +```ts +// v9 — top-level +new StreamChat(API_KEY, { browser: false, httpsAgent: customAgent }); + +// v10 — under axiosRequestConfig +new StreamChat(API_KEY, { + browser: false, + axiosRequestConfig: { httpsAgent: customAgent }, +}); +``` + +In both versions, node mode (`browser: false` or auto-detected) auto-creates a keep-alive `https.Agent` when none is supplied. Browser mode does not. + +### `paramsSerializer` is fixed + +Any `paramsSerializer` passed via `axiosRequestConfig` is silently dropped. The client always uses its internal `axiosParamsSerializer`: + +```ts +const client = new StreamChat(API_KEY, { + axiosRequestConfig: { paramsSerializer: () => 'overridden' }, +}); +client.axiosInstance.defaults.paramsSerializer; // === axiosParamsSerializer (NOT the override) +``` + +If you relied on a custom serializer, file an issue — there is no supported way to change this in v10. + +## Unchanged behavior worth confirming + +These are intentionally listed so agents don't "fix" them during migration: + +- `new StreamChat(key)` still works with no options. +- `StreamChat.getInstance(key)` still returns the same cached instance on repeated calls and ignores the `key`/`options` of subsequent calls. +- All non-axios options are unchanged: `allowServerSideConnect`, `baseURL`, `browser`, `device`, `disableCache`, `enableInsights`, `enableWSFallback`, `notifications`, `persistUserOnConnectionFailure`, `recoverStateOnReconnect`, `warmUp`, `wsConnection`, `wsUrlParams`. +- `STREAM_LOCAL_TEST_RUN` / `STREAM_LOCAL_TEST_HOST` env-var overrides on `baseURL` still work the same way. +- `browser` auto-detection (`typeof window !== 'undefined'`) and the `browser: true | false` override still work the same way. +- The subsystem managers constructed on the client (`state`, `notifications`, `uploadManager`, `moderation`, `tokenManager`, `threads`, `polls`, `reminders`, `messageDeliveryReporter`, `messageComposerCache`, `insightMetrics`) are identical in v10. + +## Mechanical migration recipe + +1. If the call site passes a secret, **stop** — migrate that code to `stream-node` instead. +2. Remove any `secret` argument and any `undefined`/`''` placeholders in the second slot: + - `new StreamChat(key, undefined, opts)` → `new StreamChat(key, opts)` + - `new StreamChat(key, '', opts)` → `new StreamChat(key, opts)` + - `StreamChat.getInstance(key, undefined, opts)` → `StreamChat.getInstance(key, opts)` +3. For each option key in the `options` object, check whether it's an axios field (`timeout`, `withCredentials`, `httpsAgent`, `headers`, `adapter`, `proxy`, `responseType`, etc. — anything from `AxiosRequestConfig`). If yes, move it under a new `axiosRequestConfig` sub-object. +4. Remove any reads of `client.secret` and any branches gated on `client._isUsingServerAuth()`. +5. Drop any custom `paramsSerializer` you were passing — it has no effect in v10. diff --git a/v9-to-v10-migration-guide-logging.md b/v9-to-v10-migration-guide-logging.md new file mode 100644 index 0000000000..ae58c2d773 --- /dev/null +++ b/v9-to-v10-migration-guide-logging.md @@ -0,0 +1,239 @@ +# v9 → v10 Migration Guide — Logging + +> Scope: this guide covers **only** the logging system replacement — the removal of the v9 `logger` option / `client.logger()` / `_log()` surface, and the introduction of the scoped `@stream-io/logger`-based system exposed as `chatLoggerSystem`. Construction, method-signature, and sort changes are covered in separate guides. + +## TL;DR + +- The `logger` option on `StreamChatOptions` is **removed**. `new StreamChat(key, { logger: fn })` no longer type-checks — the field is silently dropped at runtime. +- The `client.logger` field is **removed**. Any `client.logger('info', 'msg', extra)` call no longer compiles. +- The v9 `Logger` and `LogLevel` types (from `stream-chat`) are **removed**. Import their replacements from `stream-chat`'s new logger surface (re-exported from `./logger`): `LogLevel`, `Sink`, `ConfigureLoggersOptions`, `LogLevelEnum`, `ScopedLogger`, `ChatLoggerScope`, `chatLoggerSystem`. +- Log-level enum expanded from **3** values (`'info' | 'warn' | 'error'`) to **5** (`'trace' | 'debug' | 'info' | 'warn' | 'error'`). +- Configure logging by calling `chatLoggerSystem.configureLoggers({...})` **before** constructing the client (there is no constructor option for it — `logLevel` / `logOptions` fields do not exist on `StreamChatOptions`). +- Internal `_log()` methods (notably on `StableWSConnection`) are gone. If you subclassed or spied on them, switch to the scoped loggers. + +## What ships in v10 + +```ts +// src/logger.ts — re-exported from the package root +import { + chatLoggerSystem, // LoggerSystem + LogLevelEnum, // numeric enum: trace=0 debug=1 info=2 warn=3 error=4 + type ChatLoggerScope, // union of the 15 built-in scopes (see table below) + type ConfigureLoggersOptions, + type LogLevel, // 'trace' | 'debug' | 'info' | 'warn' | 'error' + type Sink, // (logLevel, message, ...data) => void + type ScopedLogger, // = Logger +} from 'stream-chat'; +``` + +`chatLoggerSystem` is a **module-level singleton**. It is created once when `./logger` is imported and is shared across every `StreamChat` instance (and across every SDK internal caller). Configuration is process-wide, not per-client. + +The default sink writes to `console.{trace,debug,info,warn,error}` (with a React-Native-safe fallback for `warn`/`error`). Default level is `'info'`. + +### Built-in scopes + +Every internal module attaches to one of these scopes via `chatLoggerSystem.getLogger('')`: + +| Scope | Emitted by | +| --------------------- | ----------------------------------------------------------------------------------------------------- | +| `api-client` | `src/api-client.ts` — HTTP request/response tracing | +| `channel` | `src/channel.ts` | +| `channel-manager` | `src/channel_manager.ts` | +| `client` | `src/client.ts` — connection lifecycle, event dispatch | +| `connection` | `src/connection.ts` — primary WS transport | +| `connection-fallback` | `src/connection_fallback.ts` — long-poll transport | +| `message-composer` | `src/messageComposer/messageComposer.ts` | +| `offline-db` | `src/offline-support/*` **and** offline-DB paths in `client.ts` / `channel.ts` / `messageComposer.ts` | +| `state-store` | reserved — declared in the scope union, not yet emitted | +| `text-composer` | `src/messageComposer/middleware/textComposer/*` | +| `thread` | `src/thread.ts` | +| `thread-manager` | `src/thread_manager.ts` | +| `token-manager` | `src/token_manager.ts` | +| `upload-manager` | `src/uploadManager.ts` | +| `utils` | `src/utils.ts` — `logChatPromiseExecution`, `isOnline`, `messageSetPagination`, `runDetached` | + +Unknown scope names fall through to `'default'`. The `ChatLoggerScope` union narrows autocomplete but is not enforced at runtime. + +## Removed surface + +| v9 | v10 | +| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `StreamChatOptions.logger` | **REMOVED** — no field on `StreamChatOptions` | +| `client.logger` (instance field) | **REMOVED** | +| `type Logger = (level, message, extra?) => void` | **REMOVED** — no direct replacement (write a `Sink` instead) | +| `type LogLevel = 'info' \| 'error' \| 'warn'` | **REPLACED** — same name, now `'trace' \| 'debug' \| 'info' \| 'warn' \| 'error'` | +| `isFunction(inputOptions.logger)` guard in constructor | gone | +| `StableWSConnection._log(msg, extra?, level?)` | **REMOVED** — use `chatLoggerSystem.getLogger('connection')` | +| `extraData.tags: string[]` convention (`{ tags: ['channel', 'offlineDb'], error }`) | replaced by scope + `.withExtraTags(...)` (see below) | +| Structured extra as second positional arg (`(level, msg, { tags, error, event })`) | passed as rest args after the message (`.error('msg', { error })`) | + +## The v9 → v10 shape shift + +### v9 — a single `logger` function receives everything + +```ts +// v9 +type LogLevel = 'info' | 'error' | 'warn'; +type Logger = ( + logLevel: LogLevel, + message: string, + extraData?: Record, +) => void; + +const client = new StreamChat('api_key', { + logger: (level, message, extraData) => { + // extraData contains a `tags: string[]` array plus arbitrary context + console.log(level, message, extraData); + }, +}); + +client.logger('info', 'anything I want to log', { + tags: ['channel', 'offlineDb'], + error: someError, +}); +``` + +The SDK routed _all_ internal log calls into this single function; disambiguation was done via the `extraData.tags` array (values like `'api'`, `'api_request'`, `'api_response'`, `'client'`, `'channel'`, `'connection'`, `'event'`). + +### v10 — scoped loggers with per-scope sinks and levels + +```ts +// v10 — configure the shared logger system before creating the client +import { chatLoggerSystem, type Sink } from 'stream-chat'; + +const sink: Sink = (logLevel, message, ...rest) => { + // `message` is prefixed with `[](): ` by the system + // `rest` is whatever the SDK passes after the message (e.g. `{ error }`) + myLogger[logLevel](message, ...rest); +}; + +chatLoggerSystem.configureLoggers({ + default: { level: 'info', sink }, +}); + +const client = new StreamChat('api_key'); +``` + +SDK-internal call sites look like this (do not call these yourself unless you're extending the SDK): + +```ts +const logger = chatLoggerSystem.getLogger('connection'); +logger.withExtraTags('_reconnect').info('Initiating a reconnect.'); +// → sink receives: 'warn' | 'info' | ... , '[connection](_reconnect): Initiating a reconnect.', ...rest +``` + +## Configuring logging in v10 + +There is **no constructor option** for logging in v10 (an earlier commit briefly added `logLevel` / `logOptions` to `StreamChatOptions`; both were dropped before release — do not rely on them). Configure `chatLoggerSystem` directly. Because it is a module-level singleton, configuration applies to every `StreamChat` you construct afterwards. + +### Route all output to your own logger + +```ts +import { chatLoggerSystem, type Sink } from 'stream-chat'; + +const sink: Sink = (level, message, ...rest) => myLogger[level](message, ...rest); + +chatLoggerSystem.configureLoggers({ + default: { level: 'info', sink }, +}); +``` + +### Raise the level globally (silence everything below `warn`) + +```ts +chatLoggerSystem.configureLoggers({ default: { level: 'warn' } }); +``` + +### Debug one subsystem without touching the others + +```ts +chatLoggerSystem.configureLoggers({ + connection: { level: 'trace' }, + 'connection-fallback': { level: 'trace' }, +}); +// leaves `default` and every other scope at 'info' +``` + +### Reset one scope back to defaults, or reset everything + +```ts +chatLoggerSystem.configureLoggers({ + connection: { level: null, sink: null }, // remove per-scope overrides +}); + +chatLoggerSystem.restoreDefaults(); // wipe all overrides, restore default sink + 'info' +``` + +### `configureLoggers` semantics you must know + +- Passing `{ level: 'warn' }` sets the level. Passing `{ level: null }` **deletes** the override (falls back to `'default'`). Same for `sink`. +- The `default` scope can be overridden but **cannot be deleted** — `{ default: { level: null } }` is a no-op. +- Undefined values are ignored — only explicit `null` clears an override. +- Configuration is not additive across calls to `configureLoggers` for the _same_ key — the last call wins per (scope, field). Untouched scopes keep their prior override. +- `chatLoggerSystem` is process-wide. Two `StreamChat` instances in the same process share it; there is no per-instance override. + +### Sink signature + +```ts +type Sink = (logLevel: LogLevel, message: string, ...data: any[]) => void; +``` + +- `logLevel` is the string form (`'trace' | 'debug' | 'info' | 'warn' | 'error'`), not the enum. +- `message` arrives already prefixed by the system with `[](): ` (tags parenthetical is omitted when empty). +- `...data` is whatever the SDK passed after the message (e.g. `{ error }`, `{ event }`, `{ wsURL }`). +- Use `LogLevelEnum` to compare severity numerically: `LogLevelEnum[record.logLevel] >= LogLevelEnum.warn`. + +## Mechanical migration recipe + +For every call site: + +1. **Delete the `logger` option** from `new StreamChat(key, options)` and `StreamChat.getInstance(key, options)`. If you were forwarding to your own logger, replace it with a top-level `chatLoggerSystem.configureLoggers({ default: { sink: yourSink } })` call (once, at bootstrap). +2. **Rewrite `client.logger(level, message, extra)` calls.** If it was your own instrumentation code, drop it — the SDK's internal call sites already log through `chatLoggerSystem`. If you must emit into the same stream, use `chatLoggerSystem.getLogger('')[level](message, extra)`. +3. **Remove any reads of `client.logger`.** Tests and integrations that asserted on `client.logger` being a function must be deleted or rewritten against `chatLoggerSystem`. See the `client.construction-old.test.ts` block in this branch for a concrete v9 example that must be dropped. +4. **Delete the `logger` field from `StreamChatOptions` type-satisfaction sites.** If you had `const opts: StreamChatOptions = { logger, timeout: 5000 }`, drop `logger`. +5. **Replace `import type { Logger } from 'stream-chat'`.** Write a `Sink` instead: + + ```ts + // v9 + import type { Logger } from 'stream-chat'; + const myLogger: Logger = (level, msg, extra) => { … }; + + // v10 + import { type Sink } from 'stream-chat'; + const mySink: Sink = (level, msg, ...rest) => { … }; + ``` + +6. **Update `LogLevel` consumers.** If your code was `switch(level) { case 'info': … case 'warn': … case 'error': … }`, add `case 'trace':` and `case 'debug':` (or let them fall through to a default branch). Any exhaustive union check on `LogLevel` will now fail without those cases. +7. **Drop the `tags` array convention.** In v9, extras looked like `{ tags: ['channel', 'offlineDb'], error }`. In v10, the scope already carries the primary category (`offline-db`, `channel`, …) and `.withExtraTags('sendMessage', channelCid)` adds call-site tags — you don't reconstruct them by hand. When translating an internal callsite, pick the scope that matches the module and use `.withExtraTags(...)` for the finer-grained context. +8. **Remove `_log()` calls in any code that subclassed SDK internals.** Every `_log(msg, extra?, level?)` in v9 mapped to `this.client.logger(level ?? 'info', ':' + msg, { tags: […], ...extra })`. Replace with the appropriate scoped logger. Example (from `StableWSConnection`): + + ```ts + // v9 + this._log(`connect() - established with healthcheck ${hc}`); + + // v10 + const logger = chatLoggerSystem.getLogger('connection'); + logger + .withExtraTags('connect') + .info(`Established a WebSocket connection. Health check: ${hc}.`); + ``` + +## Type-import mapping cheat sheet + +| v9 import from `stream-chat` | v10 replacement | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `Logger` | REMOVED — write a `Sink` instead | +| `LogLevel` | still exported (same name), but the union is now 5 values, not 3 | +| — | `Sink` — new. Sink function signature. | +| — | `LogLevelEnum` — new. Numeric enum, useful for severity comparisons in sinks. | +| — | `ConfigureLoggersOptions` — new. Argument shape for `chatLoggerSystem.configureLoggers`. | +| — | `ChatLoggerScope` — new. String union of the 15 built-in scopes. | +| — | `ScopedLogger` — new. Alias for `Logger` (the return type of `chatLoggerSystem.getLogger(scope)`). | +| — | `chatLoggerSystem` — new. The shared `LoggerSystem` singleton. | + +## Things that did NOT change + +- The categories of information the SDK logs (WS lifecycle, event dispatch, API request/response, offline-DB failures, upload errors, composer state) are unchanged in v10 — only the transport and the shape of the record. +- No log call is now silent-by-default that was noisy in v9 within the shared 3-level range; several v9 `.info` calls became `.debug` (e.g. `client.on/off` listener attach/detach, `openConnection` already-connecting guard). If you rely on those, lower the level to `'debug'` for that scope. +- The default sink still writes to the `console`. No behavior change for callers that never installed a custom `logger` in v9 — they now see richer output (with the `[scope](tags):` prefix), but the surface is still `console`. +- Log messages are not part of the semver contract. Do not pattern-match on message strings in production; use `logLevel` + `scope` (via the `[scope]` prefix) instead. diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md new file mode 100644 index 0000000000..7cfb1d934c --- /dev/null +++ b/v9-to-v10-migration-guide-methods.md @@ -0,0 +1,981 @@ +# v9 → v10 Migration Guide — Method Signatures + +> Scope: this guide covers **method signature changes** on `StreamChat`, `Channel`, `ChannelState`, `Moderation`, and `StableWSConnection`. Construction changes are in `v9-to-v10-migration-guide-client-construction.md`. Server-side surfaces are gone in v10 — server-side callers should switch to `@stream-io/node-sdk` (https://github.com/GetStream/stream-node) and ignore this guide. +> +> This document is written for AI agents doing mechanical rewrites. Each entry has the exact v9 signature and the exact v10 replacement. Removed methods are labeled **REMOVED** with the recommended replacement (or "no replacement" when the entire feature is dropped). +> +> **Sort arguments:** every `sort` argument shown below has also changed shape — the v9 `{ field_name: direction }` object form is gone, replaced by `SortParamRequest[]` (`[{ field, direction }]`). This guide shows sort values in the new shape but does **not** re-explain the sort migration itself. For the sort shape change, the full field→field/direction rewrite recipe, and the removed `Sort` / `*SortBase` / `normalizeQuerySort` imports, see `v9-to-v10-migration-guide-sort.md` — agents rewriting call sites that pass a `sort` must consult that guide. + +## Global renames applied everywhere + +Before applying any per-method entry below, apply these repo-wide renames — they are consistent across every class: + +| v9 | v10 | Notes | +| ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `userID` (param and field) | `userId` | Field: `client.userID` still readable via a deprecated getter; assignment (`client.userID = …`) no longer compiles. | +| `clientID` (param and field) | `clientId` | Field: `client.clientID` kept as a deprecated getter+setter. | +| `messageID`, `targetID`, `targetMessageID`, `targetUserID`, `flaggedUserID`, `entityCreatorID`, `wsID`, `channelID`, `channelId` parameter | `messageId`, `targetId`, `targetMessageId`, `targetUserId`, `flaggedUserId`, `entityCreatorId`, `wsId`, `channelId` | Named-parameter rename only; call sites using positional args are unaffected. | +| `parent_id` parameter on `keystroke` / `stopTyping` | `parentId` | Positional; call sites unaffected. | +| `Event` (type) | `Event` (still exported; shape changed) | `Event` is now `WSEvent \| LocalEvent \| keyof CustomEventTypes`. The name is unchanged; the wire shape is what's different. Internal handlers that took an untyped `Event` are the same. `EventPayload<'…'>` narrows to a specific event type. | +| `EventTypes` (type import) | `EventType \| string` (via generic) | The public alias renamed to singular `EventType = Event['type'] \| 'all'`. Callers annotating handlers as `EventHandler` are safe; callers importing `EventTypes` need to switch to `EventType`. `CustomEventTypes` module augmentation is unchanged — augment it to add custom event-type keys. | +| `Logger` option / `client.logger()` | `chatLoggerSystem` from `./logger` | See "Logging" note at the end of the guide. | + +The `secret` parameter, `client.secret`, `client._isUsingServerAuth()`, and all server-only methods are gone. Where a v9 method took a `user_id?` / `userID?` / `currentUserID?` override, that argument has been dropped in v10 (the connected user is always used). + +--- + +## StreamChat + +### Removed — no replacement in this SDK (server-side, use `@stream-io/node-sdk`) + +The following `StreamChat` methods no longer exist. All were server-side or admin-only. Rewrites should either delete the call site or move it to the server SDK: + +`updateAppSettings`, `revokeUserToken`, `revokeUsersToken`, `testPushSettings`, `testSQSSettings`, `testSNSSettings`, `createToken`, `devToken`, user-groups mutations (`createUserGroup` / `getUserGroup` / `searchUserGroups` / `updateUserGroup` / `deleteUserGroup` / `addUserGroupMembers` / `removeUserGroupMembers`) — the read path is renamed, see below, `upsertPushProvider`, `deletePushProvider`, `listPushProviders`, `setPushPreferences`, `_queryFlags`, `_queryFlagReports`, `_reviewFlagReport`, `queryFutureChannelBans`-write paths, `getHookEvents`, `partialUpdateUser`, `deleteUser`, `restoreUsers`, `reactivateUser`, `reactivateUsers`, `deactivateUser`, `deactivateUsers`, `exportUser`, `getSharedLocations`, `translate`, `translateMessage`, `updateFlags`, `queryCampaigns`, `_createImportURL`, `_createImport`, `_getImport`, `_listImports`, `commitMessage`, `queryTeamUsageStats`, `updateLocation`, `updateChannelsBatch`, `deletePredefinedFilter`, `setRetentionPolicy`, `deleteRetentionPolicy`, `getRetentionPolicy`, `getRetentionPolicyRuns`, hand-rolled reminder client methods (`createReminder`/`updateReminder`/`deleteReminder` — see note under `Reminder` handling; the inherited `queryReminders` from `ChatApi` remains but with the generated request shape, not the v9 `QueryRemindersOptions`), `createCommand`/`getCommand`/`updateCommand`/`deleteCommand`/`listCommands`/`createChannelType`/`getChannelType`/`updateChannelType`/`deleteChannelType`/`listChannelTypes`/`exportChannel`/`exportChannels`/`exportUsers`/`getExportChannelStatus`/`getTask`/`enrichURL`/`sendUserCustomEvent`, `deleteChannels`, `deleteUsers`, `createRole`/`listRoles`/`deleteRole` (only `searchRoles` remains, inherited), `getPermission`/`createPermission`/`updatePermission`/`deletePermission`/`listPermissions`, `getBlockList` (only `listBlockLists`/`createBlockList`/`updateBlockList`/`deleteBlockList` remain, inherited), `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns` (moved — see below), `campaign`, `segment`, `channelBatchUpdater`, `validateServerSideAuth`, `createSegment`, `createUserSegment`, `createChannelSegment`, `getSegment`, `updateSegment`, `addSegmentTargets`, `querySegmentTargets`, `removeSegmentTargets`, `querySegments`, `deleteSegment`, `segmentTargetExists`, `createCampaign`, `getCampaign`, `startCampaign`, `updateCampaign`, `deleteCampaign`, `stopCampaign`, `_normalizeDate`. Note: `queryDrafts`, `queryPolls`, `queryPollVotes`, `queryMessageFlags`, and `markChannelsDelivered` — all of which were hand-rolled in v9 — now come from `ChatApi` inheritance with generated request shapes; they still exist on `client`. + +### Renamed / signature-changed + +#### `client.queryChannels` + +```ts +// v9 — three overloads with positional filter/sort/options +client.queryChannels(filter, sort?, options?, stateOptions?): Promise; +client.queryChannels(filter, options?, stateOptions?): Promise; + +// v10 — split into two methods +client.queryChannels(request?: QueryChannelsRequest): Promise; // raw response (inherited from ChatApi) +client.queryChannelsAndHydrate( + options?: QueryChannelsRequest, + stateOptions?: ChannelStateOptions, +): Promise; // v9 behavior lives here +client.queryChannelsAndHydrate( + options, + stateOptions: ChannelStateOptions & { withResponse: true }, +): Promise; // returns Channels + raw response +``` + +Rewrite: + +```ts +// v9 +const channels = await client.queryChannels( + { type: 'messaging' }, + { last_message_at: -1 }, + { limit: 20 }, +); + +// v10 +const channels = await client.queryChannelsAndHydrate({ + filter_conditions: { type: 'messaging' }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, +}); +``` + +Sort now uses `Gen_SortParamRequest[]` (`{ field, direction }`), not the v9 record form. See `v9-to-v10-migration-guide-sort.md` for the full sort migration. + +#### `client.queryReactions` + +```ts +// v9 +client.queryReactions(messageID, filter, sort?, options?); + +// v10 — inherited from ChatApi +client.queryReactions(request: QueryReactionsRequest); // raw response +client.queryReactionsAndHydrate(request: QueryReactionsRequest); // wraps offline-db merge +``` + +Use `queryReactionsAndHydrate` where v9 code depended on the offline-db reaction reconciliation; otherwise use inherited `queryReactions`. + +#### `client.queryUsers` + +```ts +// v9 +client.queryUsers(filterConditions, sort?, options?); + +// v10 — inherited/overridden +client.queryUsers(request?: { payload?: Gen_QueryUsersPayload }); +// payload: { filter_conditions, sort, limit, offset, presence, ... } +``` + +#### `client.search` + +```ts +// v9 +client.search(filterConditions, query, options?); + +// v10 +client.search(request?: { payload?: SearchPayload }); +// payload combines filter_conditions, message_filter_conditions, query, sort, limit, next, ... +``` + +#### `client.queryThreads` / `client.getThread` + +```ts +// v9 +client.queryThreads(options?); // returned hydrated Thread[] +client.getThread(messageId, options?); // returned hydrated Thread + +// v10 +client.queryThreads(request?); // inherited, raw QueryThreadsResponse +client.queryThreadsAndHydrate(options?); // v9 behavior +client.getThread(request: { message_id }); // inherited, raw +client.getThreadAndHydrate(messageId, options?); // v9 behavior +``` + +Callers that want hydrated `Thread` instances (the v9 default) must call the `*AndHydrate` variants. + +#### `client.updateMessage` / `client.deleteMessage` + +```ts +// v9 +client.updateMessage(message, userId?, options?); +client.deleteMessage(messageID, hardDelete?); + +// v10 — inherited/overridden from ChatApi +client.updateMessage(request: Parameters[0] & { message: { cid?: string } }); +// request: { id, message, skip_enrich_url? } +client.deleteMessage(request: { id: string; hard?: boolean; delete_for_me?: boolean }); +``` + +Note: `hardDelete` boolean is now `hard` on the request. `user_id` override is gone. + +#### `client.partialUpdateMessage` / `client.ephemeralUpdateMessage` / `client.undeleteMessage` + +```ts +// v9 +client.partialUpdateMessage(messageID, updates, userId?, options?); +client.ephemeralUpdateMessage(messageID, updates, userId?, options?); +client.undeleteMessage(messageID, userID); + +// v10 +client.updateMessagePartial(request: UpdateMessagePartialRequest); // inherited; no user_id override +// ephemeralUpdateMessage: REMOVED — call updateMessagePartial with the ephemeral payload directly. +// undeleteMessage: REMOVED — no client-side replacement (was server-side). +``` + +#### `client.getMessage` + +```ts +// v9 +client.getMessage(messageID, options?); + +// v10 — inherited +client.getMessage(request: { id: string }); +``` + +Options like `show_deleted_message` are no longer accepted here (server-side only). + +#### `client.pinMessage` / `client.unpinMessage` + +Unchanged behavior; parameter name normalized: + +```ts +// v9 +client.pinMessage(messageOrMessageId, timeoutOrExpirationDate?, pinnedAt?); +client.unpinMessage(messageOrMessageId); + +// v10 — same signatures; `userId` positional (v9 fourth arg) is removed +client.pinMessage(messageOrMessageId, timeoutOrExpirationDate?, pinnedAt?); +client.unpinMessage(messageOrMessageId); +``` + +#### `client.markChannelsRead` (and alias `markAllRead`) + +```ts +// v9 +client.markChannelsRead(data?: MarkChannelsReadOptions); +client.markAllRead(data?); // alias — REMOVED + +// v10 — inherited +client.markChannelsRead(request?: Gen_MarkChannelsReadRequest); +``` + +#### `client.markChannelsDelivered` + +```ts +// v9 +client.markChannelsDelivered(data: MarkDeliveredOptions); + +// v10 +client.markChannelsDelivered(request?: Gen_MarkDeliveredRequest); +// v10 short-circuits when `latest_delivered_messages` is empty; still available. +``` + +#### `client.upsertUser` / `client.upsertUsers` (+ aliases `updateUser` / `updateUsers`) + +```ts +// v9 +client.upsertUser(user); +client.upsertUsers([user1, user2]); +client.updateUser(user); // alias — REMOVED +client.updateUsers([user1]); // alias — REMOVED (name reused for the new bulk method) + +// v10 — inherited +client.updateUsers({ users: { [user.id]: user } }); +// `users` is a Record keyed by user ID, not an array. +``` + +Mechanical rewrite for a single user: + +```ts +// v9 +await client.upsertUser({ id: 'u1', name: 'A' }); + +// v10 +await client.updateUsers({ users: { u1: { id: 'u1', name: 'A' } } }); +``` + +#### `client.partialUpdateUsers` + +```ts +// v9 +client.partialUpdateUsers(users: PartialUserUpdate[]); + +// v10 — inherited +client.updateUsersPartial({ users: PartialUserUpdate[] }); +``` + +#### `client.addDevice` / `client.getDevices` / `client.removeDevice` + +```ts +// v9 +client.addDevice(id, pushProvider, userID?, pushProviderName?); +client.getDevices(userID?); +client.removeDevice(id, userID?); + +// v10 — inherited (userID param dropped; server-only) +client.createDevice({ id, push_provider, push_provider_name?, hardware_id? }); +client.listDevices(); +client.deleteDevice({ id }); +``` + +`userID` is gone from all three — server-side callers using the target-user form must move to `@stream-io/node-sdk`. + +#### `client.getUnreadCount` / `client.getUnreadCountBatch` + +```ts +// v9 +client.getUnreadCount(userID?); // could query for another user server-side +client.getUnreadCountBatch(userIDs); // server-side + +// v10 — inherited +client.unreadCounts(); // connected user only +// getUnreadCountBatch: no replacement — was server-side only. +``` + +#### `client.banUser` / `client.unbanUser` / `client.shadowBan` / `client.removeShadowBan` + +```ts +// v9 +client.banUser(targetUserID, options?); +client.unbanUser(targetUserID, options?); +client.shadowBan(targetUserID, options?); +client.removeShadowBan(targetUserID, options?); + +// v10 — same shape; positional rename only +client.banUser(targetUserId, options?); +client.unbanUser(targetUserId, options?); +client.shadowBan(targetUserId, options?); +client.removeShadowBan(targetUserId, options?); +``` + +#### `client.blockUser` / `client.unBlockUser` / `client.getBlockedUsers` + +```ts +// v9 +client.blockUser(blockedUserID, user_id?); // user_id was server-side override +client.unBlockUser(blockedUserID, userID?); // note the mixed-case original name +client.getBlockedUsers(user_id?); + +// v10 +client.blockUser(blockedUserId); // takes only the target +client.unblockUser(blockedUserId); // renamed to lowercase `b` +client.getBlockedUsers(); // no user_id override +``` + +**Rename:** `unBlockUser` → `unblockUser` (lowercase `b`). + +#### `client.muteUser` / `client.unmuteUser` + +```ts +// v9 +client.muteUser(targetID, userID?, options?); // userID was server-side override +client.unmuteUser(targetID, currentUserID?); + +// v10 +client.muteUser(targetId, options?); +client.unmuteUser(targetId); +``` + +#### `client.flagMessage` / `client.flagUser` / `client.unflagMessage` / `client.unflagUser` / `client.unblockMessage` + +```ts +// v9 +client.flagMessage(targetMessageID, options?: { reason?; user_id? }); +client.flagUser(targetID, options?: { reason?; user_id? }); +client.unflagMessage(targetMessageID, options?: { user_id? }); +client.unflagUser(targetID, options?: { user_id? }); +client.unblockMessage(targetMessageID, options?: { user_id? }); + +// v10 +client.flagMessage(targetMessageId, options?: { reason? }); +client.flagUser(targetId, options?: { reason? }); +client.unflagMessage(targetMessageId); +client.unflagUser(targetId); +client.unblockMessage(targetMessageId); +``` + +`user_id` overrides dropped everywhere. + +#### `client.userMuteStatus` + +```ts +// v9 +client.userMuteStatus(targetID); + +// v10 +client.userMuteStatus(targetId); +``` + +#### `client.getChannelById` / `client.channel(...)` overload + +```ts +// v9 +client.channel(channelType, channelID?, custom?); +client.channel(channelType, custom?); +client.getChannelById(channelType, channelID, custom); + +// v10 — same overload shape; positional param renamed +client.channel(channelType, channelId?, custom?); +client.channel(channelType, custom?); +client.getChannelById(channelType, channelId, custom); +``` + +#### `client.setAnonymousUser` alias + +```ts +// v9 +client.setAnonymousUser = this.connectAnonymousUser; // REMOVED + +// v10 +await client.connectAnonymousUser(); +``` + +#### `client.doAxiosRequest` / `client.dispatchEvent` / `client.errorFromResponse` / `client.sendFile` + +```ts +// v9 — direct methods on the client +client.doAxiosRequest(type, url, data?, options?); +client.dispatchEvent(event); +client.errorFromResponse(response); +client.sendFile(url, uri, name?, contentType?, user?, axiosRequestConfig?); + +// v10 +client.api.doAxiosRequest(type, url, data?, options?); +client.dispatchEvent(event: Event); // Event union expanded to WSEvent | LocalEvent | keyof CustomEventTypes +client.api.errorFromResponse(response); // moved to ApiClient +client.api.sendFile(url, uri, name?, contentType?, user?, axiosRequestConfig?); +``` + +`client.api` is a new getter returning the internal `ApiClient` instance. + +#### `client.uploadFile` / `client.uploadImage` + +```ts +// v9 +client.uploadFile(uri, name?, contentType?, user?, axiosRequestConfig?); +client.uploadImage(uri, name?, contentType?, user?, axiosRequestConfig?); + +// v10 — TWO shapes now exist, pick the right one: +client.uploadFile(request: { file? }); // inherited from ChatApi — generated payload +client.uploadImage(request: { file? }); // inherited from ChatApi + +client.uploadFile_(uri, name?, contentType?, user?, axiosRequestConfig?); // v9 positional args preserved under trailing-underscore name +client.uploadImage_(uri, name?, contentType?, user?, axiosRequestConfig?); +``` + +`uploadFile_` and `uploadImage_` are the direct replacements for v9 code that passed positional args (uri + name + contentType + user + axios config). Ports should prefer these unless the caller wants to switch to the request-object shape. + +#### `client.deleteFile` / `client.deleteImage` + +```ts +// v9 +client.deleteFile(url); +client.deleteImage(url); + +// v10 — inherited +client.deleteFile(request?: { url? }); +client.deleteImage(request?: { url? }); +``` + +#### `client.revokeTokens` + +```ts +// v9 +client.revokeTokens(before: Date | string | null); + +// v10 +client.revokeTokens(before?: Date | null); // string form dropped +``` + +#### `client.getAppSettings` + +Still present but the return type changed (`Gen_GetApplicationResponse` wrapped as `StreamResponse<...>`); no signature change. + +#### `client.partialUpdateThread` + +Unchanged signature: `partialUpdateThread(messageId, partialThreadObject)`. + +#### `client.hydrateActiveChannels` + +Unchanged. + +#### `client.setLocalDevice` / `client.setBaseURL` / `client.setUserAgent` / `client.getUserAgent` + +Unchanged. `setUserAgent` is still marked `@deprecated` — prefer setting `sdkIdentifier`. + +#### `client.createChannelManager` / `client.setOfflineDBApi` / `client.setMessageComposerSetupFunction` + +Unchanged (composer setup function is new in v10 but not a rename). + +#### `client._enrichAxiosOptions` / `client._logApiRequest` / `client._logApiError` / `client._normalizeDate` / `client._setupConnection` + +Removed. Callers should not rely on these internals; `_setupConnection` was an alias for `openConnection`. + +#### `client.recoverState` / `client.connect` / `client._sayHi` / `client._buildWSPayload` + +Signatures unchanged. + +#### `client.queryUserGroups` + +```ts +// v9 — hand-rolled GET on `/usergroups` +client.queryUserGroups(options?: QueryUserGroupsOptions): Promise; +// QueryUserGroupsResponse = APIResponse & { user_groups: UserGroupResponse[] } + +// v10 — inherited from ChatApi (same underlying endpoint) +client.listUserGroups(request?: ListUserGroupsOptions): Promise>; +``` + +Mechanical rewrite: + +```ts +// v9 +const { user_groups } = await client.queryUserGroups({ team_id: 'engineering' }); + +// v10 +const { user_groups } = await client.listUserGroups({ team_id: 'engineering' }); +``` + +`UserGroupPaginator` still exists and now calls `listUserGroups` internally — consumers using the paginator do not need to change anything. Direct callers of `queryUserGroups` must rename to `listUserGroups`. The request shape is identical (`{ limit?, id_gt?, created_at_gt?, team_id? }`); the response gains a `metadata: RequestMetadata` field via the `StreamResponse<...>` wrapper. See the type-renames guide for the `QueryUserGroupsOptions` / `QueryUserGroupsResponse` type entries. + +#### `client.sync` + +```ts +// v9 +client.sync(channel_cids: string[], last_sync_at: string, options?: SyncOptions); + +// v10 — inherited (payload object) +client.sync(request: { channel_cids, last_sync_at, ... }); +``` + +#### `client.createBlockList` / `client.listBlockLists` / `client.updateBlockList` / `client.deleteBlockList` + +```ts +// v9 +client.createBlockList(blockList: BlockList); +client.listBlockLists(data?: { team? }); +client.getBlockList(name, data?: { team? }); // REMOVED +client.updateBlockList(name, data: { words; team? }); +client.deleteBlockList(name, data?: { team? }); + +// v10 — inherited (request objects) +client.createBlockList(request); +client.listBlockLists(request?); +// getBlockList: no replacement. +client.updateBlockList(request); +client.deleteBlockList(request); +``` + +#### Webhook / SNS / SQS helpers + +Moved off the client to module-level exports (`src/signing.ts`): + +```ts +// v9 +client.verifyWebhook(requestBody, xSignature); +client.verifyAndParseWebhook(rawBody, signature); +client.parseSqs(messageBody); +client.parseSns(notificationBody); + +// v10 — module exports; return WSEvent +import { verifySignature, verifyAndParseWebhook, parseSqs, parseSns } from 'stream-chat'; + +verifySignature(body, signature, secret); +verifyAndParseWebhook(rawBody, signature, secret); +parseSqs(messageBody); // SQS deliveries carry no application-level HMAC — decode-only +parseSns(notificationBody); // SNS deliveries carry no application-level HMAC — decode-only +``` + +The v9 `verifyWebhook` / `verifyAndParseWebhook` reused `client.secret` implicitly; the v10 module-level replacements require the secret to be passed in. `parseSqs` / `parseSns` do not take a `secret` — Stream never attaches an application-level HMAC to SQS/SNS deliveries; use `verifyAndParseWebhook` for HTTP webhooks when you need signature verification. + +--- + +## Channel + +### Constructor and lifecycle + +`getClient()`, `getConfig()`, `clean()`, `_channelURL()`, `_checkInitialized()`, `_initializeState(...)`, `_disconnect()`, and `create(options?)` are unchanged. + +### Removed with a rename → note + +- `channel._update(payload)` — REMOVED. Use `channel.update(request)` (inherited from `ChannelApi`). +- `channel.updateMemberPartial(updates, options?: { userId? })` — REMOVED (v9 wrapper). Use the inherited `channel.updateMemberPartial(request?)` — same name, generated shape. +- `channel.partialUpdateMember(user_id, updates)` — REMOVED. Use `channel.updateMemberPartial({ user_id, ...updates })`. +- `channel.sendEvent(event)` — replaced by `channel.sendEvent(request: { event })` (override). + +### Signature-changed methods + +#### `channel.sendMessage` + +```ts +// v9 +channel.sendMessage(message: Message, options?: SendMessageOptions); + +// v10 +channel.sendMessage(request: Gen_SendMessageRequest); +// { message, skip_enrich_url?, skip_push?, keep_channel_hidden?, ... } +``` + +Mechanical rewrite: + +```ts +// v9 +await channel.sendMessage({ text: 'hi' }, { skip_push: true }); + +// v10 +await channel.sendMessage({ message: { text: 'hi' }, skip_push: true }); +``` + +#### `channel.sendEvent` + +```ts +// v9 +channel.sendEvent(event: Event); + +// v10 +channel.sendEvent(request: { event: Event }); +// Event now unions the generated WSEvent, the SDK-only LocalEvent, and keyof CustomEventTypes. +``` + +#### `channel.search` + +```ts +// v9 +channel.search(query: MessageFilters | string, options?); + +// v10 +channel.search(request?: { payload?: SearchPayload }); +``` + +#### `channel.queryMembers` + +```ts +// v9 +channel.queryMembers(filterConditions, sort?, options?); + +// v10 +channel.queryMembers(request?: { payload?: Partial }); +// payload accepts filter_conditions, sort ([{field, direction}]), limit, offset +``` + +For rewriting the `sort` value, see `v9-to-v10-migration-guide-sort.md`. + +#### `channel.sendReaction` / `channel._sendReaction` / `channel.deleteReaction` / `channel._deleteReaction` + +```ts +// v9 +channel.sendReaction(messageID, reaction: Reaction, options?); +channel.deleteReaction(messageID, reactionType, user_id?); + +// v10 +channel.sendReaction(request: Parameters[0]); +// { id: messageId, reaction, enforce_unique?, skip_push? } +channel.deleteReaction(request: Parameters[0]); +// { id: messageId, type: reactionType } +``` + +`user_id` overrides dropped. `_sendReaction` and `_deleteReaction` take the same shape as their public counterparts. + +#### `channel.getReactions` + +```ts +// v9 +channel.getReactions(message_id, options: { limit?; offset? }); + +// v10 +channel.getReactions(request: Parameters[0]); +// { id: messageId, limit?, offset? } +``` + +#### `channel.getReplies` + +```ts +// v9 +channel.getReplies(parent_id, options?, sort?); + +// v10 +channel.getReplies(request: GetRepliesRequest); +// { parent_id, id_gt?, id_lt?, id_gte?, id_lte?, limit?, offset?, sort?, ... } +``` + +`sort` inside the request uses `Gen_SortParamRequest[]` (`{ field, direction }`). See `v9-to-v10-migration-guide-sort.md`. + +#### `channel.update` + +```ts +// v9 +channel.update(channelData?, updateMessage?, options?); + +// v10 (override) +channel.update(request?: Gen_UpdateChannelRequest); +// { data?, message?, skip_push?, hide_history?, ... } +``` + +Mechanical rewrite: + +```ts +// v9 +await channel.update({ name: 'X' }, { text: 'renamed' }); + +// v10 +await channel.update({ data: { name: 'X' }, message: { text: 'renamed' } }); +``` + +#### `channel.updatePartial` + +Same signature: `updatePartial(update: PartialUpdateChannel)`. Internally now calls `updateChannelPartial` (inherited). + +#### `channel.delete` / `channel.truncate` + +```ts +// v9 +channel.delete(options?: { hard_delete? }); +channel.truncate(options?: TruncateOptions); + +// v10 — inherited from ChannelApi +channel.delete(request?: { hard_delete? }); +channel.truncate(request?: TruncateChannelRequest); +// TruncateChannelRequest: { message?, skip_push?, hard_delete?, truncated_at?, user_id? } +``` + +#### `channel.acceptInvite` / `channel.rejectInvite` + +```ts +// v9 +channel.acceptInvite(options?: UpdateChannelOptions); +channel.rejectInvite(options?: UpdateChannelOptions); + +// v10 — options type renamed +channel.acceptInvite(options?: ChannelUpdateOptions); +channel.rejectInvite(options?: ChannelUpdateOptions); +``` + +`ChannelUpdateOptions` = `Omit`. + +#### `channel.mute` / `channel.unmute` + +```ts +// v9 +channel.mute(opts?: { expiration?; user_id? }); +channel.unmute(opts?: { user_id? }); + +// v10 +channel.mute(options?: Gen_MuteChannelRequest); // { channel_cids?, expiration?, user? } +channel.unmute(options?: Gen_UnmuteChannelRequest); // { channel_cids?, user? } +``` + +#### `channel.archive` / `channel.unarchive` / `channel.pin` / `channel.unpin` + +```ts +// v9 +channel.archive(opts?: { user_id? }); +channel.unarchive(opts?: { user_id? }); +channel.pin(opts?: { user_id? }); +channel.unpin(opts?: { user_id? }); + +// v10 — arguments removed; always acts on the connected user +channel.archive(); +channel.unarchive(); +channel.pin(); +channel.unpin(); +``` + +These now delegate to `channel.updateMemberPartial({ set: { archived: true } })` (etc.) internally. + +#### `channel.muteStatus` / `channel.sendAction` / `channel.keystroke` / `channel.stopTyping` + +```ts +// v9 +channel.muteStatus(): { muted: boolean; createdAt: Date | null; expiresAt: Date | null }; +channel.sendAction(messageID, formData); +channel.keystroke(parent_id?, options?: { user_id }); +channel.stopTyping(parent_id?, options?: { user_id }); + +// v10 — same shape; positional rename to `messageId` / `parentId` +channel.muteStatus(); // same return shape +channel.sendAction(messageId, formData); +channel.keystroke(parentId?, options?); +channel.stopTyping(parentId?, options?); +``` + +#### `channel.markRead` / `channel.markAsReadRequest` + +**Semantic swap** — read carefully: + +```ts +// v9 +channel.markRead(data?: MarkReadOptions); // batched through MessageDeliveryReporter +channel.markAsReadRequest(data?: MarkReadOptions); // direct API call + +// v10 +channel.markRead(data?: MarkReadRequest); // direct API call (override, requires _checkInitialized + read_events) +channel.markReadViaReporter(data?: MarkReadRequest); // batched through MessageDeliveryReporter — v9 markRead behavior +``` + +`MarkReadOptions` (v9) → `MarkReadRequest` (v10 generated type). See the type-renames guide. + +Migration rule: if you want to preserve the v9 batching behavior, rename `markRead` → `markReadViaReporter`. If your v9 code was calling `markAsReadRequest`, rename it to `markRead`. + +#### `channel.markUnread` + +```ts +// v9 +channel.markUnread(data: MarkUnreadOptions); + +// v10 — inherited/override; data is optional +channel.markUnread(data?: MarkUnreadRequest); +``` + +#### `channel.stopWatching` + +```ts +// v9 +channel.stopWatching(); + +// v10 — override +channel.stopWatching(request?: Gen_ChannelStopWatchingRequest); +``` + +#### `channel.hide` / `channel.show` + +```ts +// v9 +channel.hide(userId: string | null = null, clearHistory = false); +channel.show(userId: string | null = null); + +// v10 — override; positional args replaced with a request payload +channel.hide(request?: Gen_HideChannelRequest); // { clear_history?, user_id?, ... } +channel.show(request?: Gen_ShowChannelRequest); // { user_id?, ... } +``` + +Mechanical rewrite: + +```ts +// v9 +await channel.hide(null, true); +// v10 +await channel.hide({ clear_history: true }); +``` + +#### `channel.banUser` / `channel.unbanUser` / `channel.shadowBan` / `channel.removeShadowBan` + +Same signatures, positional rename only: + +```ts +channel.banUser(targetUserId, options); +channel.unbanUser(targetUserId, options?); +channel.shadowBan(targetUserId, options); +channel.removeShadowBan(targetUserId); +``` + +#### `channel.vote` / `channel.removeVote` + +```ts +// v9 +channel.vote(messageId, pollId, vote: PollVoteData); +channel.removeVote(messageId, pollId, voteId); + +// v10 +channel.vote(request: Parameters[0]); +// { message_id, poll_id, vote: { option_id?, answer_text? } } +channel.removeVote(request: Parameters[0]); +// { message_id, poll_id, vote_id } +``` + +#### `channel.createDraft` / `channel._createDraft` / `channel.deleteDraft` / `channel._deleteDraft` / `channel.getDraft` + +```ts +// v9 +channel.createDraft(message: DraftMessagePayload); +channel.deleteDraft(options?: { parent_id? }); +channel.getDraft(options?: { parent_id? }); + +// v10 — inherited/override with generated shape +channel.createDraft(request: Gen_CreateDraftRequest); // { message: DraftPayload } +channel.deleteDraft(request?: { parent_id? }); +channel.getDraft(request?: { parent_id? }); // inherited unchanged +channel._createDraft(request); // same shape +channel._deleteDraft(request?); // same shape +``` + +Mechanical rewrite for `createDraft`: + +```ts +// v9 +await channel.createDraft({ text: 'draft' }); + +// v10 +await channel.createDraft({ message: { text: 'draft' } }); +``` + +#### `channel.on` / `channel.off` + +```ts +// v9 — signatures +channel.on(eventType: EventTypes, callback: EventHandler): { unsubscribe: () => void }; +channel.on(callback: EventHandler): { unsubscribe: () => void }; +channel.off(eventType: EventTypes, callback: EventHandler): void; +channel.off(callback: EventHandler): void; + +// v10 +channel.on(eventType: T, callback: EventHandler): { unsubscribe: () => void }; +channel.on(callback: EventHandler): { unsubscribe: () => void }; +channel.off(eventType: T, callback: EventHandler): void; +channel.off(callback: EventHandler): void; +``` + +Callers that imported `EventTypes` need to switch to `EventType` (`EventType = Event['type'] | 'all'`). The `CustomEventTypes` interface is still exported — augment it to add custom event-type keys, same as v9. + +#### `channel.sendFile` / `channel.sendImage` / `channel.deleteFile` / `channel.deleteImage` / `channel.getPinnedMessages` / `channel.getMessagesById` / `channel.lastRead` / `channel.countUnread` / `channel.countUnreadMentions` / `channel.lastMessage` / `channel.watch` / `channel.query` + +Signatures unchanged. + +#### `channel._handleChannelEvent` / `channel._callChannelListeners` + +Both still take `Event` — the union shape of `Event` itself changed (now `WSEvent | LocalEvent | keyof CustomEventTypes`), but the parameter type name did not. + +--- + +## ChannelState + +Mostly unchanged. The relevant tweaks: + +- `formatMessage`: v9 accepted `MessageResponse | MessageResponseBase | LocalMessage`. v10 accepts only `MessageResponse | LocalMessage` (`MessageResponseBase` no longer exists). +- `deleteUserMessages(...)` internally: `deletedAt` propagation now passes `undefined` where v9 defaulted to `null` — check for `null` guards in downstream code. +- `removeReaction(reaction, message?)` return shape unchanged. +- All other methods (`addMessageSorted`, `addMessagesSorted`, `addPinnedMessages`, `addPinnedMessage`, `removePinnedMessage`, `addReaction`, `_addReactionToState`, `_addOwnReactionToMessage`, `_removeOwnReactionFromMessage`, `_removeReactionFromState`, `_updateQuotedMessageReferences`, `removeQuotedMessageReferences`, `_updateMessage`, `setIsUpToDate`, `_addToMessageList`, `removeMessage`, `removeMessageFromArray`, `updateUserMessages`, `filterErrorMessages`, `clean`, `clearMessages`, `initMessages`, `loadMessageIntoState`, `findMessage`, `findMessageByTimestamp`, `pruneOldest`) — signatures unchanged. + +--- + +## Moderation + +`Moderation` now `extends ModerationApi`. All complex admin methods were removed; the kept methods have positional-param renames only. + +### Removed — no replacement in this SDK + +- `moderation.muteUser(targetID, options?: ModerationMuteOptions)` — REMOVED (previously used `POST /api/v2/moderation/mute` directly). Use the inherited `moderation.mute(request: MuteRequest)` from `ModerationApi` (accepts `{ target_ids, timeout?, ... }`). +- `moderation.getUserModerationReport` — REMOVED. +- `moderation.queryReviewQueue` — the class-level implementation is gone but `queryReviewQueue` is inherited from `ModerationApi` (request-object shape). +- `moderation.upsertConfig` / `getConfig` / `deleteConfig` / `queryConfigs` — the class-level implementations are gone; `upsertConfig` / `getConfig` / `deleteConfig` / `queryModerationConfigs` (note the last is renamed) are inherited from `ModerationApi`. +- `moderation.submitAction` — inherited from `ModerationApi`. +- `moderation.check` / `moderation.checkUserProfile` — REMOVED. +- `moderation.addCustomFlags` / `moderation.addCustomMessageFlags` — REMOVED. +- `moderation.upsertModerationRule` / `queryModerationRules` / `getModerationRule` / `deleteModerationRule` — REMOVED. + +### Signature-changed + +#### `moderation.flagUser` / `moderation.flagMessage` + +```ts +// v9 +moderation.flagUser(flaggedUserID, reason, options?); +moderation.flagMessage(messageID, reason, options?); + +// v10 — positional rename only; body still built internally +moderation.flagUser(flaggedUserId, reason, options?); +moderation.flagMessage(messageId, reason, options?); +``` + +#### `moderation.flag` + +```ts +// v9 +moderation.flag(entityType, entityId, entityCreatorID, reason, options?); // custom method + +// v10 — inherited +moderation.flag(request: FlagRequest); +// { entity_type, entity_id, entity_creator_id, reason, ...options } +``` + +#### `moderation.unmuteUser` + +```ts +// v9 +moderation.unmuteUser(targetID, options: { user_id? }); + +// v10 — user_id override dropped (server-side only) +moderation.unmuteUser(targetId); +``` + +--- + +## StableWSConnection + +Parameter renames only (`wsID` → `wsId`) and the internal `_log(msg, extra, level)` method has been removed. Everything else (`connect`, `disconnect`, `_connect`, `_reconnect`, `_waitForHealthy`, `_buildUrl`, `onopen` / `onmessage` / `onclose` / `onerror`, `onlineStatusChanged`, `_setHealth`, `_errorFromWSEvent`, `_destroyCurrentWSConnection`, `_setupConnectionPromise`, `scheduleNextPing`, `scheduleConnectionCheck`) keeps the v9 signature. + +```ts +// v9 +_log(msg, extra?, level?); // REMOVED + +// v10 — use the module-scoped logger instead +import { chatLoggerSystem } from './logger'; +const logger = chatLoggerSystem.getLogger('connection'); +logger.info(msg, extra); +``` + +--- + +## Property renames on `StreamChat` (referenced by other classes) + +| v9 | v10 | Availability | +| -------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `client.userID` | `client.userId` | Getter `userID` deprecated. Assignment (`client.userID = …`) no longer supported. | +| `client.clientID` | `client.clientId` | Getter+setter `clientID` deprecated. | +| `client.secret` | — | REMOVED. | +| `client.logger` | — | REMOVED — see logging note below. | +| `client.appSettingsPromise` type | `Promise>` | Wrapper type changed. | +| `client._user` type | `ClientUser` | Type alias replacing v9's `OwnUserResponse \| UserResponse`. | +| `client.api` | new | Public getter that returns the internal `ApiClient` for `doAxiosRequest` / `sendFile` / `errorFromResponse`. | + +`_setToken`, `_setUser`, `_setupConnection` are still present but `_setUser` now takes `TokenManagerMinimalUser`; `_setupConnection` is REMOVED. + +--- + +## Logging (applies to every class) + +`options.logger` (function) and `client.logger(level, msg, extra?)` are gone. To capture logs in v10, configure the shared `chatLoggerSystem` before constructing the client: + +```ts +import { chatLoggerSystem, type Sink } from 'stream-chat'; + +const sink: Sink = (level, message, ...rest) => { + /* forward to your logger; message is prefixed with `[](): ` */ +}; + +chatLoggerSystem.configureLoggers({ + default: { level: 'info', sink }, +}); +``` + +Class-internal call sites use scoped loggers such as `chatLoggerSystem.getLogger('client')`, `'channel'`, `'connection'`, `'api-client'`, `'thread'`, `'thread-manager'`, `'upload-manager'`, `'offline-db'`, `'state-store'`, `'token-manager'`, `'message-composer'`, `'text-composer'`, `'utils'`, `'channel-manager'`, `'connection-fallback'`. See `v9-to-v10-migration-guide-logging.md` for the full logging system reference. diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md new file mode 100644 index 0000000000..0695515e4f --- /dev/null +++ b/v9-to-v10-migration-guide-other.md @@ -0,0 +1,427 @@ +# v9 → v10 Migration Guide — Everything Else + +> Scope: this guide catches breaking changes **not** covered by the four topic-specific guides: +> +> - `v9-to-v10-migration-guide-client-construction.md` (constructor & options) +> - `v9-to-v10-migration-guide-logging.md` (`chatLoggerSystem`, sinks, scopes) +> - `v9-to-v10-migration-guide-methods.md` (per-method signatures on `StreamChat`, `Channel`, `ChannelState`, `Moderation`, `StableWSConnection`) +> - `v9-to-v10-migration-guide-sort.md` (`SortParamRequest[]` shape) +> +> Read those first. This guide covers **exports, removed feature modules, event-type shape, filter constraints, small state/composer shape changes, and residual type/property renames** that the topic guides do not. + +## TL;DR + +- **Server-side is gone.** If you construct with a `secret` or call server-only admin endpoints, switch to `@stream-io/node-sdk`. The construction guide has the full list — every feature module below that was server-only is dropped for the same reason. +- One barrel removed from the package root, one added: **`./events` is gone; `./logger` is new.** The `./campaign`, `./channel_batch_updater`, and `./segment` barrels are still exported but the modules are emptied (they contain only a comment pointing at the server SDK) — importing anything by name from them will fail. +- `Event` (type name) is kept, but its shape widened: `Event = WSEvent | LocalEvent | keyof CustomEventTypes`. `EventPayload<''>` narrows to a specific event. +- `EventTypes` (plural) renamed to `EventType` (singular). `CustomEventTypes` interface is unchanged — augment it to add custom event-type keys, same as v9. +- Filter payloads now carry **per-endpoint operator constraints** (`Query*FilterConditions` types) — previously-permissive filter objects may stop type-checking. +- `ChannelState.membership` initializes to `undefined` (was `{}`); `ChannelState.typing` values are now `EventPayload<'typing.start' | 'typing.stop'>` (were `Event`); read receipts merged with the generated `ReadStateResponse`. +- Composer attachments now nest `mime_type` / `file_size` / `duration` under `.custom`; `LocationComposer` preview `end_at` is a `Date` (was ISO string). +- `Role` type renamed to `RoleName`. +- Assorted small tightenings: `TokenManager.setTokenOrProvider` user param narrowed, `revokeTokens(before)` no longer accepts `string`, `UserGroupPaginator` cursor field is a `Date`. + +--- + +## Public export surface + +`src/index.ts` barrel changes: + +| Removed export barrel | Reason | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `export * from './events'` | `src/events.ts` deleted along with `EVENT_MAP`. Event-type set is now derived from the generated event decoders, no longer a hand-rolled map. | + +| Emptied module (barrel still present, no named exports) | Reason | +| ------------------------------------------------------- | ------------------------------------------------------------- | +| `./campaign` | `Campaign` was a server-side admin surface; module is a stub. | +| `./segment` | Same as `campaign`. | +| `./channel_batch_updater` | `ChannelBatchUpdater` was a server-side admin surface. | + +| Added export barrel | What it exposes | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `export * from './logger'` | `chatLoggerSystem`, `LogLevel`, `LogLevelEnum`, `Sink`, `ScopedLogger`, `ChatLoggerScope`, `ConfigureLoggersOptions`. See logging guide. | + +Any consumer doing `import { Campaign, Segment, ChannelBatchUpdater, EVENT_MAP } from 'stream-chat'` will fail to resolve. Delete those imports; there is no drop-in replacement in this SDK. `CustomEventTypes` is still exported from `stream-chat` and its interface is unchanged — augment it to declare custom event-type keys the same way as in v9. + +--- + +## Removed feature modules / subsystems + +Beyond the individual server-side methods listed in the methods guide, entire subsystems are gone. If your app used one of these, the client-side wrapper is not coming back — move to `@stream-io/node-sdk`: + +| Subsystem | v9 shape | v10 status | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Campaigns** | `client.campaign`, `queryCampaigns`, `createCampaign`, `startCampaign`, `stopCampaign`, `updateCampaign`, `deleteCampaign`, `getCampaign` | removed | +| **Segments** | `client.segment`, `createSegment`, `createUserSegment`, `createChannelSegment`, `updateSegment`, `getSegment`, `deleteSegment`, `querySegments`, `segmentTargetExists`, `addSegmentTargets`, `removeSegmentTargets`, `querySegmentTargets` | removed | +| **`ChannelBatchUpdater`** | `client.channelBatchUpdater`, `client.updateChannelsBatch(...)` | removed | +| **Retention policies** | `setRetentionPolicy`, `deleteRetentionPolicy`, `getRetentionPolicy`, `getRetentionPolicyRuns` | removed | +| **Team usage stats** | `queryTeamUsageStats` | removed | +| **User groups** | `createUserGroup`, `getUserGroup`, `searchUserGroups`, `updateUserGroup`, `deleteUserGroup`, `addUserGroupMembers`, `removeUserGroupMembers` | mutations removed. Read path is now `listUserGroups` (v9 `queryUserGroups` renamed — see methods guide); `UserGroupPaginator` remains and delegates to `listUserGroups` internally. | +| **Predefined filters (client)** | `deletePredefinedFilter`, `PredefinedFilterSort(Param)` types, `mapPredefinedFilterSortToChannelSort` helper | removed. Read paths remain via the generated API. | +| **Reminder client batch API** | `client.createReminder`, `client.updateReminder`, `client.deleteReminder`, `client.queryReminders` (v9 `QueryRemindersOptions` shape) | hand-rolled `createReminder`/`updateReminder`/`deleteReminder` removed from `StreamChat`. `queryReminders` is still available via `ChatApi` inheritance but takes the generated `QueryRemindersRequest` shape. `ReminderManager` remains — use it. See "Reminders" below for shape change. | +| **Push provider admin** | `upsertPushProvider`, `deletePushProvider`, `listPushProviders`, `setPushPreferences` | removed | +| **Roles / Permissions admin** | `createRole`, `listRoles`, `deleteRole`, `getPermission`, `createPermission`, `updatePermission`, `deletePermission`, `listPermissions` | removed. `searchRoles` remains, inherited from the generated API. | +| **Channel-types admin** | `createChannelType`, `getChannelType`, `updateChannelType`, `deleteChannelType`, `listChannelTypes` | removed | +| **Commands admin** | `createCommand`, `getCommand`, `updateCommand`, `deleteCommand`, `listCommands` | removed | +| **Imports / Exports** | `_createImport`, `_createImportURL`, `_getImport`, `_listImports`, `exportChannel`, `exportChannels`, `exportUsers`, `getExportChannelStatus`, `getTask` | removed | +| **App-settings mutations** | `updateAppSettings`, `testPushSettings`, `testSQSSettings`, `testSNSSettings`, `translate`, `translateMessage`, `getHookEvents` | removed. `getAppSettings` remains. | +| **User admin** | `partialUpdateUser`, `deleteUser`, `restoreUsers`, `reactivateUser(s)`, `deactivateUser(s)`, `exportUser`, `revokeUserToken`, `revokeUsersToken`, `sendUserCustomEvent`, `deleteUsers` | removed | +| **Flag admin** | `_queryFlags`, `_queryFlagReports`, `_reviewFlagReport`, `updateFlags` | removed. `queryMessageFlags` remains via `ChatApi` inheritance (generated request shape). User/message flagging by the connected user remains via `client.flagMessage` / `client.flagUser`. | +| **Webhook / SQS / SNS helpers** | `client.verifyWebhook`, `client.verifyAndParseWebhook`, `client.parseSqs`, `client.parseSns` (used `client.secret` implicitly) | Moved to module exports on `./signing`, `secret` now required explicitly. See methods guide for signatures. | +| **Misc.** | `commitMessage`, `undeleteMessage`, `getSharedLocations`, `updateLocation`, `getUnreadCountBatch`, `getBlockList`, `enrichURL`, `_normalizeDate`, `validateServerSideAuth`, `_setupConnection`, `_enrichAxiosOptions`, `_logApiRequest`, `_logApiError` | removed | + +If your call site was gated on `client._isUsingServerAuth()` (which is also removed), delete the branch — it was only ever true on the server-side path. + +--- + +## Event system + +`src/events.ts` — the single-source `EVENT_MAP` — is **deleted**. Event types are now driven by the generated event decoders (`src/gen/model-decoders/event-decoder-mapping.ts`) plus a small local overlay. The public `Event` type is kept but its definition changed: + +### Union types you'll see + +```ts +// Wire events (over WS) — every generated event type. +type WSEvent = /* union of all generated Gen_*Event shapes */; + +// SDK-only events not received over the wire. +type LocalEvent = ( + | ({ type: 'live_location_sharing.started' } & { message: MessageResponse }) + | ({ type: 'live_location_sharing.stopped' } & { live_location?: SharedLocationResponseData }) + | ({ type: 'channels.queried' } & { + queriedChannels: { + channels: ChannelStateResponseFields[]; + isLatestMessageSet: boolean; + }; + }) + | ({ type: 'transport.changed' } & { mode: string }) + | ({ type: 'connection.changed' } & { online: boolean }) + | { type: 'connection.recovered' } + | ({ type: 'offline_reactions.queried' } & { offlineReactions: ReactionResponse[] }) + | ({ type: 'capabilities.changed' } & { + cid: string; + own_capabilities: ChannelOwnCapability[]; + }) + | ({ type: 'message.read_locally' } & { + channel_type: string; + cid: string; + created_at: Date; + channel_id?: string; + last_read_message_id?: string; + team?: string; + user?: UserResponse; + }) +) & { received_at?: Date }; + +// Public alias — same name as in v9, wider shape. +export type Event = WSEvent | LocalEvent | keyof CustomEventTypes; +export type EventType = Event['type'] | 'all'; +export type EventHandler = (event: Extract) => void; + +export type EventPayload = Extract< + Event, + { type: T } +>; +``` + +### v9 → v10 replacement table + +| v9 | v10 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `import { Event } from 'stream-chat'` | still `import { Event } from 'stream-chat'` — the alias is retained; the union it resolves to widened to `WSEvent \| LocalEvent \| keyof CustomEventTypes`. | +| `Event` as a callback argument type | `Event` still works; prefer `EventPayload<'message.new'>` for narrowed events. | +| `import { EventTypes } from 'stream-chat'` | `import { EventType } from 'stream-chat'` — singular; same shape (`Event['type'] \| 'all'`) | +| `import { EVENT_MAP } from 'stream-chat'` | removed — no runtime table. Match on `event.type` directly. | +| `interface CustomEventTypes { my_custom: 'my_custom'; ... }` (module augmentation) | unchanged — augment `CustomEventTypes` exactly the same way. The interface is still exported from `stream-chat`. | +| Hand-rolled `ReminderEvent`, `PollEvent`, `PollUpdatedEvent`, `PollVoteCastedEvent`, `PollClosedEvent`, `PollAnswerCastedEvent`, `VoteChangedEvent`, `VoteCastedEvent`, `VoteRemovedEvent`, `AnswerCastedEvent`, and similar aliases | replaced by `EventPayload<'reminder.created' \| 'reminder.updated' \| ...>` etc. `ReminderManager.ReminderEvent` now aliases to `EventPayload<`reminder.${string}` \| 'notification.reminder_due'>`. | + +### Narrowing a listener + +```ts +// v9 +client.on('message.new', (event: Event) => { + event.message; // any-typed +}); + +// v10 +client.on('message.new', (event) => { + event.message; // narrowed via EventPayload<'message.new'> +}); + +// Or explicit: +import type { EventPayload } from 'stream-chat'; +const handler = (event: EventPayload<'message.new'>) => event.message; +``` + +### Custom event types (module augmentation) + +The `CustomEventTypes` module-augmentation contract is unchanged from v9: + +```ts +declare module 'stream-chat' { + interface CustomEventTypes { + my_app_custom: 'my_app_custom'; + } +} +``` + +Because the v10 generic on `channel.on` accepts any `string`, unknown listener keys still type-check without augmentation, but the event payload will not be narrowed. Augmenting `CustomEventTypes` adds the custom key to `Event['type']`, which flows through `EventType` and `EventHandler` narrowing. + +> **Larger topic** — the event system rewrite (removed hand-rolled event types across `poll`, `poll_manager`, `thread`, `reminders`, live-location, and the client itself; the shift from a hand-maintained `EVENT_MAP` to generated decoders) touches enough call sites that it may warrant a dedicated guide. Flag me if you want one written. + +--- + +## Filter payloads — per-endpoint operator constraints + +New generated types under `src/gen/models/filter-conditions.ts` narrow what operators are legal per field per endpoint: + +``` +QueryBannedUsersPayloadFilterConditions +QueryChannelsRequestFilterConditions +QueryMembersPayloadFilterConditions +QueryMessageFlagsPayloadFilterConditions +QueryReactionsRequestFilter +QueryThreadsRequestFilter +QueryUsersPayloadFilterConditions +SearchPayloadFilterConditions +SearchPayloadMessageFilterConditions +``` + +Each entry looks like `{ field_name: { type: ; operators: '$eq' | '$in' | ... } }`. The public request types (`QueryChannelsRequest`, `QueryReactionsRequest`, `QueryBannedUsersPayload`, ...) are wrapped with `WithTypedFilters` so `filter_conditions` at the call site can only use operator/value combinations declared in the corresponding constraint. + +**Breaking effect:** any v9 filter object that used an operator not declared for a given field will stop type-checking: + +```ts +// v9 — accepted (typing was permissive) +client.queryChannels({ frozen: { $exists: true } as any }, sort); + +// v10 — QueryChannelsRequestFilterConditions.frozen only declares `{ type: boolean; operators: '$eq' }`. +// This now fails to compile — use { frozen: true } or { frozen: { $eq: true } } instead. +``` + +Field-name typos in `filter_conditions` are now compile errors for endpoints that ship a constraint type (previously only some endpoints narrowed field names). If you were relying on the v9 permissive shape, casting through `as any` is the escape hatch; the correct fix is to use the declared operators. + +`ChannelFilters`, `MessageFilters`, `ReactionFilters`, `UserFilters` etc. still exist as convenience aliases but derive from the constrained request types. + +--- + +## State shape changes + +### `ChannelState.membership` + +```ts +// v9 +membership: ChannelMemberResponse; // initialized to {} +if (channel.state.membership.role === 'admin') { ... } // OK + +// v10 +membership: ChannelMemberResponse | undefined; // initialized to undefined +if (channel.state.membership?.role === 'admin') { ... } // must guard +``` + +Unguarded reads of `channel.state.membership.` now crash on freshly-constructed channels. Add `?.` or a `membership &&` guard at every read site. + +### `ChannelState.typing` + +```ts +// v9 +typing: Record; + +// v10 +typing: Record>; +``` + +Any code that inspected the typing entry's fields is now narrowed to typing-event fields only. Reading `state.typing[userId].message` etc. no longer compiles. + +### `ChannelState.read` (`ChannelReadStatus`) + +The per-user record now composes the generated `ReadStateResponse` plus an SDK-only `first_unread_message_id`: + +```ts +type ChannelReadStatus = Record< + string, + ReadStateResponse & { first_unread_message_id?: string } +>; +``` + +Field names are unchanged (`last_read`, `unread_messages`, `user`, `last_read_message_id`, `last_delivered_at`, `last_delivered_message_id`), but `user` is now `UserResponseCommonFields`-shaped (from the generator) rather than the v9 `UserResponse`. Downstream code that reads fields off `read[uid].user` should be fine; code that assigned back onto it may not. + +### `ChannelState.formatMessage` + +`MessageResponseBase` is removed from the type signature (see methods guide, ChannelState section). Callers passing a hand-rolled `MessageResponseBase`-typed value must cast or reshape to `MessageResponse | LocalMessage`. + +--- + +## Composer & attachment shape + +### Attachment previews — flat metadata moved under `.custom` + +`AttachmentManager.fileToLocalUploadAttachment` (and downstream identity checks) no longer place `mime_type`, `file_size`, or `duration` at the attachment root. They are nested under `custom`: + +```ts +// v9 — flat +{ mime_type: 'image/png', file_size: 1024, type: 'image', duration: 3.5, ... } + +// v10 — nested +{ custom: { mime_type: 'image/png', file_size: 1024, duration: 3.5 }, type: 'image', ... } +``` + +Consequences: + +- `isFileAttachment(a)` and `isVideoAttachment(a)` now read `(a as FileAttachment).custom?.mime_type`. +- `duration` is only populated for `type === 'voiceRecording'` (v9 populated it whenever a `FileReference` carried one). +- Any UI code reading `attachment.mime_type` / `attachment.file_size` from a preview built by the composer must switch to `attachment.custom?.mime_type` / `attachment.custom?.file_size`. + +### `LocationComposer` preview + +```ts +// v9 +export type LiveLocationPreview = Omit & { + durationMs?: number; +}; +// end_at was set to `new Date(...).toISOString()` + +// v10 +export type StaticLocationPreview = StaticLocationPayload & { message_id?: string }; +export type LiveLocationPreview = Omit & { + durationMs?: number; + message_id?: string; +}; +// end_at is now a Date (or undefined when durationMs is not a number) +``` + +If your app called `preview.end_at.toISOString()` or passed `end_at` directly to a `