diff --git a/.claude/skills/docs-sync/SKILL.md b/.claude/skills/docs-sync/SKILL.md new file mode 100644 index 0000000000..b5ef47f723 --- /dev/null +++ b/.claude/skills/docs-sync/SKILL.md @@ -0,0 +1,152 @@ +--- +name: docs-sync +description: Sync the stream-chat-js docs snippet tests (test/docs-snippets) with a getstream.io docs checkout. Asks for the docs repo, runs `yarn test-docs-sync` against it, brings the tests up to date with docs changes (docs win), adds tests for new snippets, and writes fixes for broken snippets back to the docs. Use when the user asks to sync, check or update the docs snippets or docs snippet tests. +argument-hint: '[path to the getstream.io checkout or its content/docs folder]' +--- + +# Docs sync + +The tests in `test/docs-snippets/` run the client-side `JavaScript` snippets of the Stream Chat docs against a real Stream app. Each docs fence has exactly one marked region in a test, and `yarn test-docs-sync` checks that they still agree. This skill makes them agree again after either side changed. + +**Docs have preference.** When a region and its fence differ, the docs version is the source of truth: update the test to match it. Change the docs only when the docs snippet doesn't work (type error against `src/`, runtime error that isn't caused by test setup, wrong response shape), and then write the fix back from the test. + +All paths below are relative to the stream-chat-js repo root unless they start with `$DOCS_DIR`. + +## 1. Find the docs folder + +If the user passed a path as the argument, use it. Otherwise ask for it with AskUserQuestion: "Where is the getstream.io docs checkout?", offering `../getstream.io` (the default sibling checkout) as the first option, and accepting any path through "Other". + +Resolve it to the `content/docs` folder: + +- A repo root (has `content/docs/_sidebars/`): use `/content/docs`. +- A path that already ends in `content/docs` (has `_sidebars/`): use it as is. +- A bare repo name (e.g. `getstream.io`): try it as a sibling of this repo (`../`). +- Anything else, or a folder without `_sidebars/`: tell the user what was checked and ask again. Never guess a path from another machine. + +Make it absolute and call it `$DOCS_DIR`. Pass it as `DOCS_DIR=$DOCS_DIR` on **every** `yarn test-docs-sync` call: without it, the sync check falls back to the sibling checkout and, if that doesn't exist, silently skips the docs comparison. + +Then show the user, before changing anything: + +- the docs repo's current branch and `git status --short` (run git in `$DOCS_DIR`), +- the stream-chat-js branch and `git status --short`. + +If either repo has uncommitted changes, say so: your edits will mix with them. Don't stash, commit or switch branches in either repo. + +## 2. Read the conventions + +Before editing tests, read: + +1. `test/docs-snippets/AGENTS.md`: test conventions and API quirks. Follow it. +2. `test/docs-snippets/README.md`: layout, commands and the snippet-marker format (fence key, COPY, `DOCS_SYNC_WRITE`). +3. `$DOCS_DIR/AGENTS.md`, if it exists: the docs writing rules (e.g. no em-dashes). + +Live tests need `test/docs-snippets/.env` (copy `.env.example`, fill in `STREAM_API_KEY` / `STREAM_API_SECRET` of the docs test app). If it is missing or empty, ask the user for it. Without it you can still sync, typecheck and lint, but you can't tell whether a changed snippet works: say so in the report, and don't change any docs snippet in that case. + +## 3. Run the sync check + +```sh +DOCS_DIR=$DOCS_DIR yarn test-docs-sync +``` + +It runs without network and reports four kinds of failure. Collect them all, grouped by docs page, before fixing anything. + +| Failure | Meaning | +| --- | --- | +| `snippets without a test region` | A `JavaScript` fence on a tested sidebar page (chat JavaScript, platform) has no region: a new snippet, a new page, or a fence whose key changed (see the next row). | +| `JS fences need label=...` | A JS fence is unlabelled or has another label. | +| `no such fence` | A region's key (`docs`, `heading`, `tab`, `index`) matches no fence: the fence was removed, moved, or its heading was renamed. The failure lists the page's fence keys. | +| ` differs from :` | The region's code and the fence's code differ. | + +**Check that the docs branch matches the SDK first.** Because the docs win, syncing against docs written for another stream-chat-js major (e.g. v9 docs against the v10 tests on a `release-v10` based branch) would rewrite working tests to old API code. If many regions across many pages differ, or the differences look like an API change rather than a docs edit (renamed methods, different argument shapes), stop: show the user both branches and a few example differences, and ask whether this is the right docs checkout before changing anything. + +A renamed heading or a reordered fence shows up twice: as `no such fence` for the old key and as a missing region for the new one. Pair them up by code before treating either as a new or removed snippet. + +## 4. Fix each failure + +Work one docs page at a time. + +### Mislabelled fence + +Label it `JavaScript` for client code, `Node.js` for server-side code (with or without `@stream-io/node-sdk`). `Node.js` fences are out of scope: never test, add or change them otherwise. + +### Region differs from its fence (docs changed) + +1. Replace the region's code with the fence's code. Keep the region's COPY line: where the docs use a fixed value that a COPY variable stands for (`channelId="general"`), keep the variable in the region. If the docs changed that literal, update the COPY value. If the docs introduced a new fixed value that would clash with other tests (a fixed user or channel id), add a COPY variable from `uniqueId(...)` for it. +2. Adjust the setup before the region and the assertions after it to the new code (new variables, a different response shape, a new call to assert). +3. Run the page's test: `yarn test-docs `, then `yarn types-docs`. +4. If it passes, done. If it fails, decide why: + - **Test setup** (missing user, permission, channel config, eventual consistency): fix the setup, not the region. + - **SDK bug or wrong type in `src/`** while the snippet is correct: make the smallest fix in `src/` (add or update a unit test under `test/unit/` for runtime fixes), and list it in the report. `yarn types`, `yarn lint` and `yarn test` must still pass. + - **The docs snippet is wrong**: fix it as described in "Broken docs snippet" below. + - **Not sure** whether the snippet or the setup is wrong: don't touch the docs. Leave the test failing or put the snippet in `it.skip('BLOCKED: ', ...)` and report it as an open question. + +### Marker points at no fence + +- **Heading renamed or fence moved/reordered**: update the marker's `heading` / `tab` / `index` to the key the failure lists (it matches the fence with the same code). +- **Fence removed from the docs**: remove the region together with its `it` (or the part of the `it` that only exists for it) and any setup only it used. If a whole page was removed or left the sidebar, remove its test file. + +### New fence without a region + +Write a test for it in the page's test file (create the file if the page is new). The path mirrors the docs path relative to `content/docs/`, without `_default/`: `chat/_default/04-messages/01-send_message.md` → `test/docs-snippets/pages/chat/04-messages/01-send_message.test.ts`. Follow `test/docs-snippets/AGENTS.md` (isolation with `uniqueId`, `Cleanup`, one `it` per snippet, basic `expect`s after each region). In short: + +- Run the snippet with a user token (`getClientSideClient(user)`). Use `getServerClient()` (a `@stream-io/node-sdk` client) only for setup, cleanup and assertions a client can't make. +- Everything between the markers is the docs code; setup goes before, assertions after. Fragments get the minimal surrounding code to compile and run. +- Comment-only fences (e.g. "This is a server-side only feature...") need no region. +- A fence that isn't code (e.g. a bare data shape) is a docs bug: rewrite it as code that runs (e.g. reading and logging the fields), and report it. Never remove a `JavaScript` fence just because it is a fragment or can't be tested: wrap it, or put it in `it.skip`. +- A snippet the test app can't run goes in `it.skip('BLOCKED: ', ...)` so it is still typechecked; report the exact API error. +- **Server-only code in a `JavaScript` fence** (fails with a user token, e.g. 403 code 17 or "only allowed when using server side auth"): remove that fence, or just the server-only part, from the docs. Don't add a `Node.js` tab for it. To decide whether an endpoint or an acting-user field (`user_id`, `created_by_id`, ...) is client-side, check the client-side OpenAPI spec (`chat-clientside-api.json` in https://github.com/GetStream/protocol/tree/main/openapi/v2): an endpoint or field missing from it is server-only. Report every removal with the error. +- A fence from another product (Feeds, `@stream-io/feeds-client`, React / React Native tabs on platform pages) isn't tested: report the page and heading so it can be added to `EXCLUDED_SECTIONS` in `test/docs-snippets/sync/parse.ts`. Don't edit `parse.ts` without asking the user. + +### Broken docs snippet + +Only when the docs snippet itself doesn't work (wrong method name or signature, a type error against `src/`, a runtime API error not caused by setup): + +1. Make the smallest fix in the test region. +2. Write it to the docs: `DOCS_DIR=$DOCS_DIR DOCS_SYNC_WRITE= yarn test-docs-sync`. This writes every region of the page into its fence, with the COPY values applied and the docs prettier style. Never paste snippets into the docs by hand. +3. Check `git diff` in `$DOCS_DIR`: only the intended fences may change. If others changed, a region was out of sync; go back to "Region differs from its fence". +4. Format and lint the page, from `$DOCS_DIR`: + ```sh + npx prettier --write + npx markdownlint-cli2 --config .markdownlint-cli2.cjs + ``` + Never run `npm run ci` or `npm run lint` for the whole docs repo: they rewrite thousands of files, including images. +5. Record the fix for the report: before/after, reason, and evidence (error message or type error). + +## 5. Gates + +All must pass before you finish: + +```sh +DOCS_DIR=$DOCS_DIR yarn test-docs-sync # no missing, mislabelled, unknown or differing snippets +yarn test-docs # green, no LEAK or DRIFT error +yarn types-docs # no type errors, no @ts-ignore / @ts-expect-error / `!` +yarn eslint test/docs-snippets # zero warnings +npx prettier --check test/docs-snippets +``` + +Never silence a docs snippet with `@ts-ignore` / `@ts-expect-error`: a type error in a snippet is a docs bug or an SDK issue. + +If you changed `src/`, also `yarn types`, `yarn lint` and `yarn test`. + +Run `yarn test-docs` only on the test files you changed: pages may change app settings, so never run several docs test files in parallel. + +## Rules + +- Don't commit, push, stash or create branches in either repo. +- Never write the API key or secret to any file except `test/docs-snippets/.env`. +- Don't edit `test/docs-snippets/AGENTS.md`, the helpers or the configs. Propose changes in the report instead. +- Change docs snippets as little as possible, and only when they don't work. +- API and API spec issues (misleading error, inconsistent behavior, a backend that doesn't match the docs) go in the report only, with the page and the exact error. Don't write them to `test/docs-snippets/docs-snippets-todo.md` or any other file. + +## Report + +End with: + +1. **Docs folder** used, and both repos' branches. +2. **Tests updated from the docs**: per page, the regions changed and why the setup/assertions changed. +3. **New and removed tests**: snippets added, regions removed with their fences, renamed markers. +4. **Docs changes**: every fence changed in `$DOCS_DIR` (fixes, labels, removed server-only code), each with before/after, reason and evidence. These need human review. +5. **`src/` fixes**: each change as a diff, with the snippet that needed it. +6. **Open questions**: failing or blocked snippets you couldn't resolve, other-product fences to exclude. +7. **API issues**: API and API spec issues found while testing, each with the page and the exact error. +8. **Proposed additions to `test/docs-snippets/AGENTS.md`**: short, reusable lessons. Skip page-specific details. diff --git a/CLAUDE.md b/CLAUDE.md index 82bc35b58e..f74a5b5282 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth - **`connection/ConnectionIdManager.ts` (`client.connectionIdManager`).** The connection id — the WebSocket's, or the long-poll's after an `enableWSFallback` switch — and the only place the client reads it from: `client._getConnectionID()` / `_hasConnectionID()` read through it and keep no copy. The long-poll keeps no copy either: `WSConnection.disconnect()` reads the id before the socket's `disconnect()` invalidates it, and passes it to `WSConnectionFallback.disconnect()` for the close. The server keys watches and presence by it and answers `200` while registering nothing when it is missing, so `ApiClient._doRequest` holds any request `requiresConnectionId` recognises until one exists — the `watch` / `presence` flags, and nothing else. The flagless operations that declare `connection_id` set it themselves: `StreamChat.stopWatchingChannel` (overridden to always wait for the connection id as the gate would — through a handshake or reconnect, honouring the abort signal, and rejecting when there is no connection and none is being established — then send it) and the long-poll fallback; `test/unit/codegen/connectionIdEndpoints.test.ts` pins that generated endpoint set. `StableWSConnection` drives the lifecycle: `arm()` before a socket opens, `resolveConnectionId()` on the hello frame, `invalidate()` from `_applyHealth(false)` (every status transition, so the error paths are covered) and again from `disconnect()`, and a reject when a reconnect gives up. After an `enableWSFallback` switch `WSConnectionFallback` drives it instead, in the same shape: `arm()` in `connect()`, `resolveConnectionId()` before going healthy, `invalidate()` on going closed or disconnected, and a reject when a connect fails (unless cancelled) or a poll hits a non-retryable error. A deliberate close invalidates rather than rejects: `closeConnection()` exists for mobile backgrounding, so a request already waiting keeps waiting for the socket `openConnection()` will build. `reset()` is the harder variant that fails those waiters instead; nothing in the SDK calls it. A caller's abort signal reaches the wait, so an abandoned request does not hold it. - **`wsConnection/` — `WSConnection` (`client.wsConnection`).** A stable wrapper created with the client and never replaced, owning the socket's reactive status (`state`: `isHealthy`, `lastHealthyAt`, `lastUnhealthyAt`; the connection id lives on `client.connectionIdManager`), its configuration, and the one network-status subscription. The live `StableWSConnection` hangs off `.connection` and _is_ replaced per connect, which is why the store cannot live there — it would be unreadable before `connectUser` and would strand subscribers on every `closeConnection()` → `openConnection()` cycle. The store is written on **every** transition, including `disconnect()` and the error paths. It is the only description of the socket's status: there is no companion event. It also performs the `enableWSFallback` switch and holds the long-poll on `.fallback`, which every later `connect()` reuses and `disconnect()` closes alongside the socket. After the switch, `WSConnectionFallback` writes the store through `_setStatus()` instead — so `isHealthy`, `ConnectionRecoveryManager` and the socket-mirroring network reporter all follow the long-poll — `isConnecting` and the network-status routing follow it too, and `.connection` keeps the disconnected socket. - **No `store.ts`** — `StateStore` comes from `@stream-io/state-store`, imported directly wherever it is used and **not** re-exported from the root barrel (see "State and subscription patterns" below). -- **`signing.ts` — one function, `UserFromToken`.** Decodes a JWT payload with the global `atob` and returns `user_id`. Everything else this module used to hold was server-side (JWT minting via `jsonwebtoken`, webhook/SQS/SNS verification via `crypto` + `zlib`) and was removed along with those deps — see `v9-to-v10-migration-guide-server-side.md`. Do not reintroduce secret-holding or HMAC code here; that surface lives in `@stream-io/node-sdk`. +- **`signing.ts` — `UserFromToken` and `DevToken`.** `UserFromToken` decodes a JWT payload with the global `atob` and returns `user_id`; `DevToken` (also `client.devToken`) builds the unsigned development token (fixed `devtoken` signature, accepted only while the app disables auth checks), so it holds no secret. Everything else this module used to hold was server-side (JWT minting via `jsonwebtoken`, webhook/SQS/SNS verification via `crypto` + `zlib`) and was removed along with those deps — see `v9-to-v10-migration-guide-server-side.md`. Do not reintroduce secret-holding or HMAC code here; that surface lives in `@stream-io/node-sdk`. - **`middleware.ts`** — `MiddlewareExecutor` (see "Middleware pipelines" below). Used by composer pipelines, not by client request lifecycle. - **`token_manager.ts`** — handles static tokens and async token providers. Tracks a `loadTokenPromise` so concurrent calls await the same fetch. The constructor takes no arguments: there is no `secret` and no local JWT signing — every token comes from the caller (a string or a `TokenProvider`). Anonymous users may have no token at all; anyone else without one now fails at `getToken()` rather than at `setTokenOrProvider()`. - **Event types.** There is no `events.ts` / `EVENT_MAP` any more (removed in v10). Wire events come from the generated `WSEvent` union (`src/gen/models`) so adding one means regenerating rather than hand-editing. There is no runtime decoder layer any more: `--opt response_dates_as_number` types every server-sent date as the unix-nanosecond number the wire already carries, so `src/gen/model-decoders/` (including `event-decoder-mapping.ts`) is no longer emitted at all, and `src/connection/wsConnection/StableWSConnection.ts` decodes frames with a plain cast. The unit invariant and its traps live in `src/utils/time.ts`; the consumer-facing delta is `v9-to-v10-migration-guide-dates.md`. `src/types.ts` overlays one non-generated member onto the public `Event` union, `LocalEvent` — `channels.queried`, `connection.recovered`, `capabilities.changed`, `message.read_locally`, `offline_reactions.queried`, `live_location_sharing.*`, `connection.fallback_activated` (the `enableWSFallback` switch to long-poll), all dispatched client-side only and never received over the wire — and declares `received_at` on every `WSEvent` member, because `dispatchEvent` stamps it on all of them while the spec omits it from some (`connection.ok`, `connection.error`). diff --git a/src/channel.ts b/src/channel.ts index 88681fb632..b524b6613c 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -28,6 +28,7 @@ import { copyConfigPatch } from './configuration/utils/copyConfigPatch'; import { deepFreezeConfig } from './configuration/utils/deepFreezeConfig'; import { mergeServerRestrictions } from './configuration/utils/serverAuthority'; import type { ServerRestrictions } from './configuration/utils/serverAuthority'; +import type { CustomEventData } from './custom_types'; import type { ChannelDeclarativeConfig } from './configuration/types'; import { mergeDeclarativeMessageOperationsConfig, @@ -638,21 +639,6 @@ export class Channel extends ChannelApi { return this.deleteChannelImage({ url }, requestOptions); } - /** - * Sends an event on this channel. - * - * @param request - For example `{ event: { type: 'message.read' } }`. - * @param requestOptions - Per-request options such as an abort `signal`. Never serialized - * into the request (optional). - * @returns The server response. - */ - override async sendEvent( - request: { event: Event }, - requestOptions?: StreamRequestOptions, - ) { - return await super.sendEvent(request, requestOptions); - } - /** * Queries members. * @@ -828,7 +814,7 @@ export class Channel extends ChannelApi { * @returns The server response. */ async acceptInvite( - options: ChannelUpdateOptions = {}, + options: NonNullable[0]> = {}, requestOptions?: StreamRequestOptions, ) { return await this.update({ accept_invite: true, ...options }, requestOptions); @@ -843,7 +829,7 @@ export class Channel extends ChannelApi { * @returns The server response. */ async rejectInvite( - options: ChannelUpdateOptions = {}, + options: NonNullable[0]> = {}, requestOptions?: StreamRequestOptions, ) { return await this.update({ reject_invite: true, ...options }, requestOptions); @@ -1147,15 +1133,10 @@ export class Channel extends ChannelApi { * @see {@link https://getstream.io/chat/docs/typing_indicators/?language=js|Docs} * * @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). * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). */ - async keystroke( - parentId?: string, - options?: { user_id: string }, - requestOptions?: StreamRequestOptions, - ) { + async keystroke(parentId?: string, requestOptions?: StreamRequestOptions) { if (!this._isTypingIndicatorsEnabled()) { return; } @@ -1171,9 +1152,6 @@ export class Channel extends ChannelApi { event: { type: 'typing.start', parent_id: parentId, - ...(options || {}), - created_at: nowNs(), - custom: {}, }, }, requestOptions, @@ -1201,12 +1179,14 @@ export class Channel extends ChannelApi { await this.sendEvent( { event: { - ...options, type: 'ai_indicator.update', - message_id: messageId, - ai_state: state, - created_at: nowNs(), - custom: {}, + // The API reads the AI indicator fields from `custom` and puts them on the event it + // delivers. They aren't app custom data, hence the assertion. + custom: { + ...options, + message_id: messageId, + ai_state: state, + } as CustomEventData, }, }, requestOptions, @@ -1230,8 +1210,6 @@ export class Channel extends ChannelApi { { event: { type: 'ai_indicator.clear', - created_at: nowNs(), - custom: {}, }, }, requestOptions, @@ -1255,8 +1233,6 @@ export class Channel extends ChannelApi { { event: { type: 'ai_indicator.stop', - created_at: nowNs(), - custom: {}, }, }, requestOptions, @@ -1269,15 +1245,10 @@ export class Channel extends ChannelApi { * @see {@link https://getstream.io/chat/docs/typing_indicators/?language=js|Docs} * * @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). * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). */ - async stopTyping( - parentId?: string, - options?: { user_id: string }, - requestOptions?: StreamRequestOptions, - ) { + async stopTyping(parentId?: string, requestOptions?: StreamRequestOptions) { if (!this._isTypingIndicatorsEnabled()) { return; } @@ -1288,9 +1259,6 @@ export class Channel extends ChannelApi { event: { type: 'typing.stop', parent_id: parentId, - ...(options || {}), - created_at: nowNs(), - custom: {}, }, }, requestOptions, diff --git a/src/client.ts b/src/client.ts index f1ca8635d7..4838ea9b6a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -9,6 +9,7 @@ import type { ChannelMuteStatus } from './channel_state'; import { ChannelWatchStatus } from './channel_state'; import { ClientState } from './client_state'; import { UploadManager } from './uploadManager'; +import { DevToken } from './signing'; import { TokenManager, type TokenManagerMinimalUser } from './token_manager'; import { ApiClient } from './api-client'; import { @@ -54,6 +55,7 @@ import type { UnBanUserOptions, UpdateUserPartialRequest, UserMuteResponse, + UserRequest, UserResponse, } from './types'; import { isWSFailure } from './errors'; @@ -516,6 +518,17 @@ export class StreamChat extends ChatApi { this.offlineDb = offlineDBInstance; } + /** + * Creates a development token for `userId`, for apps with auth checks disabled. Never use one in + * production. See {@link DevToken}. + * + * @param userId - The id of the user the token is for. + * @returns The development token. + */ + devToken(userId: string) { + return DevToken(userId); + } + getAuthType() { return this.anonymous ? 'anonymous' : 'jwt'; } @@ -940,7 +953,7 @@ export class StreamChat extends ChatApi { * into the request (optional). * @returns A promise that resolves when the connection is set up. */ - async setGuestUser(user: UserResponse, requestOptions?: StreamRequestOptions) { + async setGuestUser(user: UserRequest, requestOptions?: StreamRequestOptions) { const response = await this.createGuest({ user }, requestOptions); const { @@ -1560,8 +1573,8 @@ export class StreamChat extends ChatApi { * @returns The hydrated channel list, or the full response when `withResponse` is `true`. */ async queryChannelsAndHydrate( - options?: QueryChannelsRequest, - stateOptions?: ChannelStateOptions & { withResponse: true }, + options: QueryChannelsRequest | undefined, + stateOptions: ChannelStateOptions & { withResponse: true }, requestOptions?: StreamRequestOptions, ): Promise; async queryChannelsAndHydrate( @@ -2077,8 +2090,8 @@ export class StreamChat extends ChatApi { const parentMessages = response.threads.map((thread) => thread.parent_message!); this.polls.hydratePollCache(parentMessages); - const threads = response.threads.map( - (thread) => new Thread({ client: this, threadData: thread }), + const threads = response.threads.map((thread) => + this.threads.resolveQueried(new Thread({ client: this, threadData: thread })), ); if (optionsWithDefaults.watch) threads.forEach(markThreadChannelWatched); @@ -2120,7 +2133,9 @@ export class StreamChat extends ChatApi { requestOptions, ); - const thread = new Thread({ client: this, threadData: response.thread }); + const thread = this.threads.resolveQueried( + new Thread({ client: this, threadData: response.thread }), + ); if (optionsWithDefaults.watch) markThreadChannelWatched(thread); return thread; diff --git a/src/gen/chat/ChannelApi.ts b/src/gen/chat/ChannelApi.ts index 9ce44d7702..07748ba6c5 100644 --- a/src/gen/chat/ChannelApi.ts +++ b/src/gen/chat/ChannelApi.ts @@ -15,6 +15,8 @@ import type { MarkReadRequest, MarkReadResponse, MarkUnreadRequest, + QueryChannelBansRequest, + QueryChannelBansResponse, Response, SendEventRequest, SendMessageRequest, @@ -121,6 +123,23 @@ export class ChannelApi { ); } + queryChannelBans( + request?: QueryChannelBansRequest, + requestOptions?: StreamRequestOptions, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.queryChannelBans( + { id: this.id, type: this.type }, + request, + requestOptions, + ); + } + deleteDraft( request?: { parent_id?: string }, requestOptions?: StreamRequestOptions, diff --git a/src/gen/chat/ChatApi.ts b/src/gen/chat/ChatApi.ts index d96aaf1387..c7217046c3 100644 --- a/src/gen/chat/ChatApi.ts +++ b/src/gen/chat/ChatApi.ts @@ -27,6 +27,7 @@ import type { DeleteMessageResponse, DeleteReactionResponse, DeleteReminderResponse, + DeleteScheduledMessageResponse, EventResponse, FileUploadRequest, FileUploadResponse, @@ -69,6 +70,8 @@ import type { PollVotesResponse, QueryBannedUsersPayload, QueryBannedUsersResponse, + QueryChannelBansRequest, + QueryChannelBansResponse, QueryChannelsRequest, QueryChannelsResponse, QueryDraftsRequest, @@ -85,6 +88,8 @@ import type { QueryReactionsResponse, QueryRemindersRequest, QueryRemindersResponse, + QueryScheduledMessagesRequest, + QueryScheduledMessagesResponse, QueryThreadsRequest, QueryThreadsResponse, QueryUsersPayload, @@ -101,6 +106,8 @@ import type { SendMessageResponse, SendReactionRequest, SendReactionResponse, + SendScheduledMessageNowRequest, + SendScheduledMessageNowResponse, SharedLocationResponse, SharedLocationsResponse, ShowChannelRequest, @@ -134,6 +141,8 @@ import type { UpdatePollRequest, UpdateReminderRequest, UpdateReminderResponse, + UpdateScheduledMessageRequest, + UpdateScheduledMessageResponse, UpdateThreadPartialRequest, UpdateThreadPartialResponse, UpdateUserGroupRequest, @@ -421,6 +430,22 @@ export class ChatApi { ); } + queryChannelBans( + pathParams: { type: string; id: string }, + request?: QueryChannelBansRequest, + requestOptions?: StreamRequestOptions, + ): Promise> { + return this.apiClient.sendRequest( + 'POST', + '/api/v2/chat/channels/{type}/{id}/bans/query', + pathParams, + undefined, + request ?? {}, + 'application/json', + requestOptions, + ); + } + deleteDraft( pathParams: { type: string; id: string }, request?: { parent_id?: string }, @@ -1131,6 +1156,69 @@ export class ChatApi { ); } + queryScheduledMessages( + request?: QueryScheduledMessagesRequest, + requestOptions?: StreamRequestOptions, + ): Promise> { + return this.apiClient.sendRequest( + 'POST', + '/api/v2/chat/scheduled_messages/query', + undefined, + undefined, + request ?? {}, + 'application/json', + requestOptions, + ); + } + + deleteScheduledMessage( + pathParams: { id: string }, + _request?: Record, + requestOptions?: StreamRequestOptions, + ): Promise> { + return this.apiClient.sendRequest( + 'DELETE', + '/api/v2/chat/scheduled_messages/{id}', + pathParams, + undefined, + undefined, + undefined, + requestOptions, + ); + } + + updateScheduledMessage( + pathParams: { id: string }, + request?: UpdateScheduledMessageRequest, + requestOptions?: StreamRequestOptions, + ): Promise> { + return this.apiClient.sendRequest( + 'PATCH', + '/api/v2/chat/scheduled_messages/{id}', + pathParams, + undefined, + request ?? {}, + 'application/json', + requestOptions, + ); + } + + sendScheduledMessageNow( + pathParams: { id: string }, + request?: SendScheduledMessageNowRequest, + requestOptions?: StreamRequestOptions, + ): Promise> { + return this.apiClient.sendRequest( + 'POST', + '/api/v2/chat/scheduled_messages/{id}/send_now', + pathParams, + undefined, + request ?? {}, + 'application/json', + requestOptions, + ); + } + search( request?: { payload?: SearchPayload }, requestOptions?: StreamRequestOptions, diff --git a/src/gen/models/index.ts b/src/gen/models/index.ts index 59d0fcb444..934eb582c3 100644 --- a/src/gen/models/index.ts +++ b/src/gen/models/index.ts @@ -48,6 +48,11 @@ export type Filters< }> | ('$eq' extends FCHelper[Property]['operators'] ? FilterValue[Property], '$eq'> + : never) + // `allowEmpty` marks a key whose empty filter (`{}`) has a meaning of its own, + // e.g. `team: {}` (all teams) in a multi-tenant channel query. + | (FCHelper[Property] extends { allowEmpty: true } + ? Record : never); }>; @@ -86,10 +91,14 @@ export type CustomHelper = { // string extends string = true // string extends "custom-string" = false // using this "hack" to omit Record types when custom types are not specified + // Object-valued custom fields are left to the `custom.${string}` index signature: a typed key with + // an object value would conflict with it (filters on nested custom data address `custom.a.b`). [P in keyof T['type'] as string extends P ? never : P extends string - ? `custom.${P}` + ? NonNullable extends string | boolean | number | Date + ? `custom.${P}` + : never : never]-?: { type: NonNullable; operators: T['operators']; @@ -182,6 +191,7 @@ export interface AIIndicatorClearEvent { */ cid?: string; received_at?: TimestampNS; + user?: UserResponseCommonFields; } export interface AIIndicatorStopEvent { @@ -207,6 +217,7 @@ export interface AIIndicatorStopEvent { */ cid?: string; received_at?: TimestampNS; + user?: UserResponseCommonFields; } export interface AIIndicatorUpdateEvent { @@ -244,6 +255,7 @@ export interface AIIndicatorUpdateEvent { */ cid?: string; received_at?: TimestampNS; + user?: UserResponseCommonFields; } export interface AITextConfig { @@ -291,6 +303,10 @@ export interface APIError { * Additional error-specific information */ details: Array; + /** + * Milliseconds until the user's slow mode cool down period ends. Only set on code 60 (cool down) errors + */ + cooldown_remaining_ms?: number; /** * 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 */ @@ -388,6 +404,17 @@ export interface AddUserGroupMembersResponse { user_group?: UserGroupResponse; } +export interface AppDefaults { + /** + * Defaults applied by the get-or-create channel endpoint. + */ + get_or_create_channel: GetOrCreateChannelDefaults; + /** + * Defaults applied by the query channels endpoint. + */ + query_channels: QueryChannelsDefaults; +} + export interface AppEventResponse { /** * boolean @@ -420,6 +447,10 @@ export interface AppResponseFields { */ name: string; placement: string; + /** + * Request limit defaults applied by the server when the corresponding field is omitted. + */ + defaults: AppDefaults; file_upload_config: FileUploadConfig; image_upload_config: FileUploadConfig; } @@ -714,11 +745,19 @@ export interface BanRequest { * ID of the user to ban */ target_user_id: string; + /** + * Whether to also ban the user from future channels created by the banner + */ + ban_from_future_channels?: boolean; /** * Channel where the ban applies */ channel_cid?: string; delete_messages?: 'soft' | 'pruning' | 'hard'; + /** + * Whether to delete reactions by the banned user on other users' messages + */ + delete_reactions?: boolean; /** * Whether to ban the user's IP address */ @@ -1027,6 +1066,33 @@ export interface CastPollVoteRequest { vote?: VoteData; } +export interface ChannelBanResponse { + created_at: TimestampNS; + scopes: Array; + channel_ban?: ChannelBanScopeResponse; + future_channel_ban?: ChannelBanScopeResponse; + /** + * User response object + */ + user?: UserResponse; +} + +export interface ChannelBanScopeResponse { + created_at: TimestampNS; + shadow: boolean; + expires?: TimestampNS; + reason?: string; + /** + * User response object + */ + banned_by?: UserResponse; +} + +export interface ChannelBansScopeTotals { + channel: number; + future_channel: number; +} + export interface ChannelConfigOverrides { blocklist?: string; blocklist_behavior?: 'flag' | 'block'; @@ -1258,6 +1324,10 @@ export interface ChannelGetOrCreateRequest { * Whether this channel will be hidden for the user who created the channel or not */ hide_for_creator?: boolean; + /** + * Adds scheduled_messages to the response: the caller's own scheduled, sending and failed messages in this channel. Client-side only. Absent from the response when there are more than 100 or they could not be loaded; use the scheduled messages query then + */ + include_scheduled_messages?: boolean; /** * Fetch user presence info */ @@ -1460,6 +1530,7 @@ export interface ChannelMemberResponse { * Date/time of the last update */ updated_at: TimestampNS; + user_id: string; custom: CustomMemberData; archived_at?: TimestampNS; /** @@ -1497,7 +1568,6 @@ export interface ChannelMemberResponse { */ role?: string; status?: string; - user_id?: string; deleted_messages?: Array; /** * User response object @@ -1568,6 +1638,7 @@ export const ChannelOwnCapability = { QUERY_POLL_VOTES: 'query-poll-votes', QUOTE_MESSAGE: 'quote-message', READ_EVENTS: 'read-events', + SCHEDULE_MESSAGE: 'schedule-message', SEARCH_MESSAGES: 'search-messages', SEND_CUSTOM_EVENTS: 'send-custom-events', SEND_LINKS: 'send-links', @@ -1716,16 +1787,54 @@ export interface ChannelResponse { export interface ChannelStateResponse { duration: string; + /** + * List of channel members + */ members: Array; + /** + * List of channel messages + */ messages: Array; + /** + * List of pinned messages in the channel + */ pinned_messages: Array; threads: Array; + /** + * Milliseconds until the current user's slow mode cool down period ends. Only set on client-side get or create channel and get channel requests while the user is under cool down + */ + cooldown_remaining_ms?: number; + /** + * Whether this channel is hidden or not + */ hidden?: boolean; + /** + * Messages before this date are hidden from the user + */ hide_messages_before?: TimestampNS; + /** + * 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; + /** + * The caller's own scheduled, sending and failed messages in this channel. Set only by a client-side GetOrCreateChannel with include_scheduled_messages; absent when not requested or not known, in which case use the scheduled messages query + */ + scheduled_messages?: Array; + /** + * List of user who is watching the channel + */ watchers?: Array; /** * Represents channel in chat @@ -1753,6 +1862,10 @@ export interface ChannelStateResponseFields { */ pinned_messages: Array; threads: Array; + /** + * Milliseconds until the current user's slow mode cool down period ends. Only set on client-side get or create channel and get channel requests while the user is under cool down + */ + cooldown_remaining_ms?: number; /** * Whether this channel is hidden or not */ @@ -2322,6 +2435,11 @@ export interface ContentCountRuleParameters { time_window?: string; } +export interface ContentCountryCodeParameters { + operator?: string; + country_codes?: Array; +} + export interface ContentCustomPropertyCountParameters { operator?: string; property_key?: string; @@ -2672,6 +2790,31 @@ export interface DeleteModerationConfigResponse { duration: string; } +export interface DeletePollAnswerRequestPayload { + /** + * Reason for deletion + */ + reason?: string; +} + +export interface DeletePollOptionRequestPayload { + /** + * Option to delete. Required on an item for a whole poll (create or edit); defaults to the option an add_option / update_option item was raised for + */ + option_id?: string; + /** + * Reason for deletion + */ + reason?: string; +} + +export interface DeletePollRequestPayload { + /** + * Reason for deletion + */ + reason?: string; +} + export interface DeleteQueueRequest {} export interface DeleteReactionRequestPayload { @@ -2712,6 +2855,17 @@ export interface DeleteReminderResponse { duration: string; } +export interface DeleteScheduledMessageResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + /** + * A message scheduled to be sent later + */ + scheduled_message: ScheduledMessageResponse; +} + export interface DeleteUserMessagesRequestPayload { /** * Message deletion mode: soft, pruning, or hard @@ -2993,7 +3147,6 @@ export interface EntityCreatorResponse { image?: string; last_active?: TimestampNS; name?: string; - revoke_tokens_issued_before?: TimestampNS; teams_role?: Record; } @@ -3091,6 +3244,7 @@ export interface FeedsNotificationComment { comment: string; id: string; user_id: string; + parent_id?: string; attachments?: Array; } @@ -3261,6 +3415,7 @@ export interface FeedsV3CommentResponse { confidence_score: number; created_at: TimestampNS; downvote_count: number; + hidden: boolean; id: string; object_id: string; object_type: string; @@ -3504,7 +3659,6 @@ export interface FullUserResponse { image?: string; last_active?: TimestampNS; name?: string; - revoke_tokens_issued_before?: TimestampNS; latest_hidden_channels?: Array; /** * The privacy settings of the user. @@ -3651,6 +3805,17 @@ export interface GetOGResponse { giphy?: Images; } +export interface GetOrCreateChannelDefaults { + /** + * Resolved defaults for a nested pagination object. + */ + members: PagerDefaults; + /** + * Resolved defaults for a nested pagination object. + */ + messages: PagerDefaults; +} + export interface GetPinnedMessagesResponse { /** * Duration of the request in milliseconds @@ -3706,15 +3871,38 @@ export interface GroupedChannelsBucket { */ prev?: string; /** - * Unread channels currently classified into this bucket + * Total unread channels matching this bucket and its filters, independent of page size */ unread_channels?: number; } export interface GroupedChannelsGroupRequest { + /** + * Max channels for this group; omitted or zero uses the request limit + */ limit?: number; + /** + * Cursor for the next page; only allowed when requesting one group + */ next?: string; + /** + * Cursor for the previous page; only allowed when requesting one group + */ prev?: string; + /** + * Optional query_timestamp $gte bound for a stored group, requiring query_timestamp sort; do not repeat its group name. For all, combine group equality/$in and date bounds with $or, or use one standalone group $ne exclusion. An empty object matches a stored group; omitted or null preserves legacy grouping. + */ + filter_conditions?: Filters<{ + group: { + type: string; + operators: '$eq' | '$in' | '$ne'; + }; + + query_timestamp: { + type: Date | string; + operators: '$gte'; + }; + }>; } export interface GroupedQueryChannelsRequest { @@ -3731,7 +3919,11 @@ export interface GroupedQueryChannelsRequest { */ 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. + * Optional query_timestamp descending with NULLS LAST when enabled. Omitted sort preserves existing last_updated ordering and cursor formats + */ + sort?: Array; + /** + * Groups to return, keyed by stored group name. Each entry can define limit, next, prev, and optional filter_conditions: date bounds for stored groups; all supports combined group/date branches or one group $ne exclusion. Cursors are only allowed when requesting exactly one group. Omitted or null filters preserve legacy grouping, including all. */ groups?: Record; } @@ -3742,7 +3934,7 @@ export interface GroupedQueryChannelsResponse { */ duration: string; /** - * Predefined channel groups keyed by group name + * Groups keyed by the requested stored group name */ groups: Record; } @@ -3890,66 +4082,31 @@ export interface IntentConfigResponse { topics: Array; } +export interface IntentRuleParameters { + topic: string; + threshold?: number; +} + export interface IntentTopicRequest { /** * Unique topic label (e.g. purchase_intent) */ label: string; - /** - * How long the buffer collects items before scoring runs - */ - analysis_cooldown_seconds?: number; /** * Optional description used in the classification prompt; the label alone is used when empty */ description?: string; - /** - * Whether this topic is evaluated - */ - enabled?: boolean; - /** - * Items buffered per conversation before scoring runs early - */ - max_captured_items?: number; - /** - * Suppresses this topic for a user and conversation after it fires; 0 uses the default - */ - refire_cooldown_seconds?: number; - /** - * Conversation score (0-100) required to fire moderation.intent_detected - */ - score_threshold?: number; } export interface IntentTopicResponse { - /** - * How long the buffer collects items before scoring runs - */ - analysis_cooldown_seconds: number; - /** - * Whether this topic is evaluated - */ - enabled: boolean; /** * Unique topic label (e.g. purchase_intent) */ label: string; - /** - * Items buffered per conversation before scoring runs early - */ - max_captured_items: number; - /** - * Conversation score (0-100) required to fire moderation.intent_detected - */ - score_threshold: number; /** * Optional description used in the classification prompt; the label alone is used when empty */ description?: string; - /** - * Suppresses this topic for a user and conversation after it fires; 0 uses the default - */ - refire_cooldown_seconds?: number; } export interface KeyframeOCRRuleParameters { @@ -4483,6 +4640,9 @@ export interface MessageModerationResult { * User who moderated the message */ moderated_by?: string; + /** + * Response from AI moderation + */ ai_moderation_response?: ModerationResponse; /** * Sets thresholds for AI moderation @@ -5362,9 +5522,21 @@ export interface ModerationQueueResponse { } export interface ModerationResponse { + /** + * Action taken by automod + */ action: string; + /** + * Explicit score + */ explicit: number; + /** + * Spam score + */ spam: number; + /** + * Toxicity score + */ toxic: number; } @@ -5458,7 +5630,20 @@ export interface NotificationAddedToChannelEvent { * 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 + */ + unread_count?: number; channel_custom?: CustomChannelData; + grouped_unread_channels?: Record; } export interface NotificationChannelDeletedEvent { @@ -6067,6 +6252,7 @@ export interface OwnUserResponse { unread_count: number; unread_threads: number; updated_at: TimestampNS; + blocked_user_ids: Array; channel_mutes: Array; devices: Array; mutes: Array; @@ -6078,8 +6264,6 @@ export interface OwnUserResponse { image?: string; last_active?: TimestampNS; name?: string; - revoke_tokens_issued_before?: TimestampNS; - blocked_user_ids?: Array; latest_hidden_channels?: Array; /** * The privacy settings of the user. @@ -6093,6 +6277,13 @@ export interface OwnUserResponse { total_unread_count_by_team?: Record; } +export interface PagerDefaults { + /** + * Number of records returned when `limit` is omitted. + */ + limit: number; +} + export interface PaginationParams { id_gt?: number; id_gte?: number; @@ -6447,12 +6638,20 @@ export interface PushPreferenceInput { * Remove the disabled until time. (IE stop snoozing notifications) */ remove_disable?: boolean; + /** + * Remove the user's quiet-hours schedule, the counterpart of remove_disable. It is applied before schedule, so sending both in one item replaces the stored schedule instead of merging into it. Cannot be combined with channel_cid. To switch from a schedule to a snooze, send remove_schedule together with disabled_until. + */ + remove_schedule?: 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; + /** + * A recurring quiet-hours schedule: while any quiet window is active, push notifications for the user are suppressed. Suppressed notifications are dropped, not delayed. + */ + schedule?: PushScheduleInput; } export interface PushPreferencesResponse { @@ -6468,6 +6667,32 @@ export interface PushPreferencesResponse { feeds_level?: string; chat_preferences?: ChatPreferencesResponse; feeds_preferences?: FeedsPreferencesResponse; + /** + * A recurring quiet-hours schedule: while any quiet window is active, push notifications for the user are suppressed. Suppressed notifications are dropped, not delayed. + */ + schedule?: PushScheduleResponse; +} + +export interface PushScheduleInput { + /** + * IANA time zone name the quiet windows are evaluated in, for example Europe/Amsterdam or America/New_York. Daylight saving time is followed automatically for region-based names. Offsets such as +02:00 and the name "Local" are rejected; fixed-offset zones such as UTC or Etc/GMT-2 are accepted but never shift for daylight saving time. When updating an existing schedule, a non-empty timezone replaces the stored one and leaves the stored quiet_hours untouched. + */ + timezone?: string; + /** + * The quiet windows, 1 to 7 rules. Push is suppressed while any rule is active. When present it replaces all stored rules; omit it to keep them, for example to change only the timezone. An empty array counts as omitted, so it cannot be used to clear the rules: use remove_schedule for that. + */ + quiet_hours?: Array; +} + +export interface PushScheduleResponse { + /** + * IANA time zone name the quiet windows are evaluated in, for example Europe/Amsterdam. + */ + timezone: string; + /** + * The quiet windows. Push is suppressed while any rule is active. + */ + quiet_hours: Array; } export interface QueryAppealsRequest { @@ -6620,6 +6845,66 @@ export interface QueryBannedUsersResponse { bans: Array; } +export interface QueryChannelBansRequest { + /** + * Whether to exclude expired bans or not + */ + exclude_expired_bans?: boolean; + /** + * When true, the response includes total and total_by_scope: the number of banned users overall and per scope, independent of paging + */ + include_total?: boolean; + limit?: number; + next?: string; + prev?: string; + /** + * Filter conditions to apply to the query + */ + filter_conditions?: Filters<{ + user_id: { + type: string; + operators: '$autocomplete' | '$eq' | '$in'; + }; + }>; +} + +export interface QueryChannelBansResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + /** + * Users banned from the channel, one entry per user, newest ban first + */ + bans: Array; + next?: string; + prev?: string; + /** + * Number of banned users, each counted once, ignoring paging. Counted per scope with a cap of 100000 each, so it can reach 200000. Only present when include_total is set on the request; omitted when counting timed out + */ + total?: number; + total_by_scope?: ChannelBansScopeTotals; +} + +export interface QueryChannelsDefaults { + /** + * Number of channels returned when `limit` is omitted. + */ + limit: number; + /** + * Largest number of channels returned for one request. A bigger `limit` is silently clamped to this value rather than rejected. An app with no per-app override gets a higher ceiling for the narrow case of a request asking for at most one message and one member per channel. + */ + max_limit: number; + /** + * Number of members returned per channel when `member_limit` is omitted. + */ + member_limit: number; + /** + * Number of messages returned per channel when `message_limit` is omitted. + */ + message_limit: number; +} + export interface QueryChannelsRequest { /** * Number of channels to limit @@ -6817,6 +7102,7 @@ export interface QueryChannelsRequest { team: { type: string; operators: '$eq' | '$exists' | '$gt' | '$gte' | '$in' | '$lt' | '$lte'; + allowEmpty: true; }; type: { @@ -7537,6 +7823,11 @@ export interface QueryReviewQueueRequest { operators: '$eq' | '$in' | '$q'; }; + country_code: { + type: string; + operators: '$eq' | '$exists' | '$gt' | '$gte' | '$in' | '$lt' | '$lte'; + }; + created_at: { type: Date | string; operators: '$eq' | '$exists' | '$gt' | '$gte' | '$in' | '$lt' | '$lte'; @@ -7747,7 +8038,41 @@ export interface QueryReviewQueueResponse { filter_config?: FilterConfigResponse; } -export interface QueryThreadsRequest { +export interface QueryScheduledMessagesRequest { + /** + * Page size, 1 to 100. Defaults to 20 + */ + limit?: number; + /** + * Cursor from the previous page + */ + next?: string; + /** + * Only scheduled_at ascending is supported. Pending messages come before failed ones + */ + sort?: Array; + /** + * Filter for querying scheduled messages + */ + filter?: ScheduledMessagesFilter; +} + +export interface QueryScheduledMessagesResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + /** + * Pending messages first, then failed ones, each by scheduled_at + */ + scheduled_messages: Array; + /** + * Cursor for the next page; empty on the last page + */ + next?: string; +} + +export interface QueryThreadsRequest { limit?: number; member_limit?: number; next?: string; @@ -7996,6 +8321,36 @@ export interface QueueResponse { queue?: ModerationQueueResponse; } +export interface QuietHoursRuleInput { + /** + * When the window closes, as HH:MM local wall-clock time between 00:00 and 24:00. The end is exclusive: a window ending at 07:00 is already over at 07:00. An end earlier than start wraps past midnight into the next day (start 22:00, end 07:00). 24:00 means the end of the day, so start 00:00 with end 24:00 covers the whole day. + */ + end: string; + /** + * When the window opens, as HH:MM local wall-clock time between 00:00 and 23:59. Must differ from end. + */ + start: string; + /** + * The days of the week the window opens on, any of mon, tue, wed, thu, fri, sat, sun, each at most once. A window belongs to the day it opens: ["fri"] with 22:00 to 07:00 is quiet from Friday 22:00 until Saturday 07:00, and not on Saturday night. Empty or omitted means every day. + */ + days?: Array; +} + +export interface QuietHoursRuleResponse { + /** + * When the window closes, as HH:MM local wall-clock time between 00:00 and 24:00. The end is exclusive. An end earlier than start wraps past midnight into the next day. + */ + end: string; + /** + * When the window opens, as HH:MM local wall-clock time between 00:00 and 23:59. + */ + start: string; + /** + * The days of the week the window opens on, any of mon, tue, wed, thu, fri, sat, sun. A window belongs to the day it opens: ["fri"] with 22:00 to 07:00 is quiet from Friday 22:00 until Saturday 07:00. Omitted means every day. + */ + days?: Array; +} + export interface Reaction { activity_id: string; created_at: TimestampNS; @@ -8525,6 +8880,7 @@ export interface ReviewQueueItemResponse { */ confidence_score?: number; config_key?: string; + country_code?: string; /** * ID of who created the entity */ @@ -8563,6 +8919,7 @@ export interface ReviewQueueItemResponse { * Content payload for moderation */ moderation_payload?: ModerationPayloadResponse; + poll?: PollResponseData; reaction?: Reaction; } @@ -8624,6 +8981,7 @@ export interface RuleBuilderCondition { channel_message_count_rule_params?: ChannelMessageCountRuleParameters; closed_caption_rule_params?: ClosedCaptionRuleParameters; content_count_rule_params?: ContentCountRuleParameters; + content_country_code_params?: ContentCountryCodeParameters; content_custom_property_count_params?: ContentCustomPropertyCountParameters; content_custom_property_params?: ContentCustomPropertyParameters; content_flag_count_rule_params?: FlagCountRuleParameters; @@ -8631,6 +8989,10 @@ export interface RuleBuilderCondition { flood_similar_params?: FloodSimilarRuleParameters; image_content_params?: ImageContentParameters; image_rule_params?: ImageRuleParameters; + /** + * intent_rule params: policy intent topic label and the minimum final score (1-100) that fires the condition + */ + intent_rule_params?: IntentRuleParameters; ip_content_count_rule_params?: IPContentCountRuleParameters; ip_flag_count_rule_params?: IPFlagCountRuleParameters; keyframe_ocr_rule_params?: KeyframeOCRRuleParameters; @@ -8638,6 +9000,7 @@ export interface RuleBuilderCondition { ocr_content_params?: OCRContentParameters; text_content_params?: TextContentParameters; text_rule_params?: TextRuleParameters; + user_channel_ban_count_params?: UserChannelBanCountRuleParameters; user_channel_count_params?: UserChannelCountRuleParameters; user_created_within_params?: UserCreatedWithinParameters; user_custom_property_params?: UserCustomPropertyParameters; @@ -8672,6 +9035,294 @@ export interface RuleBuilderRule { action?: RuleBuilderAction; } +export interface ScheduledMessageChannelResponse { + /** + * Channel CID (:) + */ + cid: string; + /** + * Channel ID + */ + id: string; + /** + * Channel type + */ + type: string; + /** + * Channel image, from its custom data + */ + image?: string; + /** + * Channel name, from its custom data + */ + name?: string; +} + +export interface ScheduledMessageContentResponse { + /** + * Text of the message + */ + text: string; + custom: Record; + /** + * Contains HTML markup of the message + */ + html?: string; + /** + * Whether the message mentions the whole channel + */ + mentioned_channel?: boolean; + /** + * Whether the message mentions the online channel members + */ + mentioned_here?: boolean; + /** + * MML content of the message + */ + mml?: string; + /** + * ID of parent message (thread) + */ + parent_id?: string; + /** + * Date/time the pin expires + */ + pin_expires?: TimestampNS; + /** + * Whether the message will be pinned when sent + */ + pinned?: boolean; + /** + * 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; + /** + * IDs of the user groups mentioned in the message + */ + mentioned_group_ids?: Array; + /** + * Roles mentioned in the message + */ + mentioned_roles?: Array; + /** + * IDs of the users mentioned in the message + */ + mentioned_users?: Array; + /** + * IDs of the users the message will be visible to + */ + restricted_visibility?: Array; + /** + * The location a scheduled message will share + */ + shared_location?: ScheduledMessageLocation; +} + +export interface ScheduledMessageCreatedEvent { + /** + * Date/time of creation + */ + created_at: TimestampNS; + custom: Record; + user: UserResponseCommonFields; + /** + * The type of event: "scheduled_message.created" in this case + */ + type: string; + /** + * The CID of the channel the message is scheduled in + */ + cid?: string; + received_at?: TimestampNS; + /** + * A message scheduled to be sent later + */ + scheduled_message?: ScheduledMessageResponse; +} + +export interface ScheduledMessageDeletedEvent { + /** + * Date/time of creation + */ + created_at: TimestampNS; + custom: Record; + user: UserResponseCommonFields; + /** + * The type of event: "scheduled_message.deleted" in this case + */ + type: string; + /** + * The CID of the channel the message is scheduled in + */ + cid?: string; + received_at?: TimestampNS; + /** + * A message scheduled to be sent later + */ + scheduled_message?: ScheduledMessageResponse; +} + +export interface ScheduledMessageFailedEvent { + /** + * Date/time of creation + */ + created_at: TimestampNS; + custom: Record; + user: UserResponseCommonFields; + /** + * The type of event: "scheduled_message.failed" in this case + */ + type: string; + /** + * The CID of the channel the message is scheduled in + */ + cid?: string; + received_at?: TimestampNS; + /** + * A message scheduled to be sent later + */ + scheduled_message?: ScheduledMessageResponse; +} + +export interface ScheduledMessageLocation { + /** + * The ID of the device that created the location. + */ + created_by_device_id: string; + /** + * The date when the location sharing ends. + * If it's empty, it means the location sharing is static instead of live. + */ + end_at?: TimestampNS; + /** + * The latitude of the location. + */ + latitude?: number; + /** + * The longitude of the location. + */ + longitude?: number; +} + +export interface ScheduledMessageResponse { + /** + * The CID of the channel the message will be sent to + */ + cid: string; + /** + * Date/time of creation + */ + created_at: TimestampNS; + /** + * Unique identifier of the scheduled message + */ + id: string; + /** + * Date/time the message is scheduled to be sent + */ + scheduled_at: TimestampNS; + /** + * Status of the scheduled message. One of: scheduled, sending, failed, canceled, sent. A message is sending from its first send attempt until it is sent or fails, including while it waits to retry a failed attempt; it cannot be edited, canceled or sent now while sending + */ + status: string; + /** + * Date/time of the last update + */ + updated_at: TimestampNS; + /** + * The message a scheduled message will send + */ + message: ScheduledMessageContentResponse; + /** + * User response object + */ + user: UserResponse; + /** + * Why sending the message failed. Only set when status is failed. One of: banned, no_permission, channel_frozen, channel_deleted, user_deleted, moderation_blocked, hook_rejected, expired, internal_error, invalid_message, pending_not_supported + */ + failure_reason?: string; + /** + * The channel of a scheduled message + */ + channel?: ScheduledMessageChannelResponse; +} + +export interface ScheduledMessageSentEvent { + /** + * Date/time of creation + */ + created_at: TimestampNS; + /** + * The ID of the message that was sent + */ + message_id: string; + custom: Record; + user: UserResponseCommonFields; + /** + * The type of event: "scheduled_message.sent" in this case + */ + type: string; + /** + * The CID of the channel the message was sent to + */ + cid?: string; + received_at?: TimestampNS; + /** + * A message scheduled to be sent later + */ + scheduled_message?: ScheduledMessageResponse; +} + +export interface ScheduledMessageUpdatedEvent { + /** + * Date/time of creation + */ + created_at: TimestampNS; + custom: Record; + user: UserResponseCommonFields; + /** + * The type of event: "scheduled_message.updated" in this case + */ + type: string; + /** + * The CID of the channel the message is scheduled in + */ + cid?: string; + received_at?: TimestampNS; + /** + * A message scheduled to be sent later + */ + scheduled_message?: ScheduledMessageResponse; +} + +export interface ScheduledMessagesFilter { + /** + * Only scheduled messages of this channel + */ + cid?: string; + /** + * Only these statuses, as reported in each scheduled message's status. One or more of: scheduled, sending, failed + */ + status?: Array; +} + export interface SearchPayload { /** * Channel filter conditions @@ -8832,6 +9483,7 @@ export interface SearchPayload { team: { type: string; operators: '$eq' | '$exists' | '$gt' | '$gte' | '$in' | '$lt' | '$lte'; + allowEmpty: true; }; type: { @@ -9101,6 +9753,10 @@ export interface SendMessageRequest { */ include_mentioned_members?: boolean; keep_channel_hidden?: boolean; + /** + * When set, the message is not sent now: it is stored and sent at this time (UTC). The response then carries scheduled_message. Must be at least 1 minute and at most the app's maximum lead time (120 days by default) from now. Slash commands cannot be scheduled + */ + scheduled_at?: Date; skip_enrich_url?: boolean; skip_push?: boolean; } @@ -9122,6 +9778,10 @@ export interface SendMessageResponse { * Map of mentioned user ID to whether that user is currently an active channel member. Only set when include_mentioned_members was requested; omitted when the message has no mentions or the membership lookup failed */ mentioned_members?: Record; + /** + * A message scheduled to be sent later + */ + scheduled_message?: ScheduledMessageResponse; } export interface SendReactionRequest { @@ -9151,6 +9811,19 @@ export interface SendReactionResponse { reaction: ReactionResponse; } +export interface SendScheduledMessageNowRequest {} + +export interface SendScheduledMessageNowResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + /** + * A message scheduled to be sent later + */ + scheduled_message: ScheduledMessageResponse; +} + export interface ShadowBlockActionRequestPayload { /** * Reason for shadow blocking @@ -9292,7 +9965,7 @@ export interface SortParamRequest { 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, delete_user_messages, unblock, block, shadow_block, unmask, kick_user, end_call, escalate, de_escalate + * Type of moderation action to perform. One of: mark_reviewed, delete_message, delete_activity, delete_comment, delete_reaction, delete_poll, delete_poll_option, delete_poll_answer, ban, custom, unban, restore, delete_user, delete_user_messages, unblock, block, shadow_block, unmask, kick_user, end_call, escalate, de_escalate */ action_type: | 'flag' @@ -9301,6 +9974,9 @@ export interface SubmitActionRequest { | 'delete_activity' | 'delete_comment' | 'delete_reaction' + | 'delete_poll' + | 'delete_poll_option' + | 'delete_poll_answer' | 'ban' | 'custom' | 'unban' @@ -9350,6 +10026,18 @@ export interface SubmitActionRequest { * Configuration for message deletion action */ delete_message?: DeleteMessageRequestPayload; + /** + * Configuration for poll deletion action. Poll deletion is always permanent. + */ + delete_poll?: DeletePollRequestPayload; + /** + * Configuration for deleting the free-text poll answer a review item was raised for. Chat polls only. Deletion is permanent; the poll and other votes are kept. + */ + delete_poll_answer?: DeletePollAnswerRequestPayload; + /** + * Configuration for deleting the single poll option a review item was raised for. Deletion is permanent; the rest of the poll is kept. + */ + delete_poll_option?: DeletePollOptionRequestPayload; /** * Configuration for reaction deletion action */ @@ -10222,6 +10910,28 @@ export interface UpdateReminderResponse { reminder: ReminderResponseData; } +export interface UpdateScheduledMessageRequest { + /** + * New send time (UTC). Must be at least 1 minute and at most the app's maximum lead time from now + */ + scheduled_at?: Date; + /** + * Message data for creating or updating a message + */ + message?: MessageRequest; +} + +export interface UpdateScheduledMessageResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + /** + * A message scheduled to be sent later + */ + scheduled_message: ScheduledMessageResponse; +} + export interface UpdateThreadPartialRequest { /** * Array of field names to unset @@ -10513,6 +11223,12 @@ export interface UserBannedEvent { created_by?: UserResponseCommonFields; } +export interface UserChannelBanCountRuleParameters { + banned_by?: string; + threshold?: number; + time_window?: string; +} + export interface UserChannelCountRuleParameters { threshold?: number; time_window?: string; @@ -10892,10 +11608,6 @@ export interface UserResponse { * Optional name of user */ name?: string; - /** - * Revocation date for tokens - */ - revoke_tokens_issued_before?: TimestampNS; teams_role?: Record; } @@ -10916,7 +11628,6 @@ export interface UserResponseCommonFields { image?: string; last_active?: TimestampNS; name?: string; - revoke_tokens_issued_before?: TimestampNS; teams_role?: Record; } @@ -10938,7 +11649,6 @@ export interface UserResponsePrivacyFields { invisible?: boolean; last_active?: TimestampNS; name?: string; - revoke_tokens_issued_before?: TimestampNS; /** * The privacy settings of the user. */ @@ -11126,6 +11836,10 @@ export interface WSAuthMessage { */ token: string; user_details: ConnectUserDetailsRequest; + /** + * Realtime event protocol version. 3 omits channel.members from channel.updated, member.*, notification.message_new and notification.added_to_channel of channels with more than 100 members and sets members_included:false. Values above the newest supported version negotiate down to it. + */ + event_protocol?: number; /** * Channel-member custom keys to project onto message.member for messages this connection receives (opt-in; capped, off by default). */ @@ -11196,6 +11910,11 @@ export type WSClientEvent = | ({ type: 'reminder.created' } & ReminderCreatedEvent) | ({ type: 'reminder.deleted' } & ReminderDeletedEvent) | ({ type: 'reminder.updated' } & ReminderUpdatedEvent) + | ({ type: 'scheduled_message.created' } & ScheduledMessageCreatedEvent) + | ({ type: 'scheduled_message.deleted' } & ScheduledMessageDeletedEvent) + | ({ type: 'scheduled_message.failed' } & ScheduledMessageFailedEvent) + | ({ type: 'scheduled_message.sent' } & ScheduledMessageSentEvent) + | ({ type: 'scheduled_message.updated' } & ScheduledMessageUpdatedEvent) | ({ type: 'thread.updated' } & ThreadUpdatedEvent) | ({ type: 'typing.start' } & TypingStartEvent) | ({ type: 'typing.stop' } & TypingStopEvent) @@ -11278,6 +11997,11 @@ export type WSEvent = | ({ type: 'reminder.created' } & ReminderCreatedEvent) | ({ type: 'reminder.deleted' } & ReminderDeletedEvent) | ({ type: 'reminder.updated' } & ReminderUpdatedEvent) + | ({ type: 'scheduled_message.created' } & ScheduledMessageCreatedEvent) + | ({ type: 'scheduled_message.deleted' } & ScheduledMessageDeletedEvent) + | ({ type: 'scheduled_message.failed' } & ScheduledMessageFailedEvent) + | ({ type: 'scheduled_message.sent' } & ScheduledMessageSentEvent) + | ({ type: 'scheduled_message.updated' } & ScheduledMessageUpdatedEvent) | ({ type: 'thread.updated' } & ThreadUpdatedEvent) | ({ type: 'typing.start' } & TypingStartEvent) | ({ type: 'typing.stop' } & TypingStopEvent) diff --git a/src/signing.ts b/src/signing.ts index 71abe411fa..a790ae754e 100644 --- a/src/signing.ts +++ b/src/signing.ts @@ -14,3 +14,22 @@ export function UserFromToken(token: string) { const data = JSON.parse(payload); return data.user_id as string; } + +/** + * Creates a development token for `userId`: an unsigned JWT the API accepts only while the app + * has auth checks disabled (`disable_auth_checks`). Never use one in production. + * + * @param userId - The id of the user the token is for. + * @returns The development token. + */ +export function DevToken(userId: string) { + // Base64 of the UTF-8 bytes, so a non-Latin-1 id can't make `btoa` throw. + const payload = String.fromCharCode( + ...new TextEncoder().encode(JSON.stringify({ user_id: userId })), + ); + return [ + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', // {"alg": "HS256", "typ": "JWT"} + btoa(payload), + 'devtoken', // fixed signature + ].join('.'); +} diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 3df3f7dfb2..c384906a03 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -186,6 +186,23 @@ export class ThreadManager extends WithSubscriptions { return thread; }; + /** + * The registered instance for a thread a query just built, or the query's own instance when none + * is registered, so `queryThreadsAndHydrate` / `getThreadAndHydrate` hand out the one instance + * events reach: a second one would be left unmanaged by `activate()`. A stale registered thread + * is refreshed from the query, unless it's open (active), in which case it reloads itself. + * + * @internal + */ + public resolveQueried = (incoming: Thread): Thread => { + const stored = this.threadStore.get(incoming.id); + if (!stored) return incoming; + if (stored.hasStaleState && !stored.state.getLatestValue().active) { + stored.hydrateState(incoming); + } + return stored; + }; + /** * Every registered thread — the list's plus the opened ones. * diff --git a/src/types.ts b/src/types.ts index f6dfde5670..468ebe3d15 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,7 @@ import type { ChannelStateResponseFields, CreateDeviceRequest, CreatePollRequest, + CustomEvent, DraftPayloadResponse, FlagRequest, Images, @@ -195,7 +196,14 @@ type LocalEvent = ( export type Event = | (WSEvent & { received_at?: TimestampNS }) | LocalEvent - | keyof CustomEventTypes; + | CustomEventTypeUnion; + +// One member per key augmented into `CustomEventTypes`; `never` when none are declared. +type CustomEventTypeUnion = { + [K in keyof CustomEventTypes]: { type: K } & Omit & { + received_at?: TimestampNS; + }; +}[keyof CustomEventTypes]; export type EventType = Event['type'] | 'all'; export type EventHandler = (event: Extract) => void; diff --git a/src/utils.ts b/src/utils.ts index d65fe83838..a2f6905fe2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -187,6 +187,9 @@ function getRandomBytes(length: number): Uint8Array { return bytes; } +const isScalarParam = (value: unknown) => + typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; + export const axiosParamsSerializer: AxiosRequestConfig['paramsSerializer'] = (params) => { const newParams = []; for (const k in params) { @@ -194,7 +197,11 @@ export const axiosParamsSerializer: AxiosRequestConfig['paramsSerializer'] = (pa // So, we need to skip the undefined values. if (params[k] === undefined) continue; - if (Array.isArray(params[k]) || typeof params[k] === 'object') { + // Arrays of scalars go comma-separated (`ids=a,b`), the form the API parses for list query + // params; a JSON array there is read as one malformed id and silently matches nothing. + if (Array.isArray(params[k]) && params[k].every(isScalarParam)) { + newParams.push(`${k}=${encodeURIComponent(params[k].join(','))}`); + } else if (Array.isArray(params[k]) || typeof params[k] === 'object') { newParams.push(`${k}=${encodeURIComponent(JSON.stringify(params[k]))}`); } else { newParams.push(`${k}=${encodeURIComponent(params[k])}`); diff --git a/test/docs-snippets/learnings.md b/test/docs-snippets/AGENTS.md similarity index 86% rename from test/docs-snippets/learnings.md rename to test/docs-snippets/AGENTS.md index 91590c330e..a7a2b57c8a 100644 --- a/test/docs-snippets/learnings.md +++ b/test/docs-snippets/AGENTS.md @@ -1,9 +1,30 @@ -# Docs snippet tests: learnings +# Docs snippet tests: agent notes -Lessons collected from agent runs. Read this before working on a page. -Entries are added only after the user approves them (agents propose them in their final report). +Read this before working on a page. **Test conventions** are the rules every test follows; **API quirks** are API and SDK behavior found while testing. +New entries are added only after the user approves them (agents propose them in their final report). -## Setup and tooling +## Test conventions + +### General + +- Import the SDK from `../../../src` (relative depth varies) and test helpers from `helpers/`. Use `import type` for types. +- `stream-chat` is aliased to `src/index.ts` (`resolve.alias` in `vitest.config.mts`, `paths` in `tsconfig.json`), so a docs fence that starts with `import ... from "stream-chat"` can be a region at module level: declare the values it uses (e.g. `apiKey`) above it. Both import paths load the same module, so `StreamChat.getInstance` returns the same singleton. +- Custom data the docs use (e.g. channel `name`) is declared once in `custom-types.d.ts` by augmenting the `Custom*Data` interfaces of `stream-chat`, the way the docs tell apps to. Add fields there rather than casting in tests. +- **Isolation**: the id or name of everything a test creates comes from `uniqueId('name')`: users, channels, channel types, roles, commands, blocklists, segments, campaigns (name), user groups, moderation config keys, predefined filters, push providers. Polls are matched by their creator, so create them as a `uniqueId` user. Never use fixed ids like `"john"` or `"general"`. +- **Cleanup**: register everything you create on a `Cleanup` instance and call `cleanup.run()` in `afterAll`. App-level settings changed by a test must be restored with `cleanup.add(...)`. +- Tests in one file may share data created in `beforeAll`. Nothing may leak across files. +- `cleanup.run()` attempts every step and then throws if anything failed, so leftovers show up as a failed `afterAll` instead of piling up in the app. Server-side hard channel deletion is a background task: `Cleanup` waits for it (`waitForTask`) and retries deleting channel types until their channels are gone. +- **Leak check** (`setup.ts`, runs automatically after each file's own `afterAll`): looks up everything minted by `uniqueId` in that file (users, channels, channel types, commands, roles, custom permissions, blocklists, segments, campaigns, user groups, moderation configs, predefined filters, push providers, polls). Anything still there fails the file with a `LEAK` error listing it, and is then removed. Per-user data (reminders, devices, drafts, ...) goes away with the user. It also snapshots app settings, pre-existing channel types and retention policies before the file and fails with `DRIFT` if anything changed and wasn't restored. +- Anything created under a non-`uniqueId` name can't be found by the leak check or the sweep. Don't do that. +- **Eventual consistency**: channel type changes (`createChannelType` / `updateChannelType`) take ~30s to reach every API node. A `create()` can succeed and the next call still fail with `: channel type does not exist`. Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after creating or updating a channel type. Timeouts are 90s per test and 180s per hook to leave room for this. Use `retry(fn, { retryIf })` for other eventually-consistent reads (e.g. search). +- **App settings**: pages are run one at a time, so tests may change app settings and built-in channel types, but must restore them (`cleanup.add`). The check after each file fails with `DRIFT` otherwise. +- **Guest users** get their id rewritten to `guest--`: register `client.userID` for cleanup. +- One `it` per docs snippet (or per closely related group of snippets in the same tab group). +- `getClientSideClient(user)` returns a connected client. Disconnect it with `disconnectClients` in `afterAll`. Use `getServerClient()` only for setup, cleanup and assertions that a client can't do (server calls need `user_id` / `created_by_id` where a client call would infer the user). +- The server client is `@stream-io/node-sdk` (`StreamClient`), not stream-chat-js: request objects (`chat.getMessage({ id })`), chat endpoints on `serverClient.chat`, custom data under `custom`, timestamps as `Date` objects. `upsertUsers(serverClient, users)` takes stream-chat-js-shaped users, `createUserToken(userId)` signs tokens, and `updateChannelType(serverClient, name, patch)` (`helpers/channel-types.ts`) fills in the fields node-sdk requires. `helpers/server.ts` has the repeated server-side steps: `sendServerMessage`, `getServerUser` / `getServerMember` / `getServerChannel` (throw unless exactly one result matches), `deletePollsCreatedBy` and `waitForAppSetting`. +- Add basic `expect`s after each snippet to prove it did what the docs claim. + +### Setup and tooling - To probe API behavior quickly, write a throwaway vitest file under `test/docs-snippets/pages/` that logs each call's result or error. It still runs the leak check, so register what it creates, and delete the file before finishing. - Fences containing `import ... from "stream-chat"` can be regions: `stream-chat` is aliased to `src` (vitest `resolve.alias` + tsconfig `paths`). Put the region at module level, below the other imports, with the values it uses (e.g. `const apiKey`) declared above it, since a `const` after it would be in the TDZ. Example: `pages/chat/02-init_and_users/02-init_and_users.test.ts`. @@ -25,7 +46,7 @@ Entries are added only after the user approves them (agents propose them in thei - COPY substitution replaces the identifier everywhere in the region, including comments: a test variable `red` (COPY `red="red"`) turned the docs comment "for team red" into `for team "red"`. Name COPY variables so they can't match words in the docs text (`redTeam`, `channelId`). - A heading with double quotes goes into the marker escaped: `heading="1: Creating a \"Do Not Disturb\" Mode"` (marker attributes are parsed as JSON strings). -## Server client (`@stream-io/node-sdk`) +### Server client (`@stream-io/node-sdk`) - `getServerClient()` is a node-sdk `StreamClient`. Every method takes one request object; chat endpoints are on `serverClient.chat`, moderation on `serverClient.moderation`. - Custom data is under `custom` in node-sdk requests and responses (users, channels, members, messages); `upsertUsers(serverClient, users)` (`helpers/clients.ts`) converts stream-chat-js-shaped users. Timestamps come back as `Date` objects. Errors are `StreamError` (`Stream error code : `, `.code` = API code, HTTP status in `.metadata.responseCode`). @@ -35,7 +56,30 @@ Entries are added only after the user approves them (agents propose them in thei - `chat.createChannelType` requires `automod`, `automod_behavior` and `max_message_length`; use the `updateChannelType(serverClient, name, patch)` helper (`helpers/channel-types.ts`) for updates. - Server messages: `chat.channel(type, id).sendMessage({ message: { text, user_id }, pending, skip_push })`; attachments need `custom: {}` (TS2741 otherwise). Reactions: `chat.sendReaction({ id, reaction: { type, user_id } })`. -## API and SDK behavior +### Docs conventions + +- Unawaited async SDK calls in a snippet (`channel.sendSharedLocation(...)`, `manager.init()`) are a docs bug: errors only surface as unhandled rejections, and later lines run too early (e.g. `unregisterSubscriptions()` before `init()` registered anything). Add `await` in the docs instead of spying on the result. +- Undefined app-supplied placeholders (e.g. `tokenProvider`) aren't docs bugs: define them in the test just before the region (e.g. `const tokenProvider = () => Promise.resolve(createUserToken(userId))`, which also satisfies `require-await`), and don't change the docs. +- A fence that declares the same `const` twice (e.g. two `const result = ...` or `const filter = ...` showing alternatives) is a real SyntaxError and TS2451. Fix the docs by renaming only the later declarations with descriptive names (`aroundResult`, `membersFilter`); don't split the fence. +- A fence showing alternative operations on one placeholder id (e.g. soft delete, hard delete, delete for me on `messageID`) can fail when run in sequence (delete-for-me after a hard delete: 404 code 16). Fix the docs by giving the conflicting later call its own id (`anotherMessageID`); don't split the fence. +- Comment-only `JavaScript` fences ("This is a server-side only feature, choose any of our server-side SDKs...") stay in the docs. The sync check skips them, so they need no marker. +- When the docs use an identifier (not a string literal) like `randomID`, name the test variable the same and don't put it in COPY. A quoted COPY value (`randomID="randomID"`) turns it into a string literal, and `DOCS_SYNC_WRITE` then silently writes the quoted string into the docs. + +### Isolation and cleanup + +- Distinct channels (no id) get an auto id `!members-...`: push `channel.cid` to `cleanup.channels` right after `create()`/`watch()`, and use `uniqueId` members. +- Guest users: push `client.userID` (the rewritten `guest--`) to `cleanup.users` in a `finally` around the snippet, so it is cleaned up even if a later assertion fails. +- Some names are unique per app (e.g. user group names: `a group with name "Design Team" already exists`), and the leak check matches by id or name. Use a COPY variable from `uniqueId` for every such `name`, including the new name in update/rename snippets. +- Server-side `deleteUsers` of an id that was never created succeeds, so register a user on `cleanup` before the snippet creates it. +- Polls and campaigns are NOT deleted when their creator user is hard-deleted. Delete them explicitly via `cleanup.add(...)` (`serverClient.deletePoll({ poll_id, user_id })`; `serverClient.chat.deleteCampaign({ id })`). To also catch polls created inside regions, add `cleanup.add(() => deletePollsCreatedBy(serverClient, userId))` (`helpers/server.ts`), and give campaigns a `uniqueId(...)` name so the leak check can find them. +- To assert ordering or offset pagination exactly, create ~12 channels in `beforeAll` and send one message to each, sequentially (not `Promise.all`): `last_message_at: -1` order is then the reverse of creation order. Same for message pagination (`id_lt`/`id_around`): send ~30 messages sequentially, record their ids in order, and compare pages to exact slices. +- To assert member `created_at` order or `created_at_before` pagination, add members one at a time with `addMembers([id])` in `beforeAll`: members passed together to `create()` can share a `created_at`. +- To prove a "flagged first" sort (e.g. `pinned_at: -1`) is applied, flag a channel first and create a second channel after it: without the sort the newer one comes first, so `toEqual([flagged, other])` shows the sort worked. +- `cleanup.add` steps run after `disconnectClients`, so they can't use a user client ("Both secret and user tokens are not set"). Standalone uploads (`client.uploadImage`/`uploadFile`) aren't removed with the user or channel and the leak check can't see them: delete them in the test while the client is connected. + +## API quirks + +### API and SDK behavior - Since v9, channel `name` (and other fields like `image`) are custom data, not in `ChannelData`/`ChannelResponse`. `test/docs-snippets/custom-types.d.ts` augments `CustomChannelData` with `name`, so snippets passing `{ name }` typecheck as-is. If a snippet needs another custom field, add it there, using the matching interface (`CustomMemberData` for member fields like `code_name`, etc.); no casts or typed client views in tests. Custom event types (`type: "friendship_request"`) go in `CustomEventTypes` and their payload fields in `CustomEventData`; `channel.sendEvent` needs a watched channel. - To prove a channel is watched, check that its events reach the client: subscribe with `channel.on('message.new', ...)`, send a message server-side, and await the event. `client.activeChannels[cid]` proves nothing (`client.channel()` adds the channel there before any `watch()`), and `channel.watch()` returns `watchers` only when a watchers limit is requested. @@ -85,7 +129,7 @@ Entries are added only after the user approves them (agents propose them in thei - Multi-tenancy access is team-scoped: a channel only accepts members from its own team (`update({ add_members })`: code 5 "user from teams [...] cannot be a member of a channel with team ..."), a user without teams only sees channels without a team, and a team user's `queryChannels({ team: {} })` / `({ team: { $eq: null } })` fails with code 70 as soon as an inaccessible channel matches (documented on the page; even for a member, even for role `admin`). Cross-team reads need a `*-any-team` grant: the built-in `global_read_only` / `global_admin` roles have `read-channel-any-team`. - Push preferences (`setPushPreferences`) work without a push provider. Read them back client-side: user-level from a fresh `connectUser` response (`me.push_preferences`), channel-member level from `queryChannels`/`watch` (`channel.push_preferences`). Devices (`addDevice`) need a matching push provider on the app: the test app's providers are named (multi-bundle), so register with `addDevice(id, "firebase", undefined, process.env.STREAM_PUSH_PROVIDER_NAME)` (a Firebase provider's name, set in `.env`). Without the name: code 4 'push provider "" with type "firebase" not found'. -## Client-side vs server-side +### Client-side vs server-side - Server-only `JavaScript` snippets (they fail with a user token) are removed from the page. Don't add a `Node.js` tab for them (`Node.js` fences are out of scope). If only part of a snippet is server-only (e.g. setting `channel_role` in `updateMemberPartial`), remove just that part from the `JavaScript` tab and keep the rest. - If every `JavaScript` fence of a page turns out to be server-only (all removed), don't write a client test file: a test that only asserts the user-token rejection covers no docs snippet. Record the error in the report instead. @@ -97,24 +141,3 @@ Entries are added only after the user approves them (agents propose them in thei - Some client-side endpoints need global role `admin` (role `user` gets 403 code 17), e.g. `client.moderation.queryReviewQueue`. Connect a second user with `getClientSideClient({ id, role: 'admin' })` and shadow `const client = adminClient;` in the `it`. - Global role changes are server-only: client-side `partialUpdateUser({ set: { role } })` fails with 403 code 17 `UpdateUserRole`, even as `admin`. `getBlockedUsers(userId)` fails with code 4 "Setting user or user_id is only allowed when using server side auth": remove the argument (`blockUser`/`unBlockUser`/`getBlockedUsers()` work for the connected user). - Member role changes are server-only: client-side `addModerators`/`demoteModerators` fail with 403 code 17 ("changing channel member roles is not allowed client-side"), and `updateMemberPartial` with `channel_role` fails with code 17 ("this channel role can only be updated server side"). Client-side `updateMemberPartial` of custom fields works for the connected user without a `userId` option (don't pass one client-side: another user's id fails with 400 code 4, and your own is redundant). Client-side `addMembers([{ user_id, channel_role }])` for a new member does work (channel owner, `messaging`). - -## Docs conventions - -- Unawaited async SDK calls in a snippet (`channel.sendSharedLocation(...)`, `manager.init()`) are a docs bug: errors only surface as unhandled rejections, and later lines run too early (e.g. `unregisterSubscriptions()` before `init()` registered anything). Add `await` in the docs instead of spying on the result. -- Undefined app-supplied placeholders (e.g. `tokenProvider`) aren't docs bugs: define them in the test just before the region (e.g. `const tokenProvider = () => Promise.resolve(createUserToken(userId))`, which also satisfies `require-await`), and don't change the docs. -- A fence that declares the same `const` twice (e.g. two `const result = ...` or `const filter = ...` showing alternatives) is a real SyntaxError and TS2451. Fix the docs by renaming only the later declarations with descriptive names (`aroundResult`, `membersFilter`); don't split the fence. -- A fence showing alternative operations on one placeholder id (e.g. soft delete, hard delete, delete for me on `messageID`) can fail when run in sequence (delete-for-me after a hard delete: 404 code 16). Fix the docs by giving the conflicting later call its own id (`anotherMessageID`); don't split the fence. -- Comment-only `JavaScript` fences ("This is a server-side only feature, choose any of our server-side SDKs...") stay in the docs. The sync check skips them, so they need no marker. -- When the docs use an identifier (not a string literal) like `randomID`, name the test variable the same and don't put it in COPY. A quoted COPY value (`randomID="randomID"`) turns it into a string literal, and `DOCS_SYNC_WRITE` then silently writes the quoted string into the docs. - -## Isolation and cleanup - -- Distinct channels (no id) get an auto id `!members-...`: push `channel.cid` to `cleanup.channels` right after `create()`/`watch()`, and use `uniqueId` members. -- Guest users: push `client.userID` (the rewritten `guest--`) to `cleanup.users` in a `finally` around the snippet, so it is cleaned up even if a later assertion fails. -- Some names are unique per app (e.g. user group names: `a group with name "Design Team" already exists`), and the leak check matches by id or name. Use a COPY variable from `uniqueId` for every such `name`, including the new name in update/rename snippets. -- Server-side `deleteUsers` of an id that was never created succeeds, so register a user on `cleanup` before the snippet creates it. -- Polls and campaigns are NOT deleted when their creator user is hard-deleted. Delete them explicitly via `cleanup.add(...)` (`serverClient.deletePoll({ poll_id, user_id })`; `serverClient.chat.deleteCampaign({ id })`). To also catch polls created inside regions, add `cleanup.add(() => deletePollsCreatedBy(serverClient, userId))` (`helpers/server.ts`), and give campaigns a `uniqueId(...)` name so the leak check can find them. -- To assert ordering or offset pagination exactly, create ~12 channels in `beforeAll` and send one message to each, sequentially (not `Promise.all`): `last_message_at: -1` order is then the reverse of creation order. Same for message pagination (`id_lt`/`id_around`): send ~30 messages sequentially, record their ids in order, and compare pages to exact slices. -- To assert member `created_at` order or `created_at_before` pagination, add members one at a time with `addMembers([id])` in `beforeAll`: members passed together to `create()` can share a `created_at`. -- To prove a "flagged first" sort (e.g. `pinned_at: -1`) is applied, flag a channel first and create a second channel after it: without the sort the newer one comes first, so `toEqual([flagged, other])` shows the sort worked. -- `cleanup.add` steps run after `disconnectClients`, so they can't use a user client ("Both secret and user tokens are not set"). Standalone uploads (`client.uploadImage`/`uploadFile`) aren't removed with the user or channel and the leak check can't see them: delete them in the test while the client is connected. diff --git a/test/docs-snippets/README.md b/test/docs-snippets/README.md index cd794e5c00..e7e9c23e5f 100644 --- a/test/docs-snippets/README.md +++ b/test/docs-snippets/README.md @@ -19,6 +19,10 @@ yarn eslint test/docs-snippets && npx prettier --check test/docs-snippets # li These tests hit the network and are not part of `yarn test`. +## How to add tests? + +Run the `docs-sync` Claude Code skill (`.claude/skills/docs-sync/SKILL.md`): `/docs-sync [path to the getstream.io checkout]`. It runs `yarn test-docs-sync` against that checkout, adds tests for new `JavaScript` snippets, updates existing tests to docs changes (the docs win), and writes fixes for snippets that don't work back to the docs. + ## Layout ``` @@ -30,9 +34,8 @@ setup.ts # .env loading, per-file leak check custom-types.d.ts # Custom*Data augmentations for the custom fields the docs use sync/ # `yarn test-docs-sync` (docs fence <-> test region check) maintenance/ # `yarn test-docs-sweep` -docs-snippets-todo.md # pages to cover, their status, blockers and setup notes -docs-snippets-agent-prompt.md # prompt for an agent that covers one page -learnings.md # lessons from earlier agent runs, read before working on a page +docs-snippets-todo.md # pages to cover and their status, open generator fixes +AGENTS.md # test conventions and API quirks, read before working on a page ``` The path mirrors the docs path relative to `content/docs/`, without `_default/`: @@ -42,22 +45,7 @@ The path mirrors the docs path relative to `content/docs/`, without `_default/`: ## Conventions -- Import the SDK from `../../../src` (relative depth varies) and test helpers from `helpers/`. Use `import type` for types. -- `stream-chat` is aliased to `src/index.ts` (`resolve.alias` in `vitest.config.mts`, `paths` in `tsconfig.json`), so a docs fence that starts with `import ... from "stream-chat"` can be a region at module level: declare the values it uses (e.g. `apiKey`) above it. Both import paths load the same module, so `StreamChat.getInstance` returns the same singleton. -- Custom data the docs use (e.g. channel `name`) is declared once in `custom-types.d.ts` by augmenting the `Custom*Data` interfaces of `stream-chat`, the way the docs tell apps to. Add fields there rather than casting in tests. -- **Isolation**: the id or name of everything a test creates comes from `uniqueId('name')`: users, channels, channel types, roles, commands, blocklists, segments, campaigns (name), user groups, moderation config keys, predefined filters, push providers. Polls are matched by their creator, so create them as a `uniqueId` user. Never use fixed ids like `"john"` or `"general"`. -- **Cleanup**: register everything you create on a `Cleanup` instance and call `cleanup.run()` in `afterAll`. App-level settings changed by a test must be restored with `cleanup.add(...)`. -- Tests in one file may share data created in `beforeAll`. Nothing may leak across files. -- `cleanup.run()` attempts every step and then throws if anything failed, so leftovers show up as a failed `afterAll` instead of piling up in the app. Server-side hard channel deletion is a background task: `Cleanup` waits for it (`waitForTask`) and retries deleting channel types until their channels are gone. -- **Leak check** (`setup.ts`, runs automatically after each file's own `afterAll`): looks up everything minted by `uniqueId` in that file (users, channels, channel types, commands, roles, custom permissions, blocklists, segments, campaigns, user groups, moderation configs, predefined filters, push providers, polls). Anything still there fails the file with a `LEAK` error listing it, and is then removed. Per-user data (reminders, devices, drafts, ...) goes away with the user. It also snapshots app settings, pre-existing channel types and retention policies before the file and fails with `DRIFT` if anything changed and wasn't restored. -- Anything created under a non-`uniqueId` name can't be found by the leak check or the sweep. Don't do that. -- **Eventual consistency**: channel type changes (`createChannelType` / `updateChannelType`) take ~30s to reach every API node. A `create()` can succeed and the next call still fail with `: channel type does not exist`. Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after creating or updating a channel type. Timeouts are 90s per test and 180s per hook to leave room for this. Use `retry(fn, { retryIf })` for other eventually-consistent reads (e.g. search). -- **App settings**: pages are run one at a time, so tests may change app settings and built-in channel types, but must restore them (`cleanup.add`). The check after each file fails with `DRIFT` otherwise. -- **Guest users** get their id rewritten to `guest--`: register `client.userID` for cleanup. -- One `it` per docs snippet (or per closely related group of snippets in the same tab group). -- `getClientSideClient(user)` returns a connected client. Disconnect it with `disconnectClients` in `afterAll`. Use `getServerClient()` only for setup, cleanup and assertions that a client can't do (server calls need `user_id` / `created_by_id` where a client call would infer the user). -- The server client is `@stream-io/node-sdk` (`StreamClient`), not stream-chat-js: request objects (`chat.getMessage({ id })`), chat endpoints on `serverClient.chat`, custom data under `custom`, timestamps as `Date` objects. `upsertUsers(serverClient, users)` takes stream-chat-js-shaped users, `createUserToken(userId)` signs tokens, and `updateChannelType(serverClient, name, patch)` (`helpers/channel-types.ts`) fills in the fields node-sdk requires. `helpers/server.ts` has the repeated server-side steps: `sendServerMessage`, `getServerUser` / `getServerMember` / `getServerChannel` (throw unless exactly one result matches), `deletePollsCreatedBy` and `waitForAppSetting`. -- Add basic `expect`s after each snippet to prove it did what the docs claim. +The test conventions and the API quirks found so far are in [`AGENTS.md`](AGENTS.md). Read it before working on a page. ## Snippet markers and docs sync diff --git a/test/docs-snippets/custom-types.d.ts b/test/docs-snippets/custom-types.d.ts index f5bc7e93c5..8a4c1b6c3d 100644 --- a/test/docs-snippets/custom-types.d.ts +++ b/test/docs-snippets/custom-types.d.ts @@ -34,6 +34,8 @@ declare module 'stream-chat' { interface CustomAttachmentData { // _default/04-messages/01-send_message.md myCustomField?: number; + // _default/04-messages/07-silent_messages.md + distance_km?: number; } interface CustomEventData { diff --git a/test/docs-snippets/docs-snippets-agent-prompt.md b/test/docs-snippets/docs-snippets-agent-prompt.md deleted file mode 100644 index a56e5263cb..0000000000 --- a/test/docs-snippets/docs-snippets-agent-prompt.md +++ /dev/null @@ -1,129 +0,0 @@ -# Agent prompt: test the docs snippets of one page - -Replace `{{PAGE}}` with a path from `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md`, e.g. `chat/_default/04-messages/01-send_message.md` or `platform/02-identity-and-access/04-multi-tenancy.md`. - ---- - -You are verifying the client-side JavaScript code snippets of one Stream Chat docs page by writing typed tests that run them against a real Stream app with a user token. Only fences labelled `JavaScript` are in scope. Docs fences must always have a label: if you find an unlabelled `js` fence, label it (`JavaScript` for client code, `Node.js` for server-side code, with or without `@stream-io/node-sdk`) and report it. `Node.js` fences are out of scope: don't test, add or change them. - -**Page:** `/Users/zitaszupera/Stream/getstream.io/content/docs/{{PAGE}}` - -## Hard rules - -- **Do not commit, push, stash, or create branches** in any repo. Work only in the local working tree. -- **Do not write to `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/learnings.md`.** Read it, and propose additions in your final report. -- Only touch: - - the test files for this page under `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/` - - the docs page itself - - this page's status row in `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md` - - `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/custom-types.d.ts`, to add custom data fields the snippets use (augment the matching `Custom*Data` interface; never remove or change existing fields) - - `stream-chat-js/src/`, to fix SDK issues (see below) -- Do not edit the helpers, the configs, or other pages. If a helper is missing something, write a local helper in your test file and propose the shared helper in your report. -- **Fix stream-chat-js issues in `src/`** when a correct docs snippet hits one: a wrong or missing type (e.g. a field in `src/types.ts`, a too-narrow parameter type) or an SDK bug. Make the smallest fix; for a runtime fix, add or update a unit test under `test/unit/`. After the edit, `yarn types`, `yarn lint` and `yarn test` must still pass. List every change in the report. -- **API (backend) behavior issues** are not blockers: if the API behaves unexpectedly (inconsistent, misleading error, missing field) but the snippet can still be tested, test what works, add the issue to the "Possible API issues" list in the TODO (page, behavior, exact error), and carry on. -- Change existing docs snippets as little as possible, and only when they are actually wrong. -- Never put the API key or secret in any file. They are loaded from `test/docs-snippets/.env`. -- In getstream.io, never run `npm run ci` or `npm run lint` for the whole repo. They rewrite thousands of files, including image assets. Use only the per-file commands listed below. - -## Read first - -1. `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/learnings.md`: issues that earlier agents ran into. Follow it. -2. `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/README.md`: layout, helpers, isolation, cleanup and snippet-marker conventions. Look at `pages/chat/04-messages/01-send_message.test.ts` as an example. -3. The page itself, and its row in `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md`. Also read the page's entries in the TODO's **Blockers detail**, **Setup notes** and **Known docs bugs** sections. - -4. When the page doesn't make clear whether an endpoint is client-side or server-side only, check the OpenAPI specs: https://github.com/GetStream/protocol/tree/main/openapi/v2. An endpoint missing from the client spec is server-only. Also check the SDK source in `stream-chat-js/src/` for the method's signature and types. - -## What to build - -- **Client test** (`test/docs-snippets/pages/
/.test.ts`): run each `JavaScript` snippet with a user token, using `getClientSideClient(user)`. Use `getServerClient()` only for setup, cleanup and assertions a client can't do. It returns a `@stream-io/node-sdk` client (see the README's server client notes), never a stream-chat-js `StreamChat` with the secret. - - If a snippet (or part of it) only works server-side (needs the secret, `user_id` on behalf of others, app settings, ...), it doesn't belong in a client tab: remove it from the page (see "Server-side code in JavaScript snippets") and list it in the report. Don't add a `Node.js` tab for it. - - If every `JavaScript` fence of the page turns out to be server-only, don't write a test file. -- **Mirror paths.** The test paths mirror the docs path relative to `content/docs/`, without `_default/`: `chat/_default/04-messages/...` goes under `pages/chat/04-messages/...`, `chat/javascript/...` under `pages/chat/javascript/...`, `platform/...` under `pages/platform/...`. -- **Other products (platform pages).** Platform pages also have Feeds and Video fences. Only fences that use stream-chat-js are in scope (Chat, and Video `call_level` push preferences set through the chat client). Feeds fences (`@stream-io/feeds-client`, `useCreateFeedsClient`, `feeds_level`, `addComment`, `createDevice`/`listDevices`/`deleteDevice`) and `React` / `React Native` tabs are excluded through `EXCLUDED_SECTIONS` in `sync/parse.ts`. If you find a non-stream-chat-js fence the sync check still asks you to cover, don't test it and don't edit `sync/parse.ts`: report it with the heading to exclude. -- **Test structure:** - - `describe` per page. - - `beforeAll` creates the shared users and channels with `uniqueId(...)` ids and registers them on a `Cleanup`. - - `afterAll` disconnects the clients and runs `cleanup.run()`. - - One `it` per snippet, or per tab group when the snippets in it depend on each other. -- **Snippet markers.** Every `JavaScript` fence on the page needs exactly one marker in a test (`Node.js` fences are ignored). `yarn test-docs-sync` checks the format and the coverage. - - Wrap the docs code like this: - ```ts - // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Sending a Message" tab="JavaScript" index=1 - // COPY: channelId="general", userId="john" - ...docs code... - // #endregion snippet - ``` - - `docs` is the page path relative to `content/docs/` (same as `{{PAGE}}`). - - `heading` is the exact text of the nearest heading above the fence. - - `tab` is the fence label, or `unlabelled`. - - `index` (default 1) counts fences with the same heading text and tab on the page. If a key is wrong, the sync check lists the page's fence keys. - - Everything between the markers must be the docs code. Setup goes before the region and assertions after it. - - **COPY** (optional, first line(s) of the region): `name="docs literal"` pairs. Where the docs use fixed values ("john", "general", "messageID"), use variables in the test, and the sync check replaces each variable with its literal before comparing. Don't use COPY variables as object keys or in shorthand properties (`{ userId }`). - - Comment-only fences (e.g. `// This is a server-side only feature...`) are skipped by the sync check and need no marker. Every other `JavaScript` fence gets a real region: blocked or untestable snippets go inside `it.skip('BLOCKED: ...')` so they are still typechecked. A fence that isn't JavaScript code (e.g. a bare data shape) is a docs bug: rewrite it as code that runs (e.g. reading and logging the fields), and report it. -- **Assertions.** After each snippet, add basic `expect`s proving that it did what the docs say. For example, check that the response contains the new field, or re-query and check that the state changed. Don't over-assert on volatile fields. -- **Naming**: the id or name of every resource you create comes from `uniqueId(...)` (campaigns: `name`; polls: create them as a `uniqueId` user). The automatic leak check after each file only finds resources named this way. If it reports a `LEAK`, fix your cleanup; don't work around the check. -- **App-level side effects** (app settings, built-in channel types, grants, roles, retention policies, multi-tenancy, push config, ...): pages are run **one at a time**, so changing them is allowed. - - Read the original value first and restore it with `cleanup.add(...)`. - - The check after each file fails with `DRIFT` if app settings or pre-existing channel types differ from before the file ran. - - Prefer `uniqueId` channel types or `config_overrides` when the snippet allows it. -- **Channel types are eventually consistent.** After `createChannelType` or `updateChannelType`, API nodes can disagree for up to ~30s. A `create()` can succeed and the next `sendMessage` still fail with "channel type does not exist". - - Call `await waitForChannelTypePropagation()` (from `helpers/wait.ts`) right after the create/update, outside the snippet region. - - Push the name to `cleanup.channelTypes` straight after `createChannelType`. `Cleanup` deletes it after the channels are gone. Never delete channel types in your own cleanup code. -- **Blockers** (the page's Blockers cell in the TODO): - - ⛔ snippets go in `it.skip('BLOCKED: ', ...)` so they are still typechecked. - - If you find a new blocker, use `it.skip('BLOCKED: ')`, mark the page `[~]` and report it, with the exact API error. -- **Guest users** get their id rewritten to `guest--`. Register `client.userID` for cleanup, not the id you passed. -- **Partial snippets** (fragments like `filters = {...}` or `...` placeholders): wrap them in the minimal code needed to compile and run. The region still holds exactly the docs text. - -## Server-side code in JavaScript snippets - -- If a `JavaScript` snippet is server-only (it fails with a user token, e.g. 403 code 17 or "only allowed when using server side auth"), **remove the `JavaScript` fence**. Don't add or change a `Node.js` tab. Record the exact client-side error in the report. -- If only part of the snippet is server-only (e.g. one field like `channel_role`, or a line naming the acting user), remove just that part from the `JavaScript` fence and keep the rest. Probe each part client-side before deciding. -- Acting-user fields (`user_id`, `user`, `created_by_id`, a `userId` option naming who performs the action): remove one only if the client-side OpenAPI spec (`chat-clientside-api.json`, see "Read first") doesn't have it on that endpoint's request schema. If it does, keep it and test it. Cite the operation id and schema in the report either way. -- Don't remove a `JavaScript` fence just because it is a fragment or untestable for other reasons. - -## Editing the docs page - -- Write fixes from the test with `DOCS_SYNC_WRITE= yarn test-docs-sync`. This writes every region of that page into its fence, applying the COPY values and the docs prettier style (double quotes). Don't paste snippets by hand. -- Follow `/Users/zitaszupera/Stream/getstream.io/content/docs/AGENTS.md`. In particular, no em-dashes in prose or comments. -- Then run, from `/Users/zitaszupera/Stream/getstream.io/content/docs`: - - `npx prettier --write ` - - `npx markdownlint-cli2 --config .markdownlint-cli2.cjs ` - -## Faulty docs snippets - -If a docs snippet is wrong (wrong method name or signature, a type error against `src/`, wrong response shape, a runtime API error that isn't caused by test setup): - -1. Make the smallest fix that makes it correct, in the test region. -2. Write it to the docs page with `DOCS_SYNC_WRITE= yarn test-docs-sync`, then check `git diff` in getstream.io: only the intended fence should change. -3. Record it for the report as before/after with the reason and the evidence (error message or type error). - -If the snippet is correct but stream-chat-js rejects it (types or runtime), fix `src/` instead of the snippet. - -If you're not sure whether the snippet or your setup is wrong, don't edit the docs. Mark it as an open question in the report instead. - -## Gates (all must pass before you finish) - -Run these from `/Users/zitaszupera/Stream/stream-chat-js`: - -```sh -yarn test-docs # green, with no `LEAK` or `DRIFT` error -yarn types-docs # no type errors (strict TS, no `any` escapes, no `!` non-null assertions) -yarn eslint test/docs-snippets # zero warnings (same rules as src/: `import type`, sorted import members, no `!`, no unused vars) -npx prettier --check test/docs-snippets -yarn test-docs-sync # every JavaScript fence on the tested sidebars (chat JS, platform) covered, every JS fence labelled JavaScript / Node.js, every region identical to its docs fence -``` - -Don't use `@ts-ignore`/`@ts-expect-error` to silence a docs snippet. A type error in a snippet is either a docs bug (fix it as above) or an SDK typing bug (fix the type in `src/` and report it). - -Finally, update the page's row in `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-todo.md`: `[x]` when done, `[~]` when there are open questions. - -## Final report (your last message) - -1. **Files created/changed**: test files, plus the docs page with a one-line summary. -2. **Docs snippet corrections**: for each one, a before/after diff, the reason, and the evidence. These need human review. -3. **Skipped / untestable snippets**: for each one, the snippet and why. -4. **Removed server-side code**: each removed fence or part of a fence, with the client-side error (or the reason) that shows it is server-only. -5. **stream-chat-js fixes**: every `src/` change as a diff, with the snippet that needed it. -6. **Open SDK issues and API issues**: stream-chat-js problems you couldn't fix, and the API behavior issues you added to the TODO. -7. **Proposed learnings** for `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/learnings.md`: a numbered list of short, reusable lessons that would have saved you time (e.g. "`channel.muteStatus()` needs a watched channel"). Skip page-specific trivia. They are reviewed before being added. diff --git a/test/docs-snippets/docs-snippets-todo.md b/test/docs-snippets/docs-snippets-todo.md index d1f565dc0f..159c8c84eb 100644 --- a/test/docs-snippets/docs-snippets-todo.md +++ b/test/docs-snippets/docs-snippets-todo.md @@ -1,193 +1,9 @@ # Docs snippet tests: todo -Pages under `getstream.io/content/docs/` that contain JavaScript snippets. Give each page to an agent with `/Users/zitaszupera/Stream/stream-chat-js/test/docs-snippets/docs-snippets-agent-prompt.md`. **Run pages one at a time**: tests may change app settings (they must restore them). +## Generator fixes (`../chat`, `tools/openapi`) -- Only client-side snippets are in scope: fences labelled `JavaScript` of pages in the chat JavaScript sidebar and the platform sidebar. On platform pages, only fences that use stream-chat-js count: Feeds sections are excluded (`EXCLUDED_SECTIONS` in `sync/parse.ts`). Unlabelled `js` fences get a label (`JavaScript` or `Node.js`). `Node.js` fences and pages only in the Node sidebar are out of scope. -- **JS / unlabelled**: number of `js` fences labelled `JavaScript` / with no label (before the docs were changed). -- **Client test**: `yes` when the page gets a test under `test/docs-snippets/pages/...` (`chat/_default/X` maps to `pages/chat/X`, `platform/X` to `pages/platform/X`); `-` when nothing on it can be tested client-side. +Found while migrating the snippets to v10 and patched by hand in `src/gen/models/index.ts`. A `yarn generate-client` overwrites the patches, so fix them at the source, regenerate, and check that `yarn types`, `yarn types:tests` and the docs tests still pass. -**Blockers** (details below; empty = fully runnable, maybe with setup notes): - -- ⛔ **Blocked**: the app doesn't have the feature. The snippet goes in `it.skip('BLOCKED: ...')` (still typechecked). -- ◐ **Partial**: only some snippets of the page are affected. -- **Excluded**: not validated, leave as is. - -Status: `[ ]` todo, `[x]` done, `[~]` done with open questions. - -App facts (2026-10-05): push v3 (providers: type `firebase` named `firebase`, type `firebase` named `apn`; the docs' Firebase example config is rejected by `upsertPushProvider`), permissions v2, multi-tenancy off (can be toggled), campaigns on, no webhook/SQS/SNS. Enabled since (not probed yet): message history (`queryMessageHistory`). Probed and **working**: location sharing (enabled by support 2026-10-06), private messaging (restricted visibility, 2026-10-06), pending messages, delivery receipts, translation, review queue, guest users, dynamic partitioning config, multi-tenancy toggle. - -## chat/\_default/02-init_and_users - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | ------------------------------------------------------------------------ | --- | ---------- | ----------- | -------- | -| [x] | `chat/_default/02-init_and_users/01-client_tokens_and_authentication.md` | 1 | 0 | yes | | -| [x] | `chat/_default/02-init_and_users/02-init_and_users.md` | 5 | 0 | yes | | -| [x] | `chat/_default/02-init_and_users/04-user_groups.md` | 8 | 0 | yes | | -| [x] | `chat/_default/02-init_and_users/05-authless_users.md` | 2 | 0 | yes | | - -## chat/\_default/03-channels - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | ----------------------------------------------------------------------- | --- | ---------- | ----------- | -------- | -| [x] | `chat/_default/03-channels/01-creating_channels.md` | 3 | 0 | yes | | -| [x] | `chat/_default/03-channels/02-query_channels.md` | 11 | 0 | yes | | -| [x] | `chat/_default/03-channels/03-channel_update.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/04-query_members.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/05-channel_pagination.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/06-channel_members.md` | 11 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/08-archiving.md` | 1 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/09-pinning.md` | 1 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/10-muting.md` | 3 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/11-hiding.md` | 1 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/12-disabling.md` | 1 | 1 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/13-deleting.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/14-freezing.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/15-truncating.md` | 2 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/16-channel_invites.md` | 6 | 0 | yes | | -| [x] | `chat/_default/03-channels/07-channel_management/17-batch-updates.md` | 3 | 0 | - | | - -## chat/\_default/04-messages - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | --------------------------------------------------- | --- | ---------- | ----------- | -------- | -| [x] | `chat/_default/04-messages/01-send_message.md` | 9 | 0 | yes | | -| [x] | `chat/_default/04-messages/02-file_uploads.md` | 4 | 0 | yes | | -| [x] | `chat/_default/04-messages/03-threads.md` | 11 | 2 | yes | | -| [x] | `chat/_default/04-messages/04-send_reaction.md` | 5 | 0 | yes | | -| [x] | `chat/_default/04-messages/05-pinned_messages.md` | 3 | 0 | yes | | -| [x] | `chat/_default/04-messages/06-search.md` | 2 | 0 | yes | | -| [x] | `chat/_default/04-messages/07-silent_messages.md` | 2 | 0 | yes | | -| [x] | `chat/_default/04-messages/09-message_reminders.md` | 7 | 0 | yes | | -| [x] | `chat/_default/04-messages/10-message_receipts.md` | 1 | 0 | yes | | - -## chat/\_default/05-features - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | ---------------------------------------------------------------------- | --- | ---------- | ----------- | -------- | -| [x] | `chat/_default/05-features/02-events.md` | 7 | 0 | yes | | -| [x] | `chat/_default/05-features/03-unread.md` | 11 | 0 | yes | | -| [x] | `chat/_default/05-features/04-typing_indicators.md` | 2 | 0 | yes | | -| [x] | `chat/_default/05-features/05-presence_format.md` | 3 | 0 | yes | | -| [x] | `chat/_default/05-features/07-polls_api.md` | 17 | 0 | yes | | -| [x] | `chat/_default/05-features/08-location_sharing.md` | 7 | 0 | yes | | -| [x] | `chat/_default/05-features/09-translation.md` | 4 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/11-slow_mode_and_throttling.md` | 2 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/12-drafts.md` | 6 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/13-private_messaging.md` | 1 | 0 | yes | | -| [x] | `chat/_default/05-features/10-advanced/15-pending_messages.md` | 1 | 0 | yes | | - -## chat/\_default/12-best_practices - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | -------------------------------------------------- | --- | ---------- | ----------- | -------- | -| [x] | `chat/_default/12-best_practices/02-moderation.md` | 16 | 1 | yes | | - -## chat/javascript - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | ---------------------------------------------------------------- | --- | ---------- | ----------- | -------- | -| [x] | `chat/javascript/01-quick_start/01-plain_js_introduction.md` | 4 | 0 | yes | | -| — | ~~`chat/javascript/11-debugging_and_cli/10-upgrading_to_v9.md`~~ | 0 | 8 | - | Excluded | - -## platform/02-identity-and-access - -**JS** counts only stream-chat-js fences; Feeds `JavaScript` fences in excluded sections are listed in the Blockers cell. - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | ----------------------------------------------------- | --- | ---------- | ----------- | ------------------------------ | -| [x] | `platform/02-identity-and-access/02-users.md` | 0 | 0 | - | Excluded sections (2 Feeds JS) | -| [x] | `platform/02-identity-and-access/04-multi-tenancy.md` | 2 | 0 | yes | | - -## platform/03-events-and-notifications - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | --------------------------------------------------------------- | --- | ---------- | ----------- | ------------------------------ | -| [x] | `platform/03-events-and-notifications/01-webhooks.md` | 1 | 1 | yes | | -| [x] | `platform/03-events-and-notifications/02-push-notifications.md` | 0 | 0 | - | Excluded sections (2 Feeds JS) | -| [x] | `platform/03-events-and-notifications/04-push-devices.md` | 3 | 0 | yes | ◐ push provider config | -| [x] | `platform/03-events-and-notifications/05-push-preferences.md` | 9 | 0 | yes | Excluded sections (9 Feeds JS) | - -## platform/04-using-the-api - -| Status | Page | JS | Unlabelled | Client test | Blockers | -| ------ | ---------------------------------------------------- | --- | ---------- | ----------- | -------- | -| [x] | `platform/04-using-the-api/06-connection-pooling.md` | 0 | 1 | - | | - -## Blockers detail - -### `chat/javascript/11-debugging_and_cli/10-upgrading_to_v9.md` (Excluded) - -- Not validated; leave the page as is (React/TSX type examples, no runnable JS). Listed in `EXCLUDED_PAGES` (`sync/parse.ts`), so the sync coverage and label checks skip it. - -### `platform/03-events-and-notifications/04-push-devices.md` (◐ push provider config) - -- The app has one push provider (added 2026-10-14): type `firebase`, named `firebase` (multi-bundle). Devices need both the type and the name: `addDevice(id, "firebase")` without the name fails with code 4 'push provider "" with type "firebase" not found'. The listing and removing tests register a device in setup with `addDevice(id, "firebase", undefined, name)`, where `name` comes from `STREAM_PUSH_PROVIDER_NAME` in `.env` (the name of a Firebase provider; the tests are skipped when it's empty). -- "Registering from the chat client SDKs" stays `it.skip('BLOCKED: ...')`. The fence shows two alternatives: `addDevice(id, "apn")` for single-provider apps and `addDevice(id, "apn", undefined, push_provider_name)` for multi-bundle apps. The test app is multi-bundle (named providers), and as the page documents, multi-bundle configurations require `push_provider_name`: the first call fails with code 4 'push provider "" with type "apn" not found'. One app can't run both lines. The app's provider named `apn` has type `firebase`, so the second line would also need an `apn`-type provider. - -### Excluded sections (Feeds) - -- `platform/02-identity-and-access/02-users.md` ("Guest users", "Anonymous users"), `platform/03-events-and-notifications/02-push-notifications.md` (all `JavaScript` / `React` / `React Native` fences), the Feeds fences of `04-push-devices.md` and the "Feeds Push Preferences" section of `05-push-preferences.md` use `@stream-io/feeds-client` (or notifee), not stream-chat-js. They're listed in `EXCLUDED_SECTIONS` (`sync/parse.ts`) and not validated. - -## Setup notes (not blockers) - -- **02-init_and_users/01-client_tokens_and_authentication**: `devToken` needs `disable_auth_checks: true`: toggle it for the test and restore it. -- **02-init_and_users/04-user_groups**: `team_id` / `team` need multi-tenancy. Toggle it server-side with `serverClient.updateApp({ multi_tenant_enabled })`, wait with `waitForAppSetting`, and restore it. -- **02-init_and_users/05-authless_users**: `setGuestUser` works. The guest id is rewritten to `guest--`: register `client.userID` (not the requested id) for cleanup. -- **03-channels/02-query_channels**: Predefined filter `user_messaging_channels` must be created first (`createPredefinedFilter` works via the API). -- **04-messages/02-file_uploads**: Browser `File` snippets: build `File` objects in the test. The custom CDN snippet (L682) references undefined `messageComposer` / `customCDN`. -- **04-messages/01-send_message**: URL enrichment depends on Stream scraping imgur (non-deterministic): assert loosely. Group mentions need a user group and permissions. -- **04-messages/05-pinned_messages**: `pinMessage(message, 120)` expires after 120s: don't assert after that. -- **04-messages/06-search**: Search indexing is eventually consistent: poll with `retry`. -- **04-messages/10-message_receipts**: Delivery receipts work on this app (probe: `last_delivered_at` set after `markChannelsDelivered`), despite the "contact support" note. -- **05-features/09-translation**: `translateMessage` works (probe). Enable auto-translation per channel server-side (`update({ data: { auto_translation_enabled: true } })`); no app setting to change. -- **05-features/10-advanced/11-slow_mode_and_throttling**: Slow mode cooldown is 30s in the snippet: use a short cooldown in assertions. -- **12-best_practices/02-moderation**: Review queue works client-side for a user with role `admin` (probe OK). -- **platform/02-identity-and-access/04-multi-tenancy**: Toggle multi-tenancy like `chat/_default/02-init_and_users/04-user_groups` (`serverClient.updateApp({ multi_tenant_enabled })` + `waitForAppSetting`, restore it). `team: "red"`: use a `uniqueId` team in a COPY variable. The "all teams" / "no team" queries need a user with `read-channel-any-team` (built-in `global_read_only` or `global_admin` role): for other users the API returns an access error (code 70) when inaccessible channels match, as the page documents. -- **platform/03-events-and-notifications/01-webhooks**: Don't verify that webhooks are delivered: asserting on the response or a getter is enough. The unlabelled fence ("Where each argument comes from") is node-sdk code: label it `Node.js`. -- **platform/03-events-and-notifications/04-push-devices**: Use the app's `firebase` provider (type and name `firebase`), see Blockers detail. Don't verify that a push arrives: `getDevices()` is enough. -- **platform/03-events-and-notifications/05-push-preferences**: Don't verify that pushes are delivered or suppressed. Setting preferences needs no push provider. Read them back client-side: user-level from a fresh `connectUser` (`me.push_preferences`), channel-member level from `queryChannels`/`watch` (`channel.push_preferences`). Channel preferences need an existing channel (code 16 "channel with CID ... does not exist"). -- **platform/04-using-the-api/06-connection-pooling**: The unlabelled fence ("SDK-specific notes") is node-sdk code (`StreamClient`, undici): label it `Node.js`; no test. -- **All pages that create/update channel types**: Wait 30s (`waitForChannelTypePropagation()`) after `createChannelType` / `updateChannelType` before using the type. - -## Known docs bugs - -Found while scanning the pages; agents should confirm and fix them (minimal change, listed in the final report). - -- **02-init_and_users/02-init_and_users**: `tokenProvider` undefined (L82); XHR fallback (L343) uses curly quotes and `enableWSFallBack` (SDK: `enableWSFallback`). -- **02-init_and_users/04-user_groups**: Users alice/bob/charlie/dave must exist. -- **03-channels/02-query_channels**: `thirtyDaysAgo`, `userId`, `channelCID` undefined (L857, L1490); fragment snippets redeclare `const filter`. -- **03-channels/06-channel_members**: `cutoff.setDate(date.getDate() - 7)`: `date` undefined (L504). -- **03-channels/07-channel_management/10-muting**: Uses `moment` (not a dependency); `expiration` is typed `number`. -- **03-channels/07-channel_management/14-freezing**: Missing `}` in `{ set: { frozen: true }` (L12); `const update` declared twice. -- **03-channels/07-channel_management/15-truncating**: Missing comma after `'text'` (L22); `user` undefined. -- **03-channels/07-channel_management/16-channel_invites**: `rejected` declared twice and missing `await` (L545, L693). -- **04-messages/01-send_message**: `josh` undefined (attachments). -- **04-messages/04-send_reaction**: `const reaction` / `const response` redeclared. -- **04-messages/07-silent_messages**: `systemUser`, `tripData` undefined. -- **04-messages/09-message_reminders**: Positional `createReminder("message-id","user-id",date)` / `updateReminder(...)`: the SDK takes an object (`createReminder({ messageId, ... })`); `offsetMs` undefined. -- **05-features/02-events**: `myClientEventListener` / `myChannelEventListener` only defined in comments (L556). -- **05-features/07-polls_api**: Syntax errors around L305, L516-517, L800. -- **12-best_practices/02-moderation**: `const flag` declared twice (L887); `console.log(next)` undefined (L1059); `ctx.createUsers` / `ctx.serverClient()` test-harness code (L1205). - -## Possible SDK issues (check when migrating the snippets to v10) - -Found while testing the v9 snippets. Not fixed: leave v9 as is. - -- **`ThreadManager.loadNextPage()`** (`04-messages/03-threads`): a silent no-op until the first page is loaded (`nextCursor` starts `null`); the first page needs `reload()`. Should it load the first page when not `ready`? -- **`BasePaginator`** (`04-messages/09-message_reminders`, `src/pagination/BasePaginator.ts`): `hasPrev` starts `true` and is only updated in cursor mode, so `queryPrevious…` on a first page without cursors re-fetches and appends it (duplicates, e.g. 4 reminders become 8). `getStateAfterQuery` always appends, also for the `prev` direction, so an earlier page in cursor mode is added at the end. -- **`ReminderManager`** (`04-messages/09-message_reminders`): paginator results only reach `client.reminders.state` after `client.reminders.registerSubscriptions()`, which the docs never call; `client.reminders.reminders` stays empty. By design? -- **Search `previous` cursor** (`04-messages/06-search`, backend): `next: page2.previous` returns page 1's messages in reverse order (the cursor flips every sort direction, results aren't flipped back). Backend bug, or document it. -- **Reminder pagination** (`04-messages/09-message_reminders`, backend): with the default sort and a page boundary on a `remind_at: null` reminder, the `next` cursor returned an empty page although more reminders existed. -- **`PollOptionResponse` custom data** (`05-features/07-polls_api`, `src/types.ts`): extends `CustomPollData` instead of `CustomPollOptionData`, so custom option fields aren't typed on option responses (or in `PartialPollOptionUpdate.set`). Fixing it is a breaking type change for apps that put option fields on `CustomPollData`: do it in v10. - -## Possible API issues - -Found while testing. Not blockers. - -- **Multi-tenancy, code 70 hint** (`platform/02-identity-and-access/04-multi-tenancy`, backend): the access error for cross-team queries (documented: "an access error will be returned") suggests "Did you forget to include {members: $in: [...]}?" even when the filter already has it. Misleading hint. -- **Multi-tenancy, cross-team membership** (backend): server-side `getOrCreate({ data: { team, members } })` accepts members from other teams, but `update({ add_members })` rejects them (code 5 "user from teams [...] cannot be a member of a channel with team ..."). Inconsistent. -- **Push preferences response** (backend): the `setPushPreferences` response for a channel-only update has `user_preferences: { : null }`. - -## Notes - -- `chat/_default/03-channels/07-channel_management/17-batch-updates.md`: all `JavaScript` fences were server-only (batch updates and `getTask` fail with a user token: "this endpoint can only be called server side") and were removed, so the page has no client test. +- [ ] **Object-valued custom fields in filters** (`generator/templates/templates/typescript/models.tpl`, `CustomHelper`): a typed `custom.` key is built for every custom data field, including object-valued ones (e.g. `CustomChannelData.source_detail: { user_id: number }`). Its value doesn't fit the `custom.${string}` index signature (`string | boolean | number | Date`), so every filter type built from the custom data stops being assignable, including the SDK's own search sources (`ChannelSearchSource`, `MessageSearchSource`). Fix: only emit typed keys for primitive and `Date` fields (`NonNullable extends string | boolean | number | Date`); object fields fall back to the index signature, which already accepts nested paths like `custom.source_detail.user_id`. Covered by `test/types/custom-filters.ts`. **Open PR**: https://github.com/GetStream/chat/pull/18709 +- [ ] **Request dates typed as response dates** (OpenAPI spec): `created_at_after`, `created_at_after_or_equal`, `created_at_before` and `created_at_before_or_equal` on `QueryMembersPayload`, `QueryBannedUsersPayload` and `QueryFutureChannelBansPayload` are typed `TimestampNS` (the spec types them as numbers, the server-sent direction). They are request fields, sent in the JSON `payload` query param, and the server parses them as RFC 3339: a number fails with `Time.UnmarshalJSON: input is not a JSON string` (`03-channels/04-query_members`, "Paginate by created_at"). Fix the spec so they are request (string) dates, which the generator types as `Date`. Also check `ChannelStopWatchingRequest` (`created_at`, `received_at` typed `TimestampNS`), not investigated. **Reported here**: https://getstream.slack.com/archives/C07AL9Q0T2T/p1791987002057839?thread_ts=1791403182.844109&cid=C07AL9Q0T2T +- [ ] **Empty `team` channel filter** (OpenAPI spec + `models.tpl`, `Filters`): with multi-tenancy, `team: {}` queries channels across all teams (`platform/02-identity-and-access/04-multi-tenancy`, "Querying chat channels"), but every filter key only accepted an operator object with exactly one operator. Patched by hand: an `allowEmpty: true` flag on the `team` entries of `QueryChannelsRequest` and `SearchPayload` (both are `ChannelFilters`, and `MessageSearchSource` assigns one to the other), which `Filters` turns into an extra `Record` member. Fix: have the spec mark the key and the template emit the flag and the `Filters` branch. Covered by `test/types/team-filter.ts`. **Open PR:**: https://github.com/GetStream/chat/pull/18709 diff --git a/test/docs-snippets/helpers/clients.ts b/test/docs-snippets/helpers/clients.ts index a284035998..2463b1e6ce 100644 --- a/test/docs-snippets/helpers/clients.ts +++ b/test/docs-snippets/helpers/clients.ts @@ -1,7 +1,6 @@ import { StreamClient } from '@stream-io/node-sdk'; import type { UserRequest } from '@stream-io/node-sdk'; import { StreamChat } from '../../../src'; -import type { UserResponse } from '../../../src'; const apiKey = () => process.env.STREAM_API_KEY as string; const apiSecret = () => process.env.STREAM_API_SECRET as string; @@ -16,9 +15,12 @@ export const getServerClient = () => new StreamClient(apiKey(), apiSecret()); export const createUserToken = (userId: string) => getServerClient().generateUserToken({ user_id: userId }); +/** The user `connectUser` takes: only `id` is required, custom data under `custom`. */ +export type TestUser = Parameters[0]; + /** node-sdk requires `enabled` on each privacy setting; unset means enabled. */ const toPrivacySettings = ( - settings: UserResponse['privacy_settings'], + settings: TestUser['privacy_settings'], ): UserRequest['privacy_settings'] => settings && Object.fromEntries( @@ -28,57 +30,44 @@ const toPrivacySettings = ( ]), ); -/** - * Converts a stream-chat-js user (custom fields at the top level) to a node-sdk - * `UserRequest` (custom fields under `custom`). - */ -export const toUserRequest = (user: UserResponse & { id: string }): UserRequest => { - const { - id, - image, - invisible, - language, - name, - privacy_settings, - role, - teams, - teams_role, - ...rest - } = user; - const custom = Object.fromEntries( - Object.entries(rest).filter(([, value]) => value !== undefined), - ); - return { - id, - image, - invisible, - language, - name, - privacy_settings: toPrivacySettings(privacy_settings), - role, - teams, - teams_role: teams_role ?? undefined, - ...(Object.keys(custom).length ? { custom } : {}), - }; -}; +/** Converts a stream-chat-js user to a node-sdk `UserRequest`. */ +export const toUserRequest = ({ + custom, + id, + image, + invisible, + language, + name, + privacy_settings, + role, + teams, + teams_role, +}: TestUser): UserRequest => ({ + id, + image, + invisible, + language, + name, + privacy_settings: toPrivacySettings(privacy_settings), + role, + teams, + teams_role, + ...(custom && Object.keys(custom).length ? { custom } : {}), +}); -/** Creates or replaces users (stream-chat-js shape, custom fields at the top level). */ -export const upsertUsers = ( - serverClient: StreamClient, - users: Array, -) => serverClient.upsertUsers(users.map(toUserRequest)); +/** Creates or replaces users (stream-chat-js shape). */ +export const upsertUsers = (serverClient: StreamClient, users: TestUser[]) => + serverClient.upsertUsers(users.map(toUserRequest)); /** * Client-side client connected as `user` with a user token, the way an app would. * The user is upserted first server-side. * Remember to call `disconnectClients` in `afterAll`. */ -export const getClientSideClient = async ( - user: UserResponse & { id: string }, -): Promise => { +export const getClientSideClient = async (user: TestUser): Promise => { await upsertUsers(getServerClient(), [user]); // `new StreamChat` instead of `getInstance` so several users can be connected in one file. - const client = new StreamChat(apiKey(), { allowServerSideConnect: true }); + const client = new StreamChat(apiKey(), { client: { allowServerSideConnect: true } }); await client.connectUser(user, createUserToken(user.id)); return client; }; diff --git a/test/docs-snippets/pages/chat/02-init_and_users/01-client_tokens_and_authentication.test.ts b/test/docs-snippets/pages/chat/02-init_and_users/01-client_tokens_and_authentication.test.ts index a594ca2d88..cdd5c593ec 100644 --- a/test/docs-snippets/pages/chat/02-init_and_users/01-client_tokens_and_authentication.test.ts +++ b/test/docs-snippets/pages/chat/02-init_and_users/01-client_tokens_and_authentication.test.ts @@ -12,7 +12,7 @@ describe('_default/02-init_and_users/01-client_tokens_and_authentication.md', () cleanup.users.push(userId); // A client initialized with the API key alone, the way the docs set it up. const client = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); let originalDisableAuthChecks = false; @@ -22,10 +22,10 @@ describe('_default/02-init_and_users/01-client_tokens_and_authentication.md', () const deadline = Date.now() + 75000; for (;;) { const probe = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); try { - await probe.connectUser({ id: userId }, probe.devToken(userId)); + await probe.connectUser({ id: userId }, client.devToken(userId)); await probe.disconnectUser(); return; } catch (error) { @@ -52,10 +52,10 @@ describe('_default/02-init_and_users/01-client_tokens_and_authentication.md', () if (originalDisableAuthChecks) skip('auth checks are already disabled on the app'); const devClient = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); await expect( - devClient.connectUser({ id: userId }, devClient.devToken(userId)), + devClient.connectUser({ id: userId }, client.devToken(userId)), ).rejects.toThrow(/"StatusCode":401/); await devClient.disconnectUser(); }); @@ -89,7 +89,7 @@ describe('_default/02-init_and_users/01-client_tokens_and_authentication.md', () // #endregion snippet expect(client.userID).toBe(userId); - expect(client.wsConnection?.isHealthy).toBe(true); + expect(client.wsConnection.isHealthy).toBe(true); const user = await getServerUser(serverClient, userId); expect(user.name).toBe('John Doe'); }); diff --git a/test/docs-snippets/pages/chat/02-init_and_users/02-init_and_users.test.ts b/test/docs-snippets/pages/chat/02-init_and_users/02-init_and_users.test.ts index 8d3cbadc5f..9064dccf95 100644 --- a/test/docs-snippets/pages/chat/02-init_and_users/02-init_and_users.test.ts +++ b/test/docs-snippets/pages/chat/02-init_and_users/02-init_and_users.test.ts @@ -12,7 +12,7 @@ const apiKey = process.env.STREAM_API_KEY as string; import { StreamChat } from 'stream-chat'; const chatClient = StreamChat.getInstance(apiKey, { - timeout: 6000, + client: { axiosRequestConfig: { timeout: 6000 } }, }); // #endregion snippet @@ -33,7 +33,7 @@ describe('_default/02-init_and_users/02-init_and_users.md', () => { it('initializes the client as a singleton', () => { expect(chatClient.key).toBe(apiKey); - expect(chatClient.options.timeout).toBe(6000); + expect(chatClient.axiosInstance.defaults.timeout).toBe(6000); expect(StreamChat.getInstance(apiKey)).toBe(chatClient); }); @@ -54,7 +54,7 @@ describe('_default/02-init_and_users/02-init_and_users.md', () => { // #endregion snippet expect(chatClient.userID).toBe(userId); - expect(chatClient.wsConnection?.isHealthy).toBe(true); + expect(chatClient.wsConnection.isHealthy).toBe(true); const user = await getServerUser(serverClient, userId); expect(user.name).toBe('John Doe'); expect(user.image).toBe('https://getstream.io/random_svg/?name=John'); @@ -64,7 +64,7 @@ describe('_default/02-init_and_users/02-init_and_users.md', () => { // #endregion snippet expect(chatClient.userID).toBeUndefined(); - expect(chatClient.wsConnection?.isHealthy).toBe(false); + expect(chatClient.wsConnection.isHealthy).toBe(false); }); it('connects a user with privacy settings', async () => { @@ -111,12 +111,13 @@ describe('_default/02-init_and_users/02-init_and_users.md', () => { // #region snippet docs="chat/_default/02-init_and_users/02-init_and_users.md" heading="XHR Fallback" tab="JavaScript" index=1 // COPY: apiKey="apiKey" - const chatClient = StreamChat.getInstance(apiKey, { enableWSFallback: true }); + const chatClient = StreamChat.getInstance(apiKey); + chatClient.config.set({ client: { wsConnection: { enableWSFallback: true } } }); // #endregion snippet clients.push(chatClient); expect(chatClient).not.toBe(previousClient); - expect(chatClient.options.enableWSFallback).toBe(true); + expect(chatClient.wsConnection.config.enableWSFallback).toBe(true); await chatClient.connectUser({ id: fallbackUserId }, createUserToken(fallbackUserId)); expect(chatClient.userID).toBe(fallbackUserId); diff --git a/test/docs-snippets/pages/chat/02-init_and_users/04-user_groups.test.ts b/test/docs-snippets/pages/chat/02-init_and_users/04-user_groups.test.ts index 80fd9bcd88..2d3527a9d3 100644 --- a/test/docs-snippets/pages/chat/02-init_and_users/04-user_groups.test.ts +++ b/test/docs-snippets/pages/chat/02-init_and_users/04-user_groups.test.ts @@ -95,13 +95,13 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { member_ids: [alice, bob, charlie], }); // #endregion snippet - groupIds.push(user_group.id); + if (user_group) groupIds.push(user_group.id); - expect(user_group.id).toBeTruthy(); - expect(user_group.name).toBe(groupName); - expect(user_group.description).toBe('Product design team members'); - expect(user_group.created_by).toBe(userId); - expect(user_group.members?.map((member) => member.user_id).sort()).toEqual( + expect(user_group?.id).toBeTruthy(); + expect(user_group?.name).toBe(groupName); + expect(user_group?.description).toBe('Product design team members'); + expect(user_group?.created_by).toBe(userId); + expect(user_group?.members?.map((member) => member.user_id).sort()).toEqual( [alice, bob, charlie].sort(), ); }); @@ -109,66 +109,84 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { it('updates a user group', async () => { // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Updating a User Group" tab="JavaScript" index=1 // COPY: groupId="design-team", updatedGroupName="Design & Product Team" - const { user_group } = await client.updateUserGroup(groupId, { - name: updatedGroupName, - description: 'Product and design team members', - }); + const { user_group } = await client.updateUserGroup( + { id: groupId }, + { + name: updatedGroupName, + description: 'Product and design team members', + }, + ); // #endregion snippet - expect(user_group.name).toBe(updatedGroupName); - expect(user_group.description).toBe('Product and design team members'); + expect(user_group?.name).toBe(updatedGroupName); + expect(user_group?.description).toBe('Product and design team members'); }); it('adds members and changes their admin status', async () => { // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Adding Members" tab="JavaScript" index=1 // COPY: groupId="design-team", dave="dave", eve="eve", frank="frank", grace="grace" // Add members as regular members - const { user_group } = await client.addUserGroupMembers(groupId, { - member_ids: [dave, eve, frank], - }); + const { user_group } = await client.addUserGroupMembers( + { id: groupId }, + { + member_ids: [dave, eve, frank], + }, + ); // Add members as group admins - const { user_group: admins } = await client.addUserGroupMembers(groupId, { - member_ids: [grace], - as_admin: true, - }); + const { user_group: admins } = await client.addUserGroupMembers( + { id: groupId }, + { + member_ids: [grace], + as_admin: true, + }, + ); // Promote an existing member to admin - await client.addUserGroupMembers(groupId, { - member_ids: [dave], - as_admin: true, - }); + await client.addUserGroupMembers( + { id: groupId }, + { + member_ids: [dave], + as_admin: true, + }, + ); // Demote an admin back to regular member - await client.addUserGroupMembers(groupId, { - member_ids: [dave], - as_admin: false, - }); + await client.addUserGroupMembers( + { id: groupId }, + { + member_ids: [dave], + as_admin: false, + }, + ); // #endregion snippet - const isAdmin = (id: string, members = user_group.members) => + const isAdmin = (id: string, members = user_group?.members) => members?.find((member) => member.user_id === id)?.is_admin; expect(isAdmin(dave)).toBe(false); expect(isAdmin(frank)).toBe(false); - expect(isAdmin(grace, admins.members)).toBe(true); + expect(isAdmin(grace, admins?.members)).toBe(true); - const { user_group: group } = await client.getUserGroup(groupId); - expect(group.members?.map((member) => member.user_id).sort()).toEqual( + const { user_group: group } = await client.getUserGroup({ id: groupId }); + expect(group?.members?.map((member) => member.user_id).sort()).toEqual( [dave, eve, frank, grace].sort(), ); - expect(isAdmin(dave, group.members)).toBe(false); - expect(isAdmin(grace, group.members)).toBe(true); + expect(isAdmin(dave, group?.members)).toBe(false); + expect(isAdmin(grace, group?.members)).toBe(true); }); it('removes members', async () => { // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Removing Members" tab="JavaScript" index=1 // COPY: groupId="design-team", dave="dave", eve="eve" - const { user_group } = await client.removeUserGroupMembers(groupId, { - member_ids: [dave, eve], - }); + const { user_group } = await client.removeUserGroupMembers( + { id: groupId }, + { + member_ids: [dave, eve], + }, + ); // #endregion snippet - expect(user_group.members?.map((member) => member.user_id).sort()).toEqual( + expect(user_group?.members?.map((member) => member.user_id).sort()).toEqual( [frank, grace].sort(), ); }); @@ -188,14 +206,17 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { it('retrieves a user group', async () => { // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Retrieving a User Group" tab="JavaScript" index=1 // COPY: teamGroupId="design-team", teamId="my-team" - const { user_group } = await client.getUserGroup(teamGroupId, { - team_id: teamId, // only with multi-tenancy - }); + const { user_group } = await client.getUserGroup( + { id: teamGroupId }, + { + team_id: teamId, // only with multi-tenancy + }, + ); // #endregion snippet - expect(user_group.id).toBe(teamGroupId); - expect(user_group.name).toBe(teamGroupName); - expect(user_group.team_id).toBe(teamId); + expect(user_group?.id).toBe(teamGroupId); + expect(user_group?.name).toBe(teamGroupName); + expect(user_group?.team_id).toBe(teamId); }); it('lists user groups', async () => { @@ -203,7 +224,7 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { const lastId = DOCS_TEST_PREFIX; // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Listing User Groups" tab="JavaScript" index=1 // COPY: lastId="last-id", teamId="my-team" - const { user_groups } = await client.queryUserGroups({ + const { user_groups } = await client.listUserGroups({ limit: 20, id_gt: lastId, // optional: cursor for pagination created_at_gt: '2024-01-01T00:00:00Z', // optional: filter by creation date @@ -235,14 +256,17 @@ describe('_default/02-init_and_users/04-user_groups.md', () => { it('deletes a user group', async () => { // #region snippet docs="chat/_default/02-init_and_users/04-user_groups.md" heading="Deleting a User Group" tab="JavaScript" index=1 // COPY: teamGroupId="design-team", teamId="my-team" - await client.deleteUserGroup(teamGroupId, { - team_id: teamId, // only with multi-tenancy - }); + await client.deleteUserGroup( + { id: teamGroupId }, + { + team_id: teamId, // only with multi-tenancy + }, + ); // #endregion snippet - await expect(client.getUserGroup(teamGroupId, { team_id: teamId })).rejects.toThrow( - /not found/, - ); + await expect( + client.getUserGroup({ id: teamGroupId }, { team_id: teamId }), + ).rejects.toThrow(/not found/); }); }); }); diff --git a/test/docs-snippets/pages/chat/02-init_and_users/05-authless_users.test.ts b/test/docs-snippets/pages/chat/02-init_and_users/05-authless_users.test.ts index cbb36a2b89..4285425fa3 100644 --- a/test/docs-snippets/pages/chat/02-init_and_users/05-authless_users.test.ts +++ b/test/docs-snippets/pages/chat/02-init_and_users/05-authless_users.test.ts @@ -13,7 +13,7 @@ describe('_default/02-init_and_users/05-authless_users.md', () => { // A fresh client without a user, the way a front end creates it before anyone logs in. const newClient = () => { - const client = new StreamChat(apiKey, { allowServerSideConnect: true }); + const client = new StreamChat(apiKey, { client: { allowServerSideConnect: true } }); clients.push(client); return client; }; diff --git a/test/docs-snippets/pages/chat/03-channels/01-creating_channels.test.ts b/test/docs-snippets/pages/chat/03-channels/01-creating_channels.test.ts index f82687918f..b93228b023 100644 --- a/test/docs-snippets/pages/chat/03-channels/01-creating_channels.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/01-creating_channels.test.ts @@ -34,7 +34,7 @@ describe('_default/03-channels/01-creating_channels.md', () => { // #region snippet docs="chat/_default/03-channels/01-creating_channels.md" heading="Creating a Channel Using a Channel ID" tab="JavaScript" index=1 // COPY: travel="travel" const channel = client.channel('messaging', travel, { - name: 'Awesome channel about traveling', + custom: { name: 'Awesome channel about traveling' }, }); // Here, 'travel' will be the channel ID await channel.create(); @@ -52,7 +52,7 @@ describe('_default/03-channels/01-creating_channels.md', () => { // #region snippet docs="chat/_default/03-channels/01-creating_channels.md" heading="Distinct Channels" tab="JavaScript" index=1 // COPY: thierry="thierry", tommaso="tommaso" const channel = client.channel('messaging', { - members: [thierry, tommaso], + members: [{ user_id: thierry }, { user_id: tommaso }], }); await channel.create(); // #endregion snippet @@ -62,7 +62,9 @@ describe('_default/03-channels/01-creating_channels.md', () => { expect(Object.keys(channel.state.members).sort()).toEqual([thierry, tommaso].sort()); // Same members, in any order: the same channel. - const again = client.channel('messaging', { members: [tommaso, thierry] }); + const again = client.channel('messaging', { + members: [{ user_id: tommaso }, { user_id: thierry }], + }); await again.create(); expect(again.cid).toBe(channel.cid); }); @@ -79,8 +81,8 @@ describe('_default/03-channels/01-creating_channels.md', () => { // #endregion snippet // get-or-create: the channel didn't exist before. - expect(state.channel.id).toBe(travelChannel); - expect(state.channel.created_by?.id).toBe(thierry); + expect(state.channel?.id).toBe(travelChannel); + expect(state.channel?.created_by?.id).toBe(thierry); expect(Array.isArray(state.messages)).toBe(true); // Watched: events of the channel now reach this client over the WebSocket. const received = new Promise((resolve) => { diff --git a/test/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts b/test/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts index 8bf224b63b..21be6d5e60 100644 --- a/test/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/02-query_channels.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { StreamChat } from '../../../../../src'; +import type { ChannelFilters, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, @@ -54,15 +54,15 @@ describe(DOCS, () => { it('queries channels', async () => { // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="" tab="JavaScript" index=1 // COPY: thierry="thierry" - const channels = await chatClient.queryChannels( - { type: 'messaging', members: { $in: [thierry] } }, - [{ last_message_at: -1 }], - { limit: 15 }, - ); + const channels = await chatClient.queryChannelsAndHydrate({ + filter_conditions: { type: 'messaging', members: { $in: [thierry] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 15, + }); // #endregion snippet expect(channels.map((c) => c.cid)).toEqual(newestFirst); - expect(channels[0].state.messages.length).toBeGreaterThan(0); + expect(channels[0].messagePaginator.items?.length).toBeGreaterThan(0); }); it('filters messaging channels by member', async () => { @@ -71,7 +71,10 @@ describe(DOCS, () => { const filter = { members: { $in: [thierry] }, type: 'messaging' }; // #endregion snippet - const channels = await chatClient.queryChannels(filter, {}, { limit: 30 }); + const channels = await chatClient.queryChannelsAndHydrate({ + filter_conditions: filter, + limit: 30, + }); expect(channels.map((c) => c.cid).sort()).toEqual([...newestFirst].sort()); }); @@ -81,11 +84,12 @@ describe(DOCS, () => { // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Pagination" tab="JavaScript" index=1 // COPY: thierry="thierry" // Get channels 11-30 - const channels = await authClient.queryChannels( - { members: { $in: [thierry] } }, - { last_message_at: -1 }, - { limit: 20, offset: 10 }, - ); + const channels = await authClient.queryChannelsAndHydrate({ + filter_conditions: { members: { $in: [thierry] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, + offset: 10, + }); // #endregion snippet expect(channels.map((c) => c.cid)).toEqual(newestFirst.slice(10)); @@ -97,9 +101,9 @@ describe(DOCS, () => { const channel = chatClient.channel('messaging', channelId); // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Channel Creation and Watching" tab="JavaScript" index=1 - channel.create(); - channel.query(); - channel.watch(); + await channel.create(); + await channel.query(); + await channel.watch(); // #endregion snippet const created = await retry( @@ -123,24 +127,32 @@ describe(DOCS, () => { const typeFilter = { type: 'messaging' }; // #endregion snippet - const byCid = await chatClient.queryChannels(cidFilter); + const byCid = await chatClient.queryChannelsAndHydrate({ + filter_conditions: cidFilter, + }); expect(byCid.map((c) => c.cid)).toEqual([channelCID]); - const byMembers = await chatClient.queryChannels(membersFilter, {}, { limit: 30 }); + const byMembers = await chatClient.queryChannelsAndHydrate({ + filter_conditions: membersFilter, + limit: 30, + }); expect(byMembers).toHaveLength(CHANNEL_COUNT); - const byType = await chatClient.queryChannels(typeFilter, {}, { limit: 1 }); + const byType = await chatClient.queryChannelsAndHydrate({ + filter_conditions: typeFilter, + limit: 1, + }); expect(byType.every((c) => c.type === 'messaging')).toBe(true); }); it('sorts by last_message_at', async () => { // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Sort Best Practices" tab="JavaScript" index=1 - const sort = { last_message_at: -1 }; + const sort = [{ field: 'last_message_at', direction: -1 }]; // #endregion snippet - const channels = await chatClient.queryChannels( - { members: { $in: [thierry] } }, - { last_message_at: sort.last_message_at < 0 ? -1 : 1 }, - { limit: 30 }, - ); + const channels = await chatClient.queryChannelsAndHydrate({ + filter_conditions: { members: { $in: [thierry] } }, + sort, + limit: 30, + }); expect(channels.map((c) => c.cid)).toEqual(newestFirst); }); @@ -163,17 +175,15 @@ describe(DOCS, () => { // #endregion snippet // Both are valid filters: they work, they are just slow at scale. - const broad = await chatClient.queryChannels(broadFilter, {}, { limit: 1 }); + const broad = await chatClient.queryChannelsAndHydrate({ + filter_conditions: broadFilter, + limit: 1, + }); expect(broad.every((c) => c.type === 'messaging')).toBe(true); - // `nestedFilter.$and` is inferred as a plain array, not the `ArrayOneOrMore` tuple - // `ChannelFilters` asks for, so send it as the raw request body. Filtering on - // `disabled` needs the ReadDisabledChannel permission, which users don't have - // (the server test runs it). + // Filtering on `disabled` needs the ReadDisabledChannel permission, which users + // don't have, so the nested filter is rejected client-side. await expect( - chatClient.post(`${chatClient.baseURL}/channels`, { - filter_conditions: nestedFilter, - limit: 1, - }), + chatClient.queryChannels({ filter_conditions: nestedFilter, limit: 1 }), ).rejects.toThrow(/ReadDisabledChannel/); }); @@ -195,15 +205,12 @@ describe(DOCS, () => { // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Using Predefined Filters in Production" tab="JavaScript" index=1 // COPY: userMessagingChannels="user_messaging_channels" // Production-ready: Use Predefined Filter - const channels = await chatClient.queryChannels( - {}, // filter_conditions ignored with predefined_filter - { last_message_at: -1 }, - { - predefined_filter: userMessagingChannels, - filter_values: { user_id: userId }, - limit: 20, - }, - ); + const channels = await chatClient.queryChannelsAndHydrate({ + predefined_filter: userMessagingChannels, + filter_values: { user_id: userId }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, + }); // #endregion snippet expect(channels.map((c) => c.cid)).toEqual(newestFirst); @@ -214,15 +221,12 @@ describe(DOCS, () => { // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Using Predefined Filters" tab="JavaScript" index=1 // COPY: userMessagingChannels="user_messaging_channels", user123="user123" - const channels = await chatClient.queryChannels( - {}, // filter_conditions ignored with predefined_filter - { last_message_at: -1 }, - { - predefined_filter: userMessagingChannels, - filter_values: { user_id: user123 }, - limit: 20, - }, - ); + const channels = await chatClient.queryChannelsAndHydrate({ + predefined_filter: userMessagingChannels, + filter_values: { user_id: user123 }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, + }); // #endregion snippet expect(channels.map((c) => c.cid)).toEqual(newestFirst); @@ -239,20 +243,112 @@ describe(DOCS, () => { }; // #endregion snippet - const channels = await chatClient.queryChannels(filter, {}, { limit: 30 }); + const channels = await chatClient.queryChannelsAndHydrate({ + filter_conditions: filter, + limit: 30, + }); expect(channels).toHaveLength(CHANNEL_COUNT); }); it('paginates with a members filter', async () => { // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Pagination Best Practices" tab="JavaScript" index=1 // Efficient pagination with members filter - const channels = await chatClient.queryChannels( - { type: 'messaging', members: { $in: [userId] } }, - { last_message_at: -1 }, - { limit: 20 }, - ); + const channels = await chatClient.queryChannelsAndHydrate({ + filter_conditions: { type: 'messaging', members: { $in: [userId] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, + }); // #endregion snippet expect(channels.map((c) => c.cid)).toEqual(newestFirst); }); + + // Own user and channels: hiding or muting the shared ones would change the other tests' results. + describe('hidden and muted channels', () => { + const memberId = uniqueId('member'); + // The docs' placeholder value. + const userID = memberId; + const visibleCid = `messaging:${uniqueId('visible')}`; + const hiddenCid = `messaging:${uniqueId('hidden')}`; + const mutedCid = `messaging:${uniqueId('muted')}`; + let memberClient: StreamChat; + + const queryCids = async (filter_conditions: ChannelFilters) => { + const channels = await memberClient.queryChannelsAndHydrate({ + filter_conditions, + limit: 30, + }); + return channels.map((c) => c.cid).sort(); + }; + + beforeAll(async () => { + cleanup.users.push(memberId); + cleanup.channels.push(visibleCid, hiddenCid, mutedCid); + memberClient = await getClientSideClient({ id: memberId }); + for (const cid of [visibleCid, hiddenCid, mutedCid]) { + const [type, id] = cid.split(':'); + await serverClient.chat.channel(type, id).getOrCreate({ + data: { members: [{ user_id: memberId }], created_by_id: other }, + }); + } + const [hiddenType, hiddenId] = hiddenCid.split(':'); + await serverClient.chat.channel(hiddenType, hiddenId).hide({ user_id: memberId }); + await serverClient.chat.muteChannel({ + channel_cids: [mutedCid], + user_id: memberId, + }); + }); + + afterAll(async () => { + await disconnectClients(memberClient); + }); + + it.skip("BLOCKED: lists a user's hidden and visible channels (the test app leaves the hidden channel out of a `$or: [{ hidden: true }, { hidden: false }]` filter unless it has a top-level `cid: { $in }`)", async () => { + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Listing a User's Channels" tab="JavaScript" index=1 + const filter = { + type: 'messaging', + members: { $in: [userID] }, + $or: [{ hidden: true }, { hidden: false }], + }; + // #endregion snippet + + expect(await queryCids(filter)).toEqual([visibleCid, hiddenCid, mutedCid].sort()); + // Without the `hidden` condition, the hidden channel is left out. + expect(await queryCids({ type: 'messaging', members: { $in: [userID] } })).toEqual( + [visibleCid, mutedCid].sort(), + ); + }); + + it('queries channels by id together with membership', async () => { + // The channels where the user has the member-level setting, kept in your backend. + const cids = [visibleCid, hiddenCid]; + + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Filtering by Member Data" tab="JavaScript" index=1 + const filter = { + cid: { $in: cids }, + members: { $in: [userID] }, + $or: [{ hidden: true }, { hidden: false }], + }; + // #endregion snippet + + expect(await queryCids(filter)).toEqual([visibleCid, hiddenCid].sort()); + }); + + it.skip('BLOCKED: combines the ids with muted channels (the test app leaves the hidden channel out: the `hidden` $or does not include hidden channels without a top-level `cid: { $in }`)', async () => { + const cids = [hiddenCid]; + + // #region snippet docs="chat/_default/03-channels/02-query_channels.md" heading="Filtering by Member Data" tab="JavaScript" index=2 + const filter = { + members: { $in: [userID] }, + $and: [ + { $or: [{ cid: { $in: cids } }, { muted: true }] }, + { $or: [{ hidden: true }, { hidden: false }] }, + ], + }; + // #endregion snippet + + // The hidden channel by id, the muted one by `muted`; the visible one matches neither. + expect(await queryCids(filter)).toEqual([hiddenCid, mutedCid].sort()); + }); + }); }); diff --git a/test/docs-snippets/pages/chat/03-channels/03-channel_update.test.ts b/test/docs-snippets/pages/chat/03-channels/03-channel_update.test.ts index 98e65f8e14..b0fda327dd 100644 --- a/test/docs-snippets/pages/chat/03-channels/03-channel_update.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/03-channel_update.test.ts @@ -33,9 +33,11 @@ describe('_default/03-channels/03-channel_update.md', () => { // #region snippet docs="chat/_default/03-channels/03-channel_update.md" heading="Partial Update" tab="JavaScript" index=1 // Here's a channel with some custom field data that might be useful const channel = client.channel(type, id, { - source: 'user', - source_detail: { user_id: 123 }, - channel_detail: { topic: 'Plants and Animals', rating: 'pg' }, + custom: { + source: 'user', + source_detail: { user_id: 123 }, + channel_detail: { topic: 'Plants and Animals', rating: 'pg' }, + }, }); await channel.create(); @@ -60,20 +62,22 @@ describe('_default/03-channels/03-channel_update.md', () => { it('fully updates a channel', async () => { const channel = client.channel('messaging', uniqueId('channel'), { - source: 'user', + custom: { source: 'user' }, }); await channel.create(); if (channel.cid) cleanup.channels.push(channel.cid); // #region snippet docs="chat/_default/03-channels/03-channel_update.md" heading="Full Update" tab="JavaScript" index=1 const update = await channel.update({ - name: 'myspecialchannel', - color: 'green', + data: { custom: { name: 'myspecialchannel', color: 'green' } }, }); // #endregion snippet - expect(update.channel).toMatchObject({ name: 'myspecialchannel', color: 'green' }); + expect(update.channel?.custom).toMatchObject({ + name: 'myspecialchannel', + color: 'green', + }); // Fields not included in a full update are removed. - expect(update.channel.source).toBeUndefined(); + expect(update.channel?.custom.source).toBeUndefined(); }); }); diff --git a/test/docs-snippets/pages/chat/03-channels/04-query_members.test.ts b/test/docs-snippets/pages/chat/03-channels/04-query_members.test.ts index ef63bfe34f..a4033a6cb1 100644 --- a/test/docs-snippets/pages/chat/03-channels/04-query_members.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/04-query_members.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, ChannelMemberAPIResponse, StreamChat } from '../../../../../src'; +import type { Channel, MembersResponse, StreamChat } from '../../../../../src'; +import { nsToDate } from '../../../../../src'; import { disconnectClients, getClientSideClient, @@ -12,7 +13,7 @@ const DOCS = '_default/03-channels/04-query_members.md'; /** Awaits every call recorded by a `queryMembers` spy (the docs don't keep the results). */ const spiedResults = async (spy: { mock: { results: Array<{ value: unknown }> } }) => - (await Promise.all(spy.mock.results.map((r) => r.value))) as ChannelMemberAPIResponse[]; + (await Promise.all(spy.mock.results.map((r) => r.value))) as MembersResponse[]; describe(DOCS, () => { const serverClient = getServerClient(); @@ -32,7 +33,9 @@ describe(DOCS, () => { { id: bob, name: 'bob' }, ]); client = await getClientSideClient({ id: owner }); - channel = client.channel('messaging', uniqueId('channel'), { members: [owner] }); + channel = client.channel('messaging', uniqueId('channel'), { + members: [{ user_id: owner }], + }); await channel.create(); if (channel.cid) cleanup.channels.push(channel.cid); // Added one by one so the members have distinct created_at values. @@ -51,13 +54,15 @@ describe(DOCS, () => { // #region snippet docs="chat/_default/03-channels/04-query_members.md" heading="" tab="JavaScript" index=1 // Query members by user name - channel.queryMembers({ name: 'tommaso' }); + await channel.queryMembers({ payload: { filter_conditions: { name: 'tommaso' } } }); // Autocomplete members by user name - channel.queryMembers({ name: { $autocomplete: 'tom' } }); + await channel.queryMembers({ + payload: { filter_conditions: { name: { $autocomplete: 'tom' } } }, + }); // Query all members - channel.queryMembers({}); + await channel.queryMembers(); // #endregion snippet const [byName, autocomplete, all] = await spiedResults(spy); @@ -71,39 +76,50 @@ describe(DOCS, () => { it('paginates channel members', async () => { // The docs' placeholder: the last member of a previous page (here the newest member). - const { members: newestFirst } = await channel.queryMembers( - {}, - { created_at: -1 }, - {}, - ); + const { members: newestFirst } = await channel.queryMembers({ + payload: { sort: [{ field: 'created_at', direction: -1 }] }, + }); const lastMember = newestFirst[0]; const spy = vi.spyOn(channel, 'queryMembers'); // #region snippet docs="chat/_default/03-channels/04-query_members.md" heading="Paginating Channel Members" tab="JavaScript" index=1 // Returns up to 100 members ordered by created_at descending - await channel.queryMembers({}, { created_at: -1 }, {}); + await channel.queryMembers({ + payload: { sort: [{ field: 'created_at', direction: -1 }] }, + }); // Returns up to 100 members ordered by user_id descending - await channel.queryMembers({}, { user_id: -1 }, {}); + await channel.queryMembers({ + payload: { sort: [{ field: 'user_id', direction: -1 }] }, + }); // Paginate by user_id in ascending order - await channel.queryMembers({}, { user_id: 1 }, { user_id_lt: lastMember.user_id }); + await channel.queryMembers({ + payload: { + sort: [{ field: 'user_id', direction: 1 }], + user_id_lt: lastMember.user_id, + }, + }); // Paginate by created_at in descending order - await channel.queryMembers( - {}, - { created_at: -1 }, - { created_at_before: lastMember.created_at }, - ); + // (created_at is a unix-nanosecond number: nsToDate from "stream-chat" converts it back to a Date) + await channel.queryMembers({ + payload: { + sort: [{ field: 'created_at', direction: -1 }], + created_at_before: nsToDate(lastMember.created_at), + }, + }); // Paginate using offset - await channel.queryMembers({}, { created_at: -1 }, { offset: 20 }); + await channel.queryMembers({ + payload: { sort: [{ field: 'created_at', direction: -1 }], offset: 20 }, + }); // #endregion snippet const [byCreatedAt, byUserIdDesc, userIdPage, createdAtPage, offsetPage] = await spiedResults(spy); spy.mockRestore(); - const ids = (r: ChannelMemberAPIResponse) => r.members.map((m) => m.user_id ?? ''); + const ids = (r: MembersResponse) => r.members.map((m) => m.user_id ?? ''); const all = [owner, tommaso, jane, bob]; expect(ids(byCreatedAt)).toEqual([bob, jane, tommaso, owner]); diff --git a/test/docs-snippets/pages/chat/03-channels/05-channel_pagination.test.ts b/test/docs-snippets/pages/chat/03-channels/05-channel_pagination.test.ts index 3d080d5720..66fc13e46d 100644 --- a/test/docs-snippets/pages/chat/03-channels/05-channel_pagination.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/05-channel_pagination.test.ts @@ -27,12 +27,14 @@ describe(DOCS, () => { await serverClient.upsertUsers(others.map((id) => ({ id }))); client = await getClientSideClient({ id: owner }); channel = client.channel('messaging', uniqueId('channel'), { - members: [owner, ...others], + members: [owner, ...others].map((user_id) => ({ user_id })), }); await channel.create(); if (channel.cid) cleanup.channels.push(channel.cid); for (let i = 0; i < MESSAGE_COUNT; i++) { - const { message } = await channel.sendMessage({ text: `message ${i}` }); + const { message } = await channel.sendMessage({ + message: { text: `message ${i}` }, + }); messageIds.push(message.id); } }); diff --git a/test/docs-snippets/pages/chat/03-channels/06-channel_members.test.ts b/test/docs-snippets/pages/chat/03-channels/06-channel_members.test.ts index cc44900f5c..bf5311134a 100644 --- a/test/docs-snippets/pages/chat/03-channels/06-channel_members.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/06-channel_members.test.ts @@ -26,7 +26,7 @@ describe('_default/03-channels/06-channel_members.md', () => { /** A channel created by the connected user (the owner), with the given extra members. */ const createOwnedChannel = async (members: string[] = []) => { const channel = client.channel('messaging', uniqueId('channel'), { - members: [owner, ...members], + members: [owner, ...members].map((user_id) => ({ user_id })), }); await channel.create(); if (channel.cid) cleanup.channels.push(channel.cid); @@ -82,7 +82,7 @@ describe('_default/03-channels/06-channel_members.md', () => { await channel.addMembers([{ user_id: jamesBond, channel_role: 'channel_moderator' }]); // Add new channel member with custom data - await channel.addMembers([{ user_id: alecTrevelyan, code_name: '006' }]); + await channel.addMembers([{ user_id: alecTrevelyan, custom: { code_name: '006' } }]); // #endregion snippet const members = await membersOf(channel); @@ -101,8 +101,8 @@ describe('_default/03-channels/06-channel_members.md', () => { // COPY: jamesBond="james_bond", alecTrevelyan="alec_trevelyan" const channel = client.channel('messaging', randomID, { members: [ - { user_id: jamesBond, code_name: '007' }, - { user_id: alecTrevelyan, code_name: '006' }, + { user_id: jamesBond, custom: { code_name: '007' } }, + { user_id: alecTrevelyan, custom: { code_name: '006' } }, ], }); await channel.create(); @@ -147,7 +147,7 @@ describe('_default/03-channels/06-channel_members.md', () => { it('hides history', async () => { const channel = await createOwnedChannel(); - await channel.sendMessage({ text: 'Before thierry joined' }); + await channel.sendMessage({ message: { text: 'Before thierry joined' } }); // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Hide History" tab="JavaScript" index=1 // COPY: thierry="thierry" @@ -160,14 +160,14 @@ describe('_default/03-channels/06-channel_members.md', () => { it('hides history before a date', async () => { const channel = await createOwnedChannel(); - await channel.sendMessage({ text: 'Sent today' }); + await channel.sendMessage({ message: { text: 'Sent today' } }); // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Hide History Before a Specific Date" tab="JavaScript" index=1 // COPY: thierry="thierry" const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 7); // Last 7 days await channel.addMembers([thierry], undefined, { - hide_history_before: cutoff.toISOString(), + hide_history_before: cutoff, }); // #endregion snippet @@ -201,15 +201,15 @@ describe('_default/03-channels/06-channel_members.md', () => { // Add custom data while creating the channel const channel = client.channel('messaging', randomID, { members: [ - { user_id: userid1, key1: 'value1' }, - { user_id: userid2, key1: 'value1' }, - { user_id: userid3, key2: 'value2' }, + { user_id: userid1, custom: { key1: 'value1' } }, + { user_id: userid2, custom: { key1: 'value1' } }, + { user_id: userid3, custom: { key2: 'value2' } }, ], }); await channel.create(); // Add custom data with addMembers method - await channel.addMembers([{ user_id: userid1, key1: 'value1' }]); + await channel.addMembers([{ user_id: userid1, custom: { key1: 'value1' } }]); // #endregion snippet const members = await membersOf(channel); diff --git a/test/docs-snippets/pages/chat/03-channels/07-channel_management/08-archiving.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/08-archiving.test.ts index 64d6ec8655..03b987f17a 100644 --- a/test/docs-snippets/pages/chat/03-channels/07-channel_management/08-archiving.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/08-archiving.test.ts @@ -19,7 +19,9 @@ describe('_default/03-channels/07-channel_management/08-archiving.md', () => { beforeAll(async () => { cleanup.users.push(userId); client = await getClientSideClient({ id: userId }); - const channel = client.channel('messaging', channelId, { members: [userId] }); + const channel = client.channel('messaging', channelId, { + members: [{ user_id: userId }], + }); cleanup.channels.push(`messaging:${channelId}`); await channel.create(); }); @@ -41,7 +43,9 @@ describe('_default/03-channels/07-channel_management/08-archiving.md', () => { await channel.archive(); // Query for channels that are not archived - const resp = await client.queryChannels({ archived: false }); + const resp = await client.queryChannelsAndHydrate({ + filter_conditions: { archived: false }, + }); // Unarchive the channel await channel.unarchive(); @@ -49,7 +53,7 @@ describe('_default/03-channels/07-channel_management/08-archiving.md', () => { const [archived] = await Promise.all(archiveSpy.mock.results.map((r) => r.value)); archiveSpy.mockRestore(); - expect(archived?.archived_at).toBeTruthy(); + expect(archived?.channel_member?.archived_at).toBeTruthy(); expect(resp.map((c) => c.cid)).not.toContain(`messaging:${channelId}`); const member = await getServerMember(serverClient, `messaging:${channelId}`, userId); diff --git a/test/docs-snippets/pages/chat/03-channels/07-channel_management/09-pinning.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/09-pinning.test.ts index 34c04f22a5..4bcb554767 100644 --- a/test/docs-snippets/pages/chat/03-channels/07-channel_management/09-pinning.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/09-pinning.test.ts @@ -26,10 +26,14 @@ describe('_default/03-channels/07-channel_management/09-pinning.md', () => { cleanup.channels.push(`messaging:${channelId}`, `messaging:${otherChannelId}`); // created first, so without the pinned_at sort it would come after the other channel await client - .channel('messaging', channelId, { members: [userId, amyId, benId] }) + .channel('messaging', channelId, { + members: [{ user_id: userId }, { user_id: amyId }, { user_id: benId }], + }) .create(); await client - .channel('messaging', otherChannelId, { members: [userId, amyId, benId] }) + .channel('messaging', otherChannelId, { + members: [{ user_id: userId }, { user_id: amyId }, { user_id: benId }], + }) .create(); }); @@ -48,13 +52,15 @@ describe('_default/03-channels/07-channel_management/09-pinning.md', () => { await channel.pin(); // Query for channels that are pinned - const resp = await client.queryChannels({ pinned: true }); + const resp = await client.queryChannelsAndHydrate({ + filter_conditions: { pinned: true }, + }); // Query for channels for specific members and show pinned first - const pinnedFirst = await client.queryChannels( - { members: { $in: [amyId, benId] } }, - { pinned_at: -1 }, - ); + const pinnedFirst = await client.queryChannelsAndHydrate({ + filter_conditions: { members: { $in: [amyId, benId] } }, + sort: [{ field: 'pinned_at', direction: -1 }], + }); // Unpin the channel await channel.unpin(); diff --git a/test/docs-snippets/pages/chat/03-channels/07-channel_management/10-muting.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/10-muting.test.ts index 701484464b..ff9256b250 100644 --- a/test/docs-snippets/pages/chat/03-channels/07-channel_management/10-muting.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/10-muting.test.ts @@ -1,10 +1,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { StreamChat } from '../../../../../../src'; +import type { Channel } from '../../../../../../src'; import { createUserToken, disconnectClients, getServerClient, } from '../../../../helpers/clients'; +import type { TestUser } from '../../../../helpers/clients'; import { Cleanup } from '../../../../helpers/cleanup'; import { uniqueId } from '../../../../helpers/ids'; import { retry } from '../../../../helpers/wait'; @@ -16,10 +18,10 @@ describe('_default/03-channels/07-channel_management/10-muting.md', () => { const channelId = uniqueId('channel'); const otherChannelId = uniqueId('other'); const premutedChannelId = uniqueId('premuted'); - const user = { id: userId }; + const user: TestUser = { id: userId }; const token = createUserToken(userId); const client = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); beforeAll(async () => { @@ -68,7 +70,8 @@ describe('_default/03-channels/07-channel_management/10-muting.md', () => { await channel.mute({ expiration: 10000 }); // check if channel is muted - // { muted: true | false, createdAt: Date | null, expiresAt: Date | null} + // { muted: true | false, createdAt: number | null, expiresAt: number | null } + // (timestamps in unix nanoseconds) channel.muteStatus(); // #endregion snippet @@ -82,28 +85,30 @@ describe('_default/03-channels/07-channel_management/10-muting.md', () => { return Promise.resolve(s); }); expect(status.muted).toBe(true); - expect(status.expiresAt).toBeInstanceOf(Date); + expect(typeof status.expiresAt).toBe('number'); }); it('queries muted and not muted channels', async () => { // make sure the mute doesn't expire during the query await client.channel('messaging', channelId).mute(); - const spy = vi.spyOn(client, 'queryChannels'); + const spy = vi.spyOn(client, 'queryChannelsAndHydrate'); // #region snippet docs="chat/_default/03-channels/07-channel_management/10-muting.md" heading="Query Muted Channels" tab="JavaScript" index=1 // retrieve all channels excluding muted ones - await client.queryChannels({ members: { $in: [userId] }, muted: false }); + await client.queryChannelsAndHydrate({ + filter_conditions: { members: { $in: [userId] }, muted: false }, + }); // retrieve all muted channels - await client.queryChannels({ muted: true }); + await client.queryChannelsAndHydrate({ filter_conditions: { muted: true } }); // #endregion snippet - const [notMuted, muted] = await Promise.all(spy.mock.results.map((r) => r.value)); + const [notMuted, muted]: Channel[][] = await Promise.all( + spy.mock.results.map((r) => r.value), + ); spy.mockRestore(); - expect(notMuted.map((c: { cid: string }) => c.cid)).toEqual([ - `messaging:${otherChannelId}`, - ]); - expect(muted.map((c: { cid: string }) => c.cid).sort()).toEqual( + expect(notMuted.map((c) => c.cid)).toEqual([`messaging:${otherChannelId}`]); + expect(muted.map((c) => c.cid).sort()).toEqual( [`messaging:${channelId}`, `messaging:${premutedChannelId}`].sort(), ); }); @@ -116,7 +121,9 @@ describe('_default/03-channels/07-channel_management/10-muting.md', () => { await channel.unmute(); // #endregion snippet - const muted = await client.queryChannels({ muted: true }); + const muted = await client.queryChannelsAndHydrate({ + filter_conditions: { muted: true }, + }); expect(muted.map((c) => c.cid)).toEqual([`messaging:${premutedChannelId}`]); }); }); diff --git a/test/docs-snippets/pages/chat/03-channels/07-channel_management/11-hiding.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/11-hiding.test.ts index bdf758c1df..cf009bad2d 100644 --- a/test/docs-snippets/pages/chat/03-channels/07-channel_management/11-hiding.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/11-hiding.test.ts @@ -23,7 +23,11 @@ describe('_default/03-channels/07-channel_management/11-hiding.md', () => { await serverClient.upsertUsers([{ id: otherId }]); client = await getClientSideClient({ id: userId }); cleanup.channels.push(`messaging:${channelId}`); - await client.channel('messaging', channelId, { members: [userId, otherId] }).create(); + await client + .channel('messaging', channelId, { + members: [{ user_id: userId }, { user_id: otherId }], + }) + .create(); await sendServerMessage(serverClient, `messaging:${channelId}`, { text: 'before hiding', user_id: otherId, @@ -50,7 +54,7 @@ describe('_default/03-channels/07-channel_management/11-hiding.md', () => { await channel.hide(); // hides the channel until a new message is added there. This also clears the history for the user - await channel.hide(null, true); + await channel.hide({ clear_history: true }); // shows a previously hidden channel await channel.show(); @@ -58,7 +62,12 @@ describe('_default/03-channels/07-channel_management/11-hiding.md', () => { // hiding an already hidden channel sends no second `channel.hidden` event await retry(() => { - expect(events.map((e) => [e.type, e.clear_history])).toEqual([ + expect( + events.map((e) => [ + e.type, + e.type === 'channel.hidden' ? e.clear_history : undefined, + ]), + ).toEqual([ ['channel.hidden', false], ['channel.visible', undefined], ]); @@ -67,7 +76,9 @@ describe('_default/03-channels/07-channel_management/11-hiding.md', () => { unsubscribe(); // shown again - const [shown] = await client.queryChannels({ cid: `messaging:${channelId}` }); + const [shown] = await client.queryChannelsAndHydrate({ + filter_conditions: { cid: `messaging:${channelId}` }, + }); expect(shown?.cid).toBe(`messaging:${channelId}`); // and the history was cleared for this user (read server-side: the client's local // state keeps the messages it already had, since no second hidden event arrived) @@ -82,8 +93,12 @@ describe('_default/03-channels/07-channel_management/11-hiding.md', () => { await channel.hide(); const filter = { cid: `messaging:${channelId}` }; - expect(await client.queryChannels(filter)).toEqual([]); - const hidden = await client.queryChannels({ ...filter, hidden: true }); + expect(await client.queryChannelsAndHydrate({ filter_conditions: filter })).toEqual( + [], + ); + const hidden = await client.queryChannelsAndHydrate({ + filter_conditions: { ...filter, hidden: true }, + }); expect(hidden.map((c) => c.cid)).toEqual([`messaging:${channelId}`]); await sendServerMessage(serverClient, `messaging:${channelId}`, { @@ -92,7 +107,9 @@ describe('_default/03-channels/07-channel_management/11-hiding.md', () => { }); // unhiding on a new message is asynchronous await retry(async () => { - const [found] = await client.queryChannels(filter); + const [found] = await client.queryChannelsAndHydrate({ + filter_conditions: filter, + }); expect(found?.cid).toBe(`messaging:${channelId}`); }); // after the cleared history, only the new message is visible to the user diff --git a/test/docs-snippets/pages/chat/03-channels/07-channel_management/12-disabling.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/12-disabling.test.ts index c691956183..a1dd8b97b7 100644 --- a/test/docs-snippets/pages/chat/03-channels/07-channel_management/12-disabling.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/12-disabling.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { StreamChat } from '../../../../../../src'; +import type { Channel, StreamChat } from '../../../../../../src'; import { disconnectClients, getClientSideClient, @@ -21,7 +21,7 @@ describe('_default/03-channels/07-channel_management/12-disabling.md', () => { client = await getClientSideClient({ id: userId }); for (const id of [enabledId, disabledId]) { cleanup.channels.push(`messaging:${id}`); - await client.channel('messaging', id, { members: [userId] }).create(); + await client.channel('messaging', id, { members: [{ user_id: userId }] }).create(); } await serverClient.chat .channel('messaging', disabledId) @@ -34,13 +34,13 @@ describe('_default/03-channels/07-channel_management/12-disabling.md', () => { }); it('filters out disabled channels', async () => { - const spy = vi.spyOn(client, 'queryChannels'); + const spy = vi.spyOn(client, 'queryChannelsAndHydrate'); // #region snippet docs="chat/_default/03-channels/07-channel_management/12-disabling.md" heading="" tab="JavaScript" index=1 - await client.queryChannels({ disabled: false }); + await client.queryChannelsAndHydrate({ filter_conditions: { disabled: false } }); // #endregion snippet - const results = await Promise.all(spy.mock.results.map((r) => r.value)); + const results: Channel[][] = await Promise.all(spy.mock.results.map((r) => r.value)); spy.mockRestore(); expect(results).toHaveLength(1); const cids = results.flat().map((c) => c.cid); @@ -50,8 +50,10 @@ describe('_default/03-channels/07-channel_management/12-disabling.md', () => { it('disabled channels are excluded client-side even without the filter', async () => { const cids = ( - await client.queryChannels({ - cid: { $in: [`messaging:${enabledId}`, `messaging:${disabledId}`] }, + await client.queryChannelsAndHydrate({ + filter_conditions: { + cid: { $in: [`messaging:${enabledId}`, `messaging:${disabledId}`] }, + }, }) ).map((c) => c.cid); expect(cids).toEqual([`messaging:${enabledId}`]); @@ -59,13 +61,14 @@ describe('_default/03-channels/07-channel_management/12-disabling.md', () => { it('client-side reads and writes of a disabled channel fail, and disabling is server-only', async () => { const disabled = client.channel('messaging', disabledId); - await expect(disabled.watch()).rejects.toThrow(/code 17.*is disabled/); - await expect(disabled.sendMessage({ text: 'hi' })).rejects.toThrow( - /code 17.*is disabled/, + const disabledError = { code: 17, message: expect.stringMatching(/is disabled/) }; + await expect(disabled.watch()).rejects.toMatchObject(disabledError); + await expect(disabled.sendMessage({ message: { text: 'hi' } })).rejects.toMatchObject( + disabledError, ); const enabled = client.channel('messaging', enabledId); - await expect(enabled.update({ disabled: true })).rejects.toThrow( + await expect(enabled.update({ data: { disabled: true } })).rejects.toThrow( 'changing channel disabled is not allowed client-side', ); await expect(enabled.updatePartial({ set: { disabled: true } })).rejects.toThrow( diff --git a/test/docs-snippets/pages/chat/03-channels/07-channel_management/13-deleting.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/13-deleting.test.ts index e6df983c1f..4304b5a2df 100644 --- a/test/docs-snippets/pages/chat/03-channels/07-channel_management/13-deleting.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/13-deleting.test.ts @@ -18,9 +18,11 @@ describe('_default/03-channels/07-channel_management/13-deleting.md', () => { const createChannel = async (name: string) => { const id = uniqueId(name); cleanup.channels.push(`messaging:${id}`); - const channel = client.channel('messaging', id, { members: [userId, otherId] }); + const channel = client.channel('messaging', id, { + members: [{ user_id: userId }, { user_id: otherId }], + }); await channel.create(); - await channel.sendMessage({ text: 'before deleting' }); + await channel.sendMessage({ message: { text: 'before deleting' } }); return channel; }; @@ -42,9 +44,11 @@ describe('_default/03-channels/07-channel_management/13-deleting.md', () => { const destroy = await channel.delete(); // #endregion snippet - expect(destroy.channel.cid).toBe(channel.cid); - expect(destroy.channel.deleted_at).toBeTruthy(); - expect(await client.queryChannels({ cid: channel.cid })).toEqual([]); + expect(destroy.channel?.cid).toBe(channel.cid); + expect(destroy.channel?.deleted_at).toBeTruthy(); + expect( + await client.queryChannelsAndHydrate({ filter_conditions: { cid: channel.cid } }), + ).toEqual([]); }); it('deletes many channels (soft and hard)', async () => { @@ -54,19 +58,24 @@ describe('_default/03-channels/07-channel_management/13-deleting.md', () => { // #region snippet docs="chat/_default/03-channels/07-channel_management/13-deleting.md" heading="Deleting Many Channels" tab="JavaScript" index=1 // soft delete - const response = await client.deleteChannels([cid1, cid2]); + const response = await client.deleteChannels({ cids: [cid1, cid2] }); // hard delete - const hardResponse = await client.deleteChannels([cid1, cid2], { + const hardResponse = await client.deleteChannels({ + cids: [cid1, cid2], hard_delete: true, }); const result = hardResponse.result; // holds deletion result // #endregion snippet // client-side the response has a per-cid `result` and no `task_id` - expect(Object.keys(response.result).sort()).toEqual([cid1, cid2].sort()); - expect(Object.keys(result).sort()).toEqual([cid1, cid2].sort()); + expect(Object.keys(response.result ?? {}).sort()).toEqual([cid1, cid2].sort()); + expect(Object.keys(result ?? {}).sort()).toEqual([cid1, cid2].sort()); expect(hardResponse.task_id).toBeUndefined(); - expect(await client.queryChannels({ cid: { $in: [cid1, cid2] } })).toEqual([]); + expect( + await client.queryChannelsAndHydrate({ + filter_conditions: { cid: { $in: [cid1, cid2] } }, + }), + ).toEqual([]); }); }); diff --git a/test/docs-snippets/pages/chat/03-channels/07-channel_management/14-freezing.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/14-freezing.test.ts index 7f706894bc..d435a52e01 100644 --- a/test/docs-snippets/pages/chat/03-channels/07-channel_management/14-freezing.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/14-freezing.test.ts @@ -25,7 +25,9 @@ describe('_default/03-channels/07-channel_management/14-freezing.md', () => { client = await getClientSideClient({ id: userId }); const id = uniqueId('general'); cleanup.channels.push(`messaging:${id}`); - channel = client.channel('messaging', id, { members: [userId, otherId] }); + channel = client.channel('messaging', id, { + members: [{ user_id: userId }, { user_id: otherId }], + }); await channel.create(); await serverClient.chat.channel('messaging', id).update({ add_moderators: [userId] }); }); @@ -42,36 +44,36 @@ describe('_default/03-channels/07-channel_management/14-freezing.md', () => { }); // #region snippet docs="chat/_default/03-channels/07-channel_management/14-freezing.md" heading="Freeze a Channel" tab="JavaScript" index=1 - const update = await channel.update( - { frozen: true }, - { text: 'Thierry has frozen the channel' }, - ); + const update = await channel.update({ + data: { frozen: true }, + message: { text: 'Thierry has frozen the channel' }, + }); const partialUpdate = await channel.updatePartial({ set: { frozen: true } }); // #endregion snippet - expect(update.channel.frozen).toBe(true); + expect(update.channel?.frozen).toBe(true); expect(update.message?.text).toBe('Thierry has frozen the channel'); - expect(partialUpdate.channel.frozen).toBe(true); + expect(partialUpdate.channel?.frozen).toBe(true); // sending a message returns an error message, reacting fails with 403 - const sent = await channel.sendMessage({ text: 'while frozen' }); + const sent = await channel.sendMessage({ message: { text: 'while frozen' } }); expect(sent.message.type).toBe('error'); - await expect(channel.sendReaction(otherMessage.id, { type: 'like' })).rejects.toThrow( - /frozen/, - ); + await expect( + channel.sendReaction({ id: otherMessage.id }, { reaction: { type: 'like' } }), + ).rejects.toThrow(/frozen/); }); it('unfreezes a channel', async () => { // #region snippet docs="chat/_default/03-channels/07-channel_management/14-freezing.md" heading="Unfreeze a Channel" tab="JavaScript" index=1 - const update = await channel.update( - { frozen: false }, - { text: 'Thierry has unfrozen the channel' }, - ); + const update = await channel.update({ + data: { frozen: false }, + message: { text: 'Thierry has unfrozen the channel' }, + }); // #endregion snippet - expect(update.channel.frozen).toBe(false); - const sent = await channel.sendMessage({ text: 'after unfreezing' }); + expect(update.channel?.frozen).toBe(false); + const sent = await channel.sendMessage({ message: { text: 'after unfreezing' } }); expect(sent.message.type).toBe('regular'); }); }); diff --git a/test/docs-snippets/pages/chat/03-channels/07-channel_management/15-truncating.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/15-truncating.test.ts index 0551a38d6a..b388eb1dee 100644 --- a/test/docs-snippets/pages/chat/03-channels/07-channel_management/15-truncating.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/15-truncating.test.ts @@ -22,7 +22,7 @@ describe('_default/03-channels/07-channel_management/15-truncating.md', () => { const id = uniqueId('general'); cleanup.channels.push(`messaging:${id}`); const channel = client.channel('messaging', id, { - members: [userId, janeId, johnId], + members: [{ user_id: userId }, { user_id: janeId }, { user_id: johnId }], }); await channel.create(); return channel; @@ -30,12 +30,13 @@ describe('_default/03-channels/07-channel_management/15-truncating.md', () => { // The messages `viewer` gets back when it queries `channel`. const messageTexts = async (viewer: StreamChat, channel: Channel) => { - const [result] = await viewer.queryChannels( - { cid: channel.cid }, - {}, - { message_limit: 10, watch: false, state: true }, - ); - return result.state.messages.map((message) => message.text); + const { channels } = await viewer.queryChannels({ + filter_conditions: { cid: channel.cid }, + message_limit: 10, + watch: false, + state: true, + }); + return channels[0]?.messages.map((message) => message.text); }; beforeAll(async () => { @@ -52,7 +53,7 @@ describe('_default/03-channels/07-channel_management/15-truncating.md', () => { it('truncates a channel', async () => { const channel = await createChannel(); - await channel.sendMessage({ text: 'first' }); + await channel.sendMessage({ message: { text: 'first' } }); // #region snippet docs="chat/_default/03-channels/07-channel_management/15-truncating.md" heading="Truncate a Channel" tab="JavaScript" index=1 await channel.truncate(); @@ -68,7 +69,7 @@ describe('_default/03-channels/07-channel_management/15-truncating.md', () => { // #endregion snippet const { channel: data } = await channel.query({ messages: { limit: 10 } }); - expect(data.truncated_at).toBeDefined(); + expect(data?.truncated_at).toBeDefined(); expect(await messageTexts(client, channel)).toEqual([ 'Dear Everyone. The channel has been truncated.', ]); @@ -76,7 +77,7 @@ describe('_default/03-channels/07-channel_management/15-truncating.md', () => { it('truncates a channel for specific members', async () => { const channel = await createChannel(); - await channel.sendMessage({ text: 'history' }); + await channel.sendMessage({ message: { text: 'history' } }); // #region snippet docs="chat/_default/03-channels/07-channel_management/15-truncating.md" heading="Truncate for Specific Members" tab="JavaScript" index=1 // COPY: janeId="jane", johnId="john" diff --git a/test/docs-snippets/pages/chat/03-channels/07-channel_management/16-channel_invites.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/16-channel_invites.test.ts index 229876e0e5..43215c402c 100644 --- a/test/docs-snippets/pages/chat/03-channels/07-channel_management/16-channel_invites.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/16-channel_invites.test.ts @@ -56,7 +56,9 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () it('invites users', async () => { const id = uniqueId('general'); cleanup.channels.push(`messaging:${id}`); - const channel = ownerClient.channel('messaging', id, { members: [ownerId] }); + const channel = ownerClient.channel('messaging', id, { + members: [{ user_id: ownerId }], + }); await channel.create(); // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Invite Users" tab="JavaScript" index=1 @@ -107,8 +109,8 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () it('queries accepted invites', async () => { // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Accepted Invites" tab="JavaScript" index=1 - const invites = await client.queryChannels({ - invite: 'accepted', + const invites = await client.queryChannelsAndHydrate({ + filter_conditions: { invite: 'accepted' }, }); // #endregion snippet @@ -120,8 +122,8 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () it('queries rejected invites', async () => { // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Rejected Invites" tab="JavaScript" index=1 - const rejected = await client.queryChannels({ - invite: 'rejected', + const rejected = await client.queryChannelsAndHydrate({ + filter_conditions: { invite: 'rejected' }, }); // #endregion snippet @@ -133,8 +135,8 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () it('queries pending invites', async () => { // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Query Pending Invites" tab="JavaScript" index=1 - const pending = await client.queryChannels({ - invite: 'pending', + const pending = await client.queryChannelsAndHydrate({ + filter_conditions: { invite: 'pending' }, }); // #endregion snippet diff --git a/test/docs-snippets/pages/chat/04-messages/01-send_message.test.ts b/test/docs-snippets/pages/chat/04-messages/01-send_message.test.ts index 800f9995f6..3d3bde1c77 100644 --- a/test/docs-snippets/pages/chat/04-messages/01-send_message.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/01-send_message.test.ts @@ -40,7 +40,9 @@ describe('_default/04-messages/01-send_message.md', () => { await waitForChannelTypePropagation(); client = await getClientSideClient({ id: userId }); - channel = client.channel(channelType, channelId, { members: [userId, joshId] }); + channel = client.channel(channelType, channelId, { + members: [{ user_id: userId }, { user_id: joshId }], + }); cleanup.channels.push(`${channelType}:${channelId}`); await channel.watch(); }); @@ -53,7 +55,7 @@ describe('_default/04-messages/01-send_message.md', () => { it('sends a message', async () => { // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Sending a Message" tab="JavaScript" index=1 const message = await channel.sendMessage({ - text: 'Hello, world!', + message: { text: 'Hello, world!' }, }); // #endregion snippet @@ -74,26 +76,28 @@ describe('_default/04-messages/01-send_message.md', () => { // COPY: designTeamId="design-team-id" // Send a message mentioning all online users const message = await channel.sendMessage({ - text: 'Hey everyone!', - mentioned_here: true, + message: { text: 'Hey everyone!', mentioned_here: true }, }); // Send a message mentioning all channel members const allMessage = await channel.sendMessage({ - text: 'Important announcement!', - mentioned_channel: true, + message: { text: 'Important announcement!', mentioned_channel: true }, }); // Send a message mentioning specific roles const roleMessage = await channel.sendMessage({ - text: 'Attention moderators!', - mentioned_roles: ['channel_moderator', 'admin'], + message: { + text: 'Attention moderators!', + mentioned_roles: ['channel_moderator', 'admin'], + }, }); // Send a message mentioning a user group const groupMessage = await channel.sendMessage({ - text: 'Design team, please review!', - mentioned_group_ids: [designTeamId], + message: { + text: 'Design team, please review!', + mentioned_group_ids: [designTeamId], + }, }); // #endregion snippet @@ -108,37 +112,38 @@ describe('_default/04-messages/01-send_message.md', () => { const josh = { id: joshId }; // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Sending Messages with Attachments" tab="JavaScript" index=1 - const message = await channel.sendMessage( - { + // Custom fields go under `custom`, on the message and on each attachment. + const message = await channel.sendMessage({ + message: { text: '@Josh Check out this image!', attachments: [ { type: 'image', asset_url: 'https://bit.ly/2K74TaG', thumb_url: 'https://bit.ly/2Uumxti', - myCustomField: 123, + custom: { myCustomField: 123 }, }, ], mentioned_users: [josh.id], - priority: 'high', + custom: { priority: 'high' }, }, - { skip_push: true }, - ); + skip_push: true, + }); // #endregion snippet expect(message.message.attachments?.[0]).toMatchObject({ type: 'image', asset_url: 'https://bit.ly/2K74TaG', - myCustomField: 123, + custom: { myCustomField: 123 }, }); expect(message.message.mentioned_users?.map((u) => u.id)).toEqual([joshId]); - expect(message.message.priority).toBe('high'); + expect(message.message.custom.priority).toBe('high'); }); it('enriches URLs', async () => { // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="URL Enrichment" tab="JavaScript" index=1 const response = await channel.sendMessage({ - text: 'Check this out https://imgur.com/r/bears/4zmGbMN', + message: { text: 'Check this out https://imgur.com/r/bears/4zmGbMN' }, }); // #endregion snippet @@ -152,7 +157,7 @@ describe('_default/04-messages/01-send_message.md', () => { ).id; // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Retrieving a Message" tab="JavaScript" index=1 - const message = await client.getMessage(messageID); + const message = await client.getMessage({ id: messageID }); // #endregion snippet expect(message.message.id).toBe(messageID); @@ -168,8 +173,10 @@ describe('_default/04-messages/01-send_message.md', () => { ).id; // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Updating a Message" tab="JavaScript" index=1 - const message = { id: messageId, text: 'Updated message text' }; - const updated = await client.updateMessage(message); + const updated = await client.updateMessage( + { id: messageId }, + { message: { text: 'Updated message text' } }, + ); // #endregion snippet expect(updated.message.text).toBe('Updated message text'); @@ -177,32 +184,33 @@ describe('_default/04-messages/01-send_message.md', () => { it('partially updates a message', async () => { const { message: originalMessage } = await channel.sendMessage({ - text: 'original', - color: 'red', - details: { status: 'pending' }, + message: { + text: 'original', + custom: { color: 'red', details: { status: 'pending' } }, + }, }); // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Partial Update" tab="JavaScript" index=1 // Update text - await client.partialUpdateMessage(originalMessage.id, { - set: { text: 'Updated text' }, - }); + await client.updateMessagePartial( + { id: originalMessage.id }, + { set: { text: 'Updated text' } }, + ); // Remove a custom field - await client.partialUpdateMessage(originalMessage.id, { - unset: ['color'], - }); + await client.updateMessagePartial({ id: originalMessage.id }, { unset: ['color'] }); // Update nested properties - await client.partialUpdateMessage(originalMessage.id, { - set: { 'details.status': 'complete' }, - }); + await client.updateMessagePartial( + { id: originalMessage.id }, + { set: { 'details.status': 'complete' } }, + ); // #endregion snippet - const { message } = await client.getMessage(originalMessage.id); + const { message } = await client.getMessage({ id: originalMessage.id }); expect(message.text).toBe('Updated text'); - expect(message.color).toBeUndefined(); - expect(message.details).toEqual({ status: 'complete' }); + expect(message.custom.color).toBeUndefined(); + expect(message.custom.details).toEqual({ status: 'complete' }); }); it('deletes a message', async () => { @@ -218,13 +226,13 @@ describe('_default/04-messages/01-send_message.md', () => { // #region snippet docs="chat/_default/04-messages/01-send_message.md" heading="Deleting a Message" tab="JavaScript" index=1 // Soft delete - await client.deleteMessage(messageID); + await client.deleteMessage({ id: messageID }); // Hard delete - await client.deleteMessage(messageID, { hardDelete: true }); + await client.deleteMessage({ id: messageID }, { hard: true }); // Delete for me - await client.deleteMessage(anotherMessageID, { deleteForMe: true }); + await client.deleteMessage({ id: anotherMessageID }, { delete_for_me: true }); // #endregion snippet await expect(serverClient.chat.getMessage({ id: messageID })).rejects.toMatchObject({ @@ -235,7 +243,7 @@ describe('_default/04-messages/01-send_message.md', () => { id: anotherMessageID, }); expect(forOthers.type).not.toBe('deleted'); - const { message: forMe } = await client.getMessage(anotherMessageID); + const { message: forMe } = await client.getMessage({ id: anotherMessageID }); expect(forMe.deleted_for_me).toBe(true); }); }); diff --git a/test/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts b/test/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts index dcd62c1059..7da0dcde98 100644 --- a/test/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts @@ -1,17 +1,8 @@ -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { Channel, FileLike, FileReference, StreamChat } from '../../../../../src'; import { - createUserToken, disconnectClients, + getClientSideClient, getServerClient, } from '../../../helpers/clients'; import { Cleanup } from '../../../helpers/cleanup'; @@ -20,27 +11,8 @@ import { uniqueId } from '../../../helpers/ids'; import { getServerUser } from '../../../helpers/server'; import { waitForChannelTypePropagation } from '../../../helpers/wait'; -// The snippets upload browser `File`s. Under Node the SDK builds the multipart body with the -// `form-data` package (which only takes streams and Buffers) and only detects `File`s when -// `window` exists. Emulate the browser bundle: native `FormData` (what `form-data` resolves to in -// browsers) and a `window` with `File`/`Blob`, stubbed only while each test runs. -vi.mock('form-data', () => ({ default: globalThis.FormData })); - -/** - * Like `getClientSideClient`, but from a fresh import of src: `setup.ts` already loaded src (and - * the real `form-data`) before the mock above was registered. - */ -const getBrowserLikeClientSideClient = async (userId: string): Promise => { - const serverClient = getServerClient(); - await serverClient.upsertUsers([{ id: userId }]); - vi.resetModules(); - const { StreamChat: FreshStreamChat } = await import('../../../../../src'); - const client = new FreshStreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, - }); - await client.connectUser({ id: userId }, createUserToken(userId)); - return client; -}; +// v10 builds multipart bodies with the global `FormData`, and Node's `File` is a `Blob`, so the +// snippets' browser `File`s upload as-is under Node. // 1x1 transparent PNG. const PNG_BYTES = Buffer.from( @@ -52,6 +24,12 @@ const makeImage = (name: string) => new File([PNG_BYTES], name, { type: 'image/p const makeTextFile = (name: string) => new File(['hello from the docs tests'], name, { type: 'text/plain' }); +/** The upload responses type `file` as optional; it is always set on success. */ +const uploadedUrl = ({ file }: { file?: string }) => { + if (!file) throw new Error('upload returned no file URL'); + return file; +}; + describe('_default/04-messages/02-file_uploads.md', () => { const serverClient = getServerClient(); const cleanup = new Cleanup(serverClient); @@ -67,20 +45,12 @@ describe('_default/04-messages/02-file_uploads.md', () => { await grantMessagingMembers(serverClient, cleanup, ['create-attachment']); await waitForChannelTypePropagation(); - client = await getBrowserLikeClientSideClient(userId); - channel = client.channel(channelType, channelId, { members: [userId] }); + client = await getClientSideClient({ id: userId }); + channel = client.channel(channelType, channelId, { members: [{ user_id: userId }] }); cleanup.channels.push(`${channelType}:${channelId}`); await channel.watch(); }); - beforeEach(() => { - vi.stubGlobal('window', { File, Blob }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - afterAll(async () => { await disconnectClients(client); await cleanup.run(); @@ -92,11 +62,8 @@ describe('_default/04-messages/02-file_uploads.md', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); // #region snippet docs="chat/_default/04-messages/02-file_uploads.md" heading="Uploading Files to a Channel" tab="JavaScript" index=1 - const response1 = await channel.sendImage( - file1, - file1.name, - file1.type, - undefined, + const response1 = await channel.uploadImage( + { file: file1 }, // Optional { onUploadProgress: (e) => { @@ -106,11 +73,8 @@ describe('_default/04-messages/02-file_uploads.md', () => { }, ); - const response2 = await channel.sendFile( - file2, - file2.name, - file2.type, - undefined, + const response2 = await channel.uploadFile( + { file: file2 }, // Optional { onUploadProgress: (e) => { @@ -121,19 +85,23 @@ describe('_default/04-messages/02-file_uploads.md', () => { ); await channel.sendMessage({ - text: 'Check these out', - attachments: [ - { - type: 'image', - asset_url: response1.file, - thumb_url: response1.file, - }, - { - type: 'file', - asset_url: response2.file, - title: file2.name, - }, - ], + message: { + text: 'Check these out', + attachments: [ + { + type: 'image', + asset_url: response1.file, + thumb_url: response1.file, + custom: {}, + }, + { + type: 'file', + asset_url: response2.file, + title: file2.name, + custom: {}, + }, + ], + }, }); // #endregion snippet @@ -147,8 +115,8 @@ describe('_default/04-messages/02-file_uploads.md', () => { const { messages } = await channel.query({ messages: { limit: 1 } }); const [message] = messages; expect(message.text).toBe('Check these out'); - expect(message.attachments?.map((a) => a.type)).toEqual(['image', 'file']); - expect(message.attachments?.[1].title).toBe('notes.txt'); + expect(message.attachments.map((a) => a.type)).toEqual(['image', 'file']); + expect(message.attachments[1].title).toBe('notes.txt'); }); it('uploads a standalone image and uses it as the user avatar', async () => { @@ -157,10 +125,7 @@ describe('_default/04-messages/02-file_uploads.md', () => { // #region snippet docs="chat/_default/04-messages/02-file_uploads.md" heading="Uploading Standalone Files" tab="JavaScript" index=1 const { file: imageUrl } = await client.uploadImage( - imageFile, - imageFile.name, - imageFile.type, - undefined, + { file: imageFile }, // Optional { onUploadProgress: (e) => { @@ -171,28 +136,32 @@ describe('_default/04-messages/02-file_uploads.md', () => { ); // Example: use the image URL as a user avatar - await client.partialUpdateUser({ - id: userId, - set: { image: imageUrl }, + await client.updateUsersPartial({ + users: [{ id: userId, set: { image: imageUrl } }], }); // #endregion snippet // Standalone uploads aren't tied to the channel or removed with the user. - await client.deleteImage(imageUrl); + const uploadedImageUrl = uploadedUrl({ file: imageUrl }); + await client.deleteImage({ url: uploadedImageUrl }); const logs = logSpy.mock.calls.map(([line]) => String(line)); logSpy.mockRestore(); - expect(imageUrl).toMatch(/^https:\/\//); + expect(uploadedImageUrl).toMatch(/^https:\/\//); expect(logs.some((line) => /^Image: \d+%$/.test(line))).toBe(true); const { image } = await getServerUser(serverClient, userId); expect(typeof image).toBe('string'); // The stored URL is re-signed on read, so compare without the query string. - expect(String(image).split('?')[0]).toBe(imageUrl.split('?')[0]); + expect(String(image).split('?')[0]).toBe(uploadedImageUrl.split('?')[0]); }); it('deletes files from a channel', async () => { - const { file: fileURL } = await channel.sendFile(makeTextFile('delete-me.txt')); - const { file: imageURL } = await channel.sendImage(makeImage('delete-me.png')); + const fileURL = uploadedUrl( + await channel.uploadFile({ file: makeTextFile('delete-me.txt') }), + ); + const imageURL = uploadedUrl( + await channel.uploadImage({ file: makeImage('delete-me.png') }), + ); const deleteFileSpy = vi.spyOn(channel, 'deleteFile'); const deleteImageSpy = vi.spyOn(channel, 'deleteImage'); @@ -224,19 +193,37 @@ describe('_default/04-messages/02-file_uploads.md', () => { return Promise.resolve({ url: 'https://cdn.example.com/uploads/photo.png' }); }, }; + // A composer receives configuration changes once its subscriptions are registered (UI SDKs + // do this when they mount it); composers created later get it on construction. const messageComposer = channel.messageComposer; - - // #region snippet docs="chat/_default/04-messages/02-file_uploads.md" heading="Using Your Own CDN" tab="JavaScript" index=1 - messageComposer.attachmentManager.setCustomUploadFn(async (file) => { - const result = await customCDN.upload(file); - return { file: result.url }; - }); - // #endregion snippet - - expect(messageComposer.attachmentManager.hasCustomDoUploadRequest).toBe(true); - const file = makeImage('photo.png'); - const result = await messageComposer.attachmentManager.doUploadRequest(file); - expect(result).toEqual({ file: 'https://cdn.example.com/uploads/photo.png' }); - expect(uploaded).toEqual([file]); + messageComposer.registerSubscriptions(); + + try { + // #region snippet docs="chat/_default/04-messages/02-file_uploads.md" heading="Using Your Own CDN" tab="JavaScript" index=1 + client.config.set({ + messageComposer: { + attachments: { + // Uploads go to storage Stream doesn't host + customCdn: true, + doUploadRequest: async (file) => { + const result = await customCDN.upload(file); + return { file: result.url }; + }, + }, + }, + }); + // #endregion snippet + + const { attachmentManager } = messageComposer; + expect(attachmentManager.hasCustomDoUploadRequest).toBe(true); + expect(attachmentManager.config.customCdn).toBe(true); + const file = makeImage('photo.png'); + const result = await attachmentManager.doUploadRequest(file); + expect(result).toEqual({ file: 'https://cdn.example.com/uploads/photo.png' }); + expect(uploaded).toEqual([file]); + } finally { + client.config.reset('messageComposer'); + messageComposer.unregisterSubscriptions(); + } }); }); diff --git a/test/docs-snippets/pages/chat/04-messages/03-threads.test.ts b/test/docs-snippets/pages/chat/04-messages/03-threads.test.ts index b1a9e6b5e0..7f1d548682 100644 --- a/test/docs-snippets/pages/chat/04-messages/03-threads.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/03-threads.test.ts @@ -34,7 +34,7 @@ describe('_default/04-messages/03-threads.md', () => { const ownThreadIds: string[] = []; const threadUnreadCount = async (id: string) => { - const { threads } = await client.getUnreadCount(); + const { threads } = await client.unreadCounts(); return threads.find((thread) => thread.parent_message_id === id)?.unread_count ?? 0; }; @@ -42,11 +42,15 @@ describe('_default/04-messages/03-threads.md', () => { cleanup.users.push(userId, otherId); await serverClient.upsertUsers([{ id: otherId }]); client = await getClientSideClient({ id: userId }); - channel = client.channel('messaging', channelId, { members: [userId, otherId] }); + channel = client.channel('messaging', channelId, { + members: [{ user_id: userId }, { user_id: otherId }], + }); cleanup.channels.push(`messaging:${channelId}`); await channel.watch(); - const { message } = await channel.sendMessage({ text: 'Parent message' }); + const { message } = await channel.sendMessage({ + message: { text: 'Parent message' }, + }); parentMessageId = message.id; threadIds.push(parentMessageId); @@ -84,9 +88,11 @@ describe('_default/04-messages/03-threads.md', () => { it('starts a thread', async () => { // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Starting a Thread" tab="JavaScript" index=1 const reply = await channel.sendMessage({ - text: 'This is a reply in a thread', - parent_id: parentMessageId, - show_in_channel: false, + message: { + text: 'This is a reply in a thread', + parent_id: parentMessageId, + show_in_channel: false, + }, }); // #endregion snippet @@ -100,18 +106,22 @@ describe('_default/04-messages/03-threads.md', () => { // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Paginating Thread Replies" tab="JavaScript" index=1 // COPY: olderReplyId="42" // Get the latest 20 replies - const replies = await channel.getReplies(parentMessageId, { limit: 20 }); + const replies = await client.getReplies( + { parent_id: parentMessageId }, + { limit: 20 }, + ); // Get older replies (before message with ID "42") - const olderReplies = await channel.getReplies(parentMessageId, { - limit: 20, - id_lte: olderReplyId, - }); + const olderReplies = await client.getReplies( + { parent_id: parentMessageId }, + { limit: 20, id_lte: olderReplyId }, + ); // Get oldest replies first - const oldestFirst = await channel.getReplies(parentMessageId, { limit: 20 }, [ - { created_at: 1 }, - ]); + const oldestFirst = await client.getReplies( + { parent_id: parentMessageId }, + { limit: 20, sort: [{ field: 'created_at', direction: 1 }] }, + ); // #endregion snippet const replyMessageIds = replies.messages.map((m) => m.id); @@ -125,8 +135,10 @@ describe('_default/04-messages/03-threads.md', () => { // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Inline Replies" tab="JavaScript" index=1 const message = await channel.sendMessage({ - text: 'I agree with this point', - quoted_message_id: originalMessageId, + message: { + text: 'I agree with this point', + quoted_message_id: originalMessageId, + }, }); // #endregion snippet @@ -136,12 +148,12 @@ describe('_default/04-messages/03-threads.md', () => { it('queries threads', async () => { // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Querying Threads" tab="JavaScript" index=1 - const { threads } = await client.queryThreads(); + const { threads } = await client.queryThreadsAndHydrate(); for (const thread of threads) { const state = thread.state.getLatestValue(); console.log(state.parentMessage.text); - console.log(state.replies); + console.log(thread.messagePaginator.items); // the latest replies console.log(state.participants); console.log(state.read); } @@ -157,22 +169,22 @@ describe('_default/04-messages/03-threads.md', () => { // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Supported Sort Fields" tab="JavaScript" index=1 // COPY: createdByUserId="user-1" // Get threads created by a specific user, sorted by creation date - const { threads, next } = await client.queryThreads({ + const { threads, next } = await client.queryThreadsAndHydrate({ filter: { created_by_user_id: { $eq: createdByUserId }, updated_at: { $gte: '2024-01-01T00:00:00Z' }, }, - sort: [{ created_at: -1 }], + sort: [{ field: 'created_at', direction: -1 }], limit: 10, }); // Get next page - const { threads: page2 } = await client.queryThreads({ + const { threads: page2 } = await client.queryThreadsAndHydrate({ filter: { created_by_user_id: { $eq: createdByUserId }, updated_at: { $gte: '2024-01-01T00:00:00Z' }, }, - sort: [{ created_at: -1 }], + sort: [{ field: 'created_at', direction: -1 }], limit: 10, next, }); @@ -187,7 +199,7 @@ describe('_default/04-messages/03-threads.md', () => { it('gets a thread by id', async () => { // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Getting a Thread by ID" tab="JavaScript" index=1 - const thread = await client.getThread(parentMessageId, { + const thread = await client.getThreadAndHydrate(parentMessageId, { watch: true, reply_limit: 10, participant_limit: 25, @@ -195,7 +207,8 @@ describe('_default/04-messages/03-threads.md', () => { // #endregion snippet expect(thread.id).toBe(parentMessageId); - const { replies, participants } = thread.state.getLatestValue(); + const { participants } = thread.state.getLatestValue(); + const replies = thread.messagePaginator.items ?? []; expect(replies.length).toBeGreaterThan(0); expect(replies.length).toBeLessThanOrEqual(10); expect(participants?.map((p) => p.user_id)).toEqual( @@ -208,24 +221,23 @@ describe('_default/04-messages/03-threads.md', () => { // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Updating Thread Title and Custom Data" tab="JavaScript" index=1 // Set properties - const { thread } = await client.partialUpdateThread(threadId, { - set: { - title: 'Project Discussion', - priority: 'high', + const { thread } = await client.updateThreadPartial( + { message_id: threadId }, + { + set: { + title: 'Project Discussion', + priority: 'high', + }, }, - }); + ); // Remove properties - await client.partialUpdateThread(threadId, { - unset: ['priority'], - }); + await client.updateThreadPartial({ message_id: threadId }, { unset: ['priority'] }); // #endregion snippet expect(thread.title).toBe('Project Discussion'); - expect(thread).toMatchObject({ priority: 'high' }); - const updated = ( - await client.getThread(threadId, { watch: false }) - ).state.getLatestValue(); + expect(thread.custom).toMatchObject({ priority: 'high' }); + const { thread: updated } = await client.getThread({ message_id: threadId }); expect(updated.title).toBe('Project Discussion'); expect(updated.custom).not.toHaveProperty('priority'); }); @@ -240,7 +252,7 @@ describe('_default/04-messages/03-threads.md', () => { const readerId = otherId; const token = createUserToken(readerId); const client = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); try { @@ -303,7 +315,7 @@ describe('_default/04-messages/03-threads.md', () => { it('gets the unread count per thread', async () => { // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Unread Count Per Thread" tab="JavaScript" index=1 - const response = await client.getUnreadCount(); + const response = await client.unreadCounts(); console.log(response.total_unread_threads_count); @@ -325,34 +337,43 @@ describe('_default/04-messages/03-threads.md', () => { // Subscribe to state updates const unsubscribe = threadManager.state.subscribe((state) => { - console.log(state.threads); console.log(state.unreadThreadCount); }); + // Subscribe to the thread list + const unsubscribeList = threadManager.paginator.state.subscribe((state) => { + console.log(state.items); + }); + // Load threads await threadManager.reload(); // Load more threads - await threadManager.loadNextPage(); + await threadManager.paginator.toTail(); // Access current state - const { threads } = threadManager.state.getLatestValue(); + const { items: threads = [] } = threadManager.paginator.state.getLatestValue(); // #endregion snippet unsubscribe(); + unsubscribeList(); + expect(threadManager.paginator.lastQueryError).toBeUndefined(); expect(threads.map((thread) => thread.id).sort()).toEqual([...threadIds].sort()); }); it('handles events for a single thread', async () => { // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Event Handling" tab="JavaScript" index=1 - const { threads } = await client.queryThreads({ watch: true, limit: 10 }); + const { threads } = await client.queryThreadsAndHydrate({ watch: true, limit: 10 }); const [thread] = threads; - // Register event handlers for a single thread - thread.registerSubscriptions(); + // Threads receive events through the client's thread manager + client.threads.registerSubscriptions(); - const unsubscribe = thread.state.subscribe((state) => { - console.log(state.replies); + // Open the thread: the thread manager keeps it up to date from now on + const release = thread.activate(); + + const unsubscribe = thread.messagePaginator.state.subscribe((state) => { + console.log(state.items); }); // #endregion snippet @@ -363,14 +384,15 @@ describe('_default/04-messages/03-threads.md', () => { parent_id: thread.id, }); await retry(() => { - expect(thread.state.getLatestValue().replies.map((r) => r.id)).toContain( + expect((thread.messagePaginator.items ?? []).map((r) => r.id)).toContain( reply.id, ); return Promise.resolve(); }); } finally { unsubscribe(); - thread.unregisterSubscriptions(); + release(); + client.threads.unregisterSubscriptions(); } }); @@ -385,19 +407,19 @@ describe('_default/04-messages/03-threads.md', () => { await threadManager.reload(); - // All threads are now listening to channel events - const { threads } = threadManager.state.getLatestValue(); + // All loaded threads now receive their events + const { items: threads = [] } = threadManager.paginator.state.getLatestValue(); // #endregion snippet expect(threads.map((thread) => thread.id)).toContain(parentMessageId); - const thread = threadManager.threadsById[parentMessageId]; + const thread = threadManager.get(parentMessageId); const reply = await sendServerMessage(serverClient, cid, { text: 'Managed reply', user_id: otherId, parent_id: parentMessageId, }); await retry(() => { - expect(thread?.state.getLatestValue().replies.map((r) => r.id)).toContain( + expect((thread?.messagePaginator.items ?? []).map((r) => r.id)).toContain( reply.id, ); return Promise.resolve(); diff --git a/test/docs-snippets/pages/chat/04-messages/04-send_reaction.test.ts b/test/docs-snippets/pages/chat/04-messages/04-send_reaction.test.ts index d8a75ca72a..c72b635cbc 100644 --- a/test/docs-snippets/pages/chat/04-messages/04-send_reaction.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/04-send_reaction.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, QueryReactionsAPIResponse, StreamChat } from '../../../../../src'; +import type { Channel, QueryReactionsResponse, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, @@ -18,7 +18,7 @@ describe('_default/04-messages/04-send_reaction.md', () => { let channel: Channel; const newMessageId = async (text: string) => { - const { message } = await channel.sendMessage({ text }); + const { message } = await channel.sendMessage({ message: { text } }); return message.id; }; @@ -43,7 +43,9 @@ describe('_default/04-messages/04-send_reaction.md', () => { cleanup.users.push(userId, otherId); await serverClient.upsertUsers([{ id: otherId }]); client = await getClientSideClient({ id: userId }); - channel = client.channel('messaging', channelId, { members: [userId, otherId] }); + channel = client.channel('messaging', channelId, { + members: [{ user_id: userId }, { user_id: otherId }], + }); cleanup.channels.push(`messaging:${channelId}`); await channel.watch(); }); @@ -56,31 +58,31 @@ describe('_default/04-messages/04-send_reaction.md', () => { it('sends a reaction', async () => { const messageID = await newMessageId('React to me'); // A reaction of another type, which `enforce_unique` should replace. - await channel.sendReaction(messageID, { type: 'fire' }); + await channel.sendReaction({ id: messageID }, { reaction: { type: 'fire' } }); // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Sending a Reaction" tab="JavaScript" index=1 // Add a reaction - const reaction = await channel.sendReaction(messageID, { - type: 'love', - }); + const reaction = await channel.sendReaction( + { id: messageID }, + { reaction: { type: 'love' } }, + ); // Add a reaction with custom data - const customReaction = await channel.sendReaction(messageID, { - type: 'love', - customField: 'value', - }); + const customReaction = await channel.sendReaction( + { id: messageID }, + { reaction: { type: 'love', custom: { customField: 'value' } } }, + ); // Replace all existing reactions from this user with the new one const uniqueReaction = await channel.sendReaction( - messageID, - { type: 'love' }, - { enforce_unique: true }, + { id: messageID }, + { reaction: { type: 'love' }, enforce_unique: true }, ); // #endregion snippet expect(reaction.reaction.type).toBe('love'); expect(reaction.reaction.user_id).toBe(userId); - expect(customReaction.reaction.customField).toBe('value'); + expect(customReaction.reaction.custom.customField).toBe('value'); expect(uniqueReaction.reaction.type).toBe('love'); const message = await getServerMessage(messageID); @@ -89,11 +91,11 @@ describe('_default/04-messages/04-send_reaction.md', () => { it('removes a reaction', async () => { const messageID = await newMessageId('Remove my reaction'); - await channel.sendReaction(messageID, { type: 'love' }); + await channel.sendReaction({ id: messageID }, { reaction: { type: 'love' } }); expect((await getServerMessage(messageID)).reaction_counts).toEqual({ love: 1 }); // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Removing a Reaction" tab="JavaScript" index=1 - await channel.deleteReaction(messageID, 'love'); + await channel.deleteReaction({ id: messageID, type: 'love' }); // #endregion snippet const message = await getServerMessage(messageID); @@ -108,13 +110,13 @@ describe('_default/04-messages/04-send_reaction.md', () => { // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Paginating Reactions" tab="JavaScript" index=1 // Get the first 10 reactions - const response = await channel.getReactions(messageID, { limit: 10 }); + const response = await client.getReactions({ id: messageID }, { limit: 10 }); // Get reactions 11-13 - const nextResponse = await channel.getReactions(messageID, { - limit: 3, - offset: 10, - }); + const nextResponse = await client.getReactions( + { id: messageID }, + { limit: 3, offset: 10 }, + ); // #endregion snippet expect(response.reactions).toHaveLength(10); @@ -127,7 +129,7 @@ describe('_default/04-messages/04-send_reaction.md', () => { it('queries reactions', async () => { const messageId = await newMessageId('Query my reactions'); - await channel.sendReaction(messageId, { type: 'like' }); + await channel.sendReaction({ id: messageId }, { reaction: { type: 'like' } }); // Enough reactions from another user for `firstPage` to have a `next` cursor. const otherTypes = Array.from({ length: 30 }, (_, i) => `t${i + 1}`); await sendServerReactions(messageId, otherTypes, otherId); @@ -136,22 +138,20 @@ describe('_default/04-messages/04-send_reaction.md', () => { // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Querying Reactions" tab="JavaScript" index=1 // Query reactions by type - await client.queryReactions(message.id, { type: 'like' }); + await client.queryReactions({ id: message.id }, { filter: { type: 'like' } }); // Query reactions by user - await client.queryReactions(message.id, { user_id: userId }); + await client.queryReactions({ id: message.id }, { filter: { user_id: userId } }); // Paginate results - const firstPage = await client.queryReactions(message.id, {}); + const firstPage = await client.queryReactions({ id: message.id }); const secondPage = await client.queryReactions( - message.id, - {}, - {}, + { id: message.id }, { limit: 5, next: firstPage.next }, ); // #endregion snippet - const [byType, byUser]: QueryReactionsAPIResponse[] = await Promise.all( + const [byType, byUser]: QueryReactionsResponse[] = await Promise.all( spy.mock.results.slice(0, 2).map((r) => r.value), ); spy.mockRestore(); @@ -169,16 +169,16 @@ describe('_default/04-messages/04-send_reaction.md', () => { // #region snippet docs="chat/_default/04-messages/04-send_reaction.md" heading="Cumulative Reactions" tab="JavaScript" index=1 // User claps 5 times - await channel.sendReaction(messageID, { - type: 'clap', - score: 5, - }); + await channel.sendReaction( + { id: messageID }, + { reaction: { type: 'clap', score: 5 } }, + ); // Same user claps 20 more times (total becomes 25) - await channel.sendReaction(messageID, { - type: 'clap', - score: 25, - }); + await channel.sendReaction( + { id: messageID }, + { reaction: { type: 'clap', score: 25 } }, + ); // #endregion snippet const message = await getServerMessage(messageID); diff --git a/test/docs-snippets/pages/chat/04-messages/05-pinned_messages.test.ts b/test/docs-snippets/pages/chat/04-messages/05-pinned_messages.test.ts index b4b957fc28..b9e93d4f7e 100644 --- a/test/docs-snippets/pages/chat/04-messages/05-pinned_messages.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/05-pinned_messages.test.ts @@ -1,5 +1,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, StreamChat, UpdateMessageAPIResponse } from '../../../../../src'; +import { nsToDate } from '../../../../../src'; +import type { + Channel, + StreamChat, + TimestampNS, + UpdateMessagePartialResponse, +} from '../../../../../src'; import { disconnectClients, getClientSideClient, @@ -8,6 +14,10 @@ import { import { Cleanup } from '../../../helpers/cleanup'; import { uniqueId } from '../../../helpers/ids'; +/** Server timestamps are unix nanoseconds; '' when unset. */ +const isoString = (timestamp: TimestampNS | undefined) => + timestamp === undefined ? '' : nsToDate(timestamp).toISOString(); + describe('_default/04-messages/05-pinned_messages.md', () => { const serverClient = getServerClient(); const cleanup = new Cleanup(serverClient); @@ -23,16 +33,20 @@ describe('_default/04-messages/05-pinned_messages.md', () => { beforeAll(async () => { cleanup.users.push(userId); client = await getClientSideClient({ id: userId }); - channel = client.channel('messaging', channelId, { members: [userId] }); + channel = client.channel('messaging', channelId, { members: [{ user_id: userId }] }); cleanup.channels.push(`messaging:${channelId}`); await channel.watch(); - pagingChannel = client.channel('messaging', pagingChannelId, { members: [userId] }); + pagingChannel = client.channel('messaging', pagingChannelId, { + members: [{ user_id: userId }], + }); cleanup.channels.push(`messaging:${pagingChannelId}`); await pagingChannel.create(); // 12 pinned messages, pinned one at a time so `pinned_at` follows this order. for (let i = 0; i < 12; i++) { - const { message } = await pagingChannel.sendMessage({ text: `Pinned ${i}` }); + const { message } = await pagingChannel.sendMessage({ + message: { text: `Pinned ${i}` }, + }); await client.pinMessage(message); pinnedIds.push(message.id); } @@ -51,9 +65,11 @@ describe('_default/04-messages/05-pinned_messages.md', () => { // #region snippet docs="chat/_default/04-messages/05-pinned_messages.md" heading="Pinning and Unpinning Messages" tab="JavaScript" index=1 // Create a pinned message const { message } = await channel.sendMessage({ - text: 'Important announcement', - pinned: true, - pin_expires: '2077-01-01T00:00:00Z', + message: { + text: 'Important announcement', + pinned: true, + pin_expires: new Date('2077-01-01T00:00:00Z'), + }, }); // Pin an existing message for 120 seconds @@ -70,37 +86,36 @@ describe('_default/04-messages/05-pinned_messages.md', () => { // #endregion snippet expect(message.pinned).toBe(true); - expect(message.pin_expires).toMatch(/^2077-01-01T00:00:00/); + expect(isoString(message.pin_expires)).toMatch(/^2077-01-01T00:00:00/); expect(message.pinned_by?.id).toBe(userId); - const [timed, dated, indefinite]: UpdateMessageAPIResponse[] = await Promise.all( + const [timed, dated, indefinite]: UpdateMessagePartialResponse[] = await Promise.all( pinSpy.mock.results.map((r) => r.value), ); pinSpy.mockRestore(); - expect(timed.message.pinned).toBe(true); - const timedExpires = new Date(timed.message.pin_expires ?? 0).getTime(); + expect(timed.message?.pinned).toBe(true); + const timedExpires = new Date(isoString(timed.message?.pin_expires)).getTime(); expect(timedExpires).toBeGreaterThanOrEqual(before + 110_000); expect(timedExpires).toBeLessThanOrEqual(Date.now() + 130_000); - expect(dated.message.pinned).toBe(true); - expect(dated.message.pin_expires).toMatch(/^2077-01-01T00:00:00/); - expect(indefinite.message.pinned).toBe(true); - expect(indefinite.message.pin_expires ?? null).toBeNull(); + expect(dated.message?.pinned).toBe(true); + expect(isoString(dated.message?.pin_expires)).toMatch(/^2077-01-01T00:00:00/); + expect(indefinite.message?.pinned).toBe(true); + expect(indefinite.message?.pin_expires ?? null).toBeNull(); - const [unpinned]: UpdateMessageAPIResponse[] = await Promise.all( + const [unpinned]: UpdateMessagePartialResponse[] = await Promise.all( unpinSpy.mock.results.map((r) => r.value), ); unpinSpy.mockRestore(); - expect(unpinned.message.pinned).toBe(false); + expect(unpinned.message?.pinned).toBe(false); const { message: stored } = await serverClient.chat.getMessage({ id: message.id }); expect(stored.pinned).toBe(false); }); it('retrieves pinned messages with the channel state', async () => { const { message: pinned } = await channel.sendMessage({ - text: 'Pinned for query', - pinned: true, + message: { text: 'Pinned for query', pinned: true }, }); - await channel.sendMessage({ text: 'Not pinned' }); + await channel.sendMessage({ message: { text: 'Not pinned' } }); // #region snippet docs="chat/_default/04-messages/05-pinned_messages.md" heading="Retrieving Pinned Messages" tab="JavaScript" index=1 const channelState = await client.channel('messaging', channelId).query(); @@ -117,14 +132,18 @@ describe('_default/04-messages/05-pinned_messages.md', () => { // #region snippet docs="chat/_default/04-messages/05-pinned_messages.md" heading="Paginating Pinned Messages" tab="JavaScript" index=1 // First page, newest first - const page1 = await channel.getPinnedMessages({ limit: 10 }, { pinned_at: -1 }); + const page1 = await channel.getPinnedMessages({ + limit: 10, + sort: [{ field: 'pinned_at', direction: -1 }], + }); // Next page const lastMsg = page1.messages[page1.messages.length - 1]; - const page2 = await channel.getPinnedMessages( - { limit: 10, id_lt: lastMsg.id }, - { pinned_at: -1 }, - ); + const page2 = await channel.getPinnedMessages({ + limit: 10, + id_lt: lastMsg.id, + sort: [{ field: 'pinned_at', direction: -1 }], + }); // Oldest first const ascPage = await channel.getPinnedMessages({ limit: 10 }); diff --git a/test/docs-snippets/pages/chat/04-messages/06-search.test.ts b/test/docs-snippets/pages/chat/04-messages/06-search.test.ts index cc50c882b9..24947118d1 100644 --- a/test/docs-snippets/pages/chat/04-messages/06-search.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/06-search.test.ts @@ -30,13 +30,19 @@ describe('_default/04-messages/06-search.md', () => { await serverClient.upsertUsers([{ id: otherUserId }]); client = await getClientSideClient({ id: userId }); - const channel = client.channel('messaging', searchChannelId, { members: [userId] }); + const channel = client.channel('messaging', searchChannelId, { + members: [{ user_id: userId }], + }); cleanup.channels.push(`messaging:${searchChannelId}`); await channel.create(); ids.text = ( - await channel.sendMessage({ text: 'supercalifragilisticexpialidocious' }) + await channel.sendMessage({ + message: { text: 'supercalifragilisticexpialidocious' }, + }) + ).message.id; + ids.plain = ( + await channel.sendMessage({ message: { text: 'superb, no attachment' } }) ).message.id; - ids.plain = (await channel.sendMessage({ text: 'superb, no attachment' })).message.id; // Sent server-side: client-side attachments need the `create-attachment` grant. ids.attachment = ( await sendServerMessage(serverClient, `messaging:${searchChannelId}`, { @@ -65,14 +71,13 @@ describe('_default/04-messages/06-search.md', () => { ).id; const pagingChannel = client.channel('messaging', pagingChannelId, { - members: [userId], + members: [{ user_id: userId }], }); cleanup.channels.push(myChannelCid); await pagingChannel.create(); for (let i = 0; i < PAGING_COUNT; i++) { const { message } = await pagingChannel.sendMessage({ - text: `supercali ${i}`, - my_custom_field: i, + message: { text: `supercali ${i}`, custom: { my_custom_field: i } }, }); pagingIds.push(message.id); } @@ -80,11 +85,18 @@ describe('_default/04-messages/06-search.md', () => { // Search indexing is eventually consistent: wait until every message is searchable. await retry(async () => { const [a, b] = await Promise.all([ - client.search( - { cid: `messaging:${searchChannelId}` }, - { text: { $autocomplete: 'super' } }, - ), - client.search({ cid: myChannelCid }, { text: { $autocomplete: 'supercali' } }), + client.search({ + payload: { + filter_conditions: { cid: `messaging:${searchChannelId}` }, + message_filter_conditions: { text: { $autocomplete: 'super' } }, + }, + }), + client.search({ + payload: { + filter_conditions: { cid: myChannelCid }, + message_filter_conditions: { text: { $autocomplete: 'supercali' } }, + }, + }), ]); expect(a.results).toHaveLength(3); expect(b.results).toHaveLength(PAGING_COUNT); @@ -100,22 +112,29 @@ describe('_default/04-messages/06-search.md', () => { // #region snippet docs="chat/_default/04-messages/06-search.md" heading="Searching Messages" tab="JavaScript" index=1 // COPY: userId="john" // Search for messages containing text - const results = await client.search( - { members: { $in: [userId] } }, - 'supercalifragilisticexpialidocious', - { limit: 10 }, - ); + const results = await client.search({ + payload: { + filter_conditions: { members: { $in: [userId] } }, + query: 'supercalifragilisticexpialidocious', + limit: 10, + }, + }); // Search with message filters - const filtered = await client.search( - { members: { $in: [userId] } }, - { text: { $autocomplete: 'super' }, attachments: { $exists: true } }, - { limit: 10 }, - ); + const filtered = await client.search({ + payload: { + filter_conditions: { members: { $in: [userId] } }, + message_filter_conditions: { + text: { $autocomplete: 'super' }, + attachments: { $exists: true }, + }, + limit: 10, + }, + }); // #endregion snippet - expect(results.results.map((r) => r.message.id)).toEqual([ids.text]); - expect(filtered.results.map((r) => r.message.id)).toEqual([ids.attachment]); + expect(results.results.map((r) => r.message?.id)).toEqual([ids.text]); + expect(filtered.results.map((r) => r.message?.id)).toEqual([ids.attachment]); }); it('paginates search results with a cursor', async () => { @@ -125,25 +144,41 @@ describe('_default/04-messages/06-search.md', () => { const messageFilters = { text: { $autocomplete: 'supercali' } }; // First page with custom sorting - const page1 = await client.search(channelFilters, messageFilters, { - sort: [{ relevance: -1 }, { updated_at: 1 }, { my_custom_field: -1 }], - limit: 10, + const page1 = await client.search({ + payload: { + filter_conditions: channelFilters, + message_filter_conditions: messageFilters, + sort: [ + { field: 'relevance', direction: -1 }, + { field: 'updated_at', direction: 1 }, + { field: 'my_custom_field', direction: -1 }, + ], + limit: 10, + }, }); // Next page using cursor - const page2 = await client.search(channelFilters, messageFilters, { - limit: 10, - next: page1.next, + const page2 = await client.search({ + payload: { + filter_conditions: channelFilters, + message_filter_conditions: messageFilters, + limit: 10, + next: page1.next, + }, }); // Previous page - const page1Again = await client.search(channelFilters, messageFilters, { - limit: 10, - next: page2.previous, + const page1Again = await client.search({ + payload: { + filter_conditions: channelFilters, + message_filter_conditions: messageFilters, + limit: 10, + next: page2.previous, + }, }); // #endregion snippet - const idsOf = (page: typeof page1) => page.results.map((r) => r.message.id); + const idsOf = (page: typeof page1) => page.results.map((r) => r.message?.id); expect(idsOf(page1)).toEqual(pagingIds.slice(0, 10)); expect(idsOf(page2)).toEqual(pagingIds.slice(10)); expect(page2.next).toBeUndefined(); diff --git a/test/docs-snippets/pages/chat/04-messages/07-silent_messages.test.ts b/test/docs-snippets/pages/chat/04-messages/07-silent_messages.test.ts index 27c5997729..e409a88535 100644 --- a/test/docs-snippets/pages/chat/04-messages/07-silent_messages.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/07-silent_messages.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { Channel, StreamChat } from '../../../../../src'; import { disconnectClients, @@ -38,7 +38,9 @@ describe('_default/04-messages/07-silent_messages.md', () => { await grantMessagingMembers(serverClient, cleanup, EXTRA_MEMBER_GRANTS); await waitForChannelTypePropagation(); client = await getClientSideClient({ id: userId }); - channel = client.channel(channelType, channelId, { members: [userId, otherId] }); + channel = client.channel(channelType, channelId, { + members: [{ user_id: userId }, { user_id: otherId }], + }); cleanup.channels.push(`${channelType}:${channelId}`); await channel.watch(); }); @@ -49,39 +51,44 @@ describe('_default/04-messages/07-silent_messages.md', () => { }); it('sends a silent message', async () => { - const tripData = { title: 'Trip to the airport', distance_km: 12 }; + const tripData = { distance_km: 12 }; const before = await unreadCountOfOther(); + const sendSpy = vi.spyOn(channel, 'sendMessage'); // #region snippet docs="chat/_default/04-messages/07-silent_messages.md" heading="Silent Messages" tab="JavaScript" index=1 const message = { text: 'You completed your trip', silent: true, - attachments: [{ type: 'trip', ...tripData }], + attachments: [{ type: 'trip', custom: tripData }], }; - await channel.sendMessage(message); + await channel.sendMessage({ message }); // #endregion snippet - const { messages } = await channel.query({ messages: { limit: 2 } }); - const sent = messages[0]; + const { message: sent } = await sendSpy.mock.results[0].value; + sendSpy.mockRestore(); expect(sent.text).toBe('You completed your trip'); expect(sent.silent).toBe(true); - expect(sent.attachments?.[0]?.type).toBe('trip'); + expect(sent.attachments[0]?.type).toBe('trip'); + expect(sent.attachments[0]?.custom.distance_km).toBe(12); expect(await unreadCountOfOther()).toBe(before); // A regular message does count, so the check above is meaningful. - await channel.sendMessage({ text: 'Regular message' }); + await channel.sendMessage({ message: { text: 'Regular message' } }); expect(await unreadCountOfOther()).toBe(before + 1); }); it('sends a system message', async () => { + const sendSpy = vi.spyOn(channel, 'sendMessage'); // #region snippet docs="chat/_default/04-messages/07-silent_messages.md" heading="System Messages" tab="JavaScript" index=1 await channel.sendMessage({ - text: 'You completed your trip', - type: 'system', + message: { + text: 'You completed your trip', + type: 'system', + }, }); // #endregion snippet - const { messages } = await channel.query({ messages: { limit: 1 } }); - const sent = messages[messages.length - 1]; + const { message: sent } = await sendSpy.mock.results[0].value; + sendSpy.mockRestore(); expect(sent.text).toBe('You completed your trip'); expect(sent.type).toBe('system'); }); diff --git a/test/docs-snippets/pages/chat/04-messages/09-message_reminders.test.ts b/test/docs-snippets/pages/chat/04-messages/09-message_reminders.test.ts index f465b0965d..1f0835dcd3 100644 --- a/test/docs-snippets/pages/chat/04-messages/09-message_reminders.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/09-message_reminders.test.ts @@ -2,11 +2,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { Channel, QueryRemindersResponse, - ReminderAPIResponse, ReminderFilters, - ReminderSort, + SortParamRequest, StreamChat, } from '../../../../../src'; +import { nsToMs } from '../../../../../src'; import { disconnectClients, getClientSideClient, @@ -37,7 +37,7 @@ describe('_default/04-messages/09-message_reminders.md', () => { beforeAll(async () => { cleanup.users.push(userId); client = await getClientSideClient({ id: userId }); - channel = client.channel('messaging', channelId, { members: [userId] }); + channel = client.channel('messaging', channelId, { members: [{ user_id: userId }] }); cleanup.channels.push(`messaging:${channelId}`); await channel.watch(); // Reminders are off for `messaging` in the test app: enable them for this channel only. @@ -58,7 +58,7 @@ describe('_default/04-messages/09-message_reminders.md', () => { 'page4', ]; for (const name of names) { - ids[name] = (await channel.sendMessage({ text: name })).message.id; + ids[name] = (await channel.sendMessage({ message: { text: name } })).message.id; } }); @@ -77,33 +77,29 @@ describe('_default/04-messages/09-message_reminders.md', () => { // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Creating a Message Reminder" tab="JavaScript" index=1 // COPY: messageID="message-id", anotherMessageID="another-message-id" // Create a reminder with a specific due date (direct API call) - await client.createReminder({ - messageId: messageID, + await client.createReminder( + { message_id: messageID }, // Remind in offsetMs - remind_at: new Date(new Date().getTime() + offsetMs).toISOString(), - }); + { remind_at: new Date(new Date().getTime() + offsetMs) }, + ); // Create a reminder with a specific due date (client state optimistic update) - await client.reminders.upsertReminder({ - messageId: messageID, - remind_at: new Date(new Date().getTime() + offsetMs).toISOString(), - }); + await client.reminders.upsertReminder( + { message_id: messageID }, + { remind_at: new Date(new Date().getTime() + offsetMs) }, + ); // Create a "Save for later" reminder without a specific time (direct API call) - await client.createReminder({ - messageId: anotherMessageID, - }); + await client.createReminder({ message_id: anotherMessageID }); // Create a "Save for later" reminder without a specific time (client state optimistic update) - await client.reminders.upsertReminder({ - messageId: messageID, - }); + await client.reminders.upsertReminder({ message_id: messageID }); // #endregion snippet - const first = (await createSpy.mock.results[0].value) as ReminderAPIResponse; + const first = await createSpy.mock.results[0].value; createSpy.mockRestore(); expect(first.reminder.message_id).toBe(ids.create); - expect(new Date(first.reminder.remind_at ?? 0).getTime()).toBeGreaterThanOrEqual( + expect(nsToMs(first.reminder.remind_at ?? 0)).toBeGreaterThanOrEqual( before + offsetMs - 1000, ); expect(client.reminders.getFromState(ids.create)).toBeDefined(); @@ -114,45 +110,39 @@ describe('_default/04-messages/09-message_reminders.md', () => { it('updates reminders', async () => { const messageID = ids.update; const newOffsetMs = 2 * oneHour; - await client.createReminder({ - messageId: ids.update, - remind_at: new Date(Date.now() + oneHour).toISOString(), - }); + await client.createReminder( + { message_id: ids.update }, + { remind_at: new Date(Date.now() + oneHour) }, + ); const updateSpy = vi.spyOn(client, 'updateReminder'); const before = Date.now(); // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Updating a Message Reminder" tab="JavaScript" index=1 // COPY: messageID="message-id" // Update a reminder with a new due date (direct API call) - await client.updateReminder({ - messageId: messageID, + await client.updateReminder( + { message_id: messageID }, // Remind in newOffsetMs - remind_at: new Date(new Date().getTime() + newOffsetMs).toISOString(), - }); + { remind_at: new Date(new Date().getTime() + newOffsetMs) }, + ); // Update a reminder with a new due date (client state optimistic update) - await client.reminders.upsertReminder({ - messageId: messageID, + await client.reminders.upsertReminder( + { message_id: messageID }, // Remind in newOffsetMs - remind_at: new Date(new Date().getTime() + newOffsetMs).toISOString(), - }); + { remind_at: new Date(new Date().getTime() + newOffsetMs) }, + ); - // Convert a timed reminder to "Save for later" (direct API call) - await client.updateReminder({ - messageId: messageID, - remind_at: null, - }); + // Convert a timed reminder to "Save for later" by omitting remind_at (direct API call) + await client.updateReminder({ message_id: messageID }); // Convert a timed reminder to "Save for later" (client state optimistic update) - await client.reminders.upsertReminder({ - messageId: messageID, - remind_at: null, - }); + await client.reminders.upsertReminder({ message_id: messageID }); // #endregion snippet - const first = (await updateSpy.mock.results[0].value) as ReminderAPIResponse; + const first = await updateSpy.mock.results[0].value; updateSpy.mockRestore(); - expect(new Date(first.reminder.remind_at ?? 0).getTime()).toBeGreaterThanOrEqual( + expect(nsToMs(first.reminder.remind_at ?? 0)).toBeGreaterThanOrEqual( before + newOffsetMs - 1000, ); expect((await reminderOf(ids.update))?.remind_at ?? null).toBeNull(); @@ -161,14 +151,14 @@ describe('_default/04-messages/09-message_reminders.md', () => { it('deletes reminders', async () => { const messageID = ids.delete; const anotherMessageID = ids.deleteOther; - await client.createReminder({ messageId: ids.delete }); - await client.reminders.upsertReminder({ messageId: ids.deleteOther }); + await client.createReminder({ message_id: ids.delete }); + await client.reminders.upsertReminder({ message_id: ids.deleteOther }); expect(client.reminders.getFromState(ids.deleteOther)).toBeDefined(); // #region snippet docs="chat/_default/04-messages/09-message_reminders.md" heading="Deleting a Message Reminder" tab="JavaScript" index=1 // COPY: messageID="message-id", anotherMessageID="another-message-id" // Delete a reminder for a message with id 'message-id' (direct API call) - await client.deleteReminder(messageID); + await client.deleteReminder({ message_id: messageID }); // Delete a reminder for a message with id 'another-message-id' (client state optimistic update) await client.reminders.deleteReminder(anotherMessageID); @@ -237,17 +227,17 @@ describe('_default/04-messages/09-message_reminders.md', () => { // #endregion snippet try { - await client.createReminder({ messageId: ids.events }); - await client.updateReminder({ - messageId: ids.events, - remind_at: new Date(Date.now() + oneHour).toISOString(), - }); - await client.deleteReminder(ids.events); + await client.createReminder({ message_id: ids.events }); + await client.updateReminder( + { message_id: ids.events }, + { remind_at: new Date(Date.now() + oneHour) }, + ); + await client.deleteReminder({ message_id: ids.events }); // The earliest allowed due time is one minute ahead; the due event arrives shortly after. - await client.createReminder({ - messageId: ids.due, - remind_at: new Date(Date.now() + 61000).toISOString(), - }); + await client.createReminder( + { message_id: ids.due }, + { remind_at: new Date(Date.now() + 61000) }, + ); await retry( () => { expect(logged('Reminder created for message:', ids.events)).toBe(true); @@ -273,10 +263,10 @@ describe('_default/04-messages/09-message_reminders.md', () => { it('filters reminders', async () => { // Runs after the events test, so the `due` reminder is overdue by now. - const timed = await client.createReminder({ - messageId: ids.events, - remind_at: new Date(Date.now() + oneHour).toISOString(), - }); + const timed = await client.createReminder( + { message_id: ids.events }, + { remind_at: new Date(Date.now() + oneHour) }, + ); expect(timed.reminder.remind_at).toBeDefined(); const querySpy = vi.spyOn(client, 'queryReminders'); @@ -285,21 +275,21 @@ describe('_default/04-messages/09-message_reminders.md', () => { // Create filter for overdue reminders await client.queryReminders({ filter: { - remind_at: { $lte: new Date().toISOString() }, - }, - sort: { - remind_at: -1, // sort from the most recently expired + remind_at: { $lte: new Date() }, }, + sort: [ + { field: 'remind_at', direction: -1 }, // sort from the most recently expired + ], }); // Create filter for upcoming reminders await client.queryReminders({ filter: { - remind_at: { $gt: new Date().toISOString() }, - }, - sort: { - remind_at: 1, // sort from the nearest to expire + remind_at: { $gt: new Date() }, }, + sort: [ + { field: 'remind_at', direction: 1 }, // sort from the nearest to expire + ], }); // Create filter for reminders with no due date (saved for later) @@ -307,9 +297,9 @@ describe('_default/04-messages/09-message_reminders.md', () => { filter: { remind_at: null, }, - sort: { - created_at: -1, // sort from the most recently created - }, + sort: [ + { field: 'created_at', direction: -1 }, // sort from the most recently created + ], }); // Client-side calls with local state updates before starting the pagination. @@ -317,27 +307,27 @@ describe('_default/04-messages/09-message_reminders.md', () => { // Create filter for overdue reminders client.reminders.paginator.filters = { - remind_at: { $lte: new Date().toISOString() }, - }; - client.reminders.paginator.sort = { - remind_at: -1, // sort from the most recently expired + remind_at: { $lte: new Date() }, }; + client.reminders.paginator.sort = [ + { field: 'remind_at', direction: -1 }, // sort from the most recently expired + ]; // Create filter for upcoming reminders client.reminders.paginator.filters = { - remind_at: { $gt: new Date().toISOString() }, - }; - client.reminders.paginator.sort = { - remind_at: 1, // sort from the nearest to expire + remind_at: { $gt: new Date() }, }; + client.reminders.paginator.sort = [ + { field: 'remind_at', direction: 1 }, // sort from the nearest to expire + ]; // Create filter for reminders with no due date (saved for later) client.reminders.paginator.filters = { remind_at: null, }; - client.reminders.paginator.sort = { - created_at: -1, // sort from the most recently created - }; + client.reminders.paginator.sort = [ + { field: 'created_at', direction: -1 }, // sort from the most recently created + ]; // adjust the page size client.reminders.paginator.pageSize = 15; @@ -365,14 +355,14 @@ describe('_default/04-messages/09-message_reminders.md', () => { it('paginates reminders', async () => { const pageIds = [ids.page1, ids.page2, ids.page3, ids.page4]; for (const [i, messageId] of pageIds.entries()) { - await client.createReminder({ - messageId, - remind_at: new Date(Date.now() + 3 * oneHour + i * 60000).toISOString(), - }); + await client.createReminder( + { message_id: messageId }, + { remind_at: new Date(Date.now() + 3 * oneHour + i * 60000) }, + ); } const filter: ReminderFilters = { message_id: { $in: pageIds } }; const filters = filter; - const sort: ReminderSort = { remind_at: 1 }; + const sort: SortParamRequest[] = [{ field: 'remind_at', direction: 1 }]; const limit = 2; const firstPage = await client.queryReminders({ filter, sort, limit }); const next = firstPage.next; diff --git a/test/docs-snippets/pages/chat/04-messages/10-message_receipts.test.ts b/test/docs-snippets/pages/chat/04-messages/10-message_receipts.test.ts index 35bff81900..904e1e78cb 100644 --- a/test/docs-snippets/pages/chat/04-messages/10-message_receipts.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/10-message_receipts.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { Channel, Event } from '../../../../../src'; +import type { Channel, EventPayload } from '../../../../../src'; import { StreamChat } from '../../../../../src'; import { createUserToken, @@ -28,11 +28,13 @@ describe('_default/04-messages/10-message_receipts.md', () => { let observer: StreamChat; let control: StreamChat; let observerChannel: Channel; - const events: Event[] = []; + const events: EventPayload<'message.delivered' | 'message.read'>[] = []; const clients: StreamChat[] = []; const newChatClient = () => { - const chatClient = new StreamChat(apiKey, { allowServerSideConnect: true }); + const chatClient = new StreamChat(apiKey, { + client: { allowServerSideConnect: true }, + }); clients.push(chatClient); return chatClient; }; @@ -48,7 +50,9 @@ describe('_default/04-messages/10-message_receipts.md', () => { control = await getClientSideClient({ id: controlId }); clients.push(observer, control); observerChannel = observer.channel('messaging', channelId, { - members: [observerId, controlId, deliveryUserId, readUserId], + members: [observerId, controlId, deliveryUserId, readUserId].map((user_id) => ({ + user_id, + })), }); cleanup.channels.push(cid); await observerChannel.watch(); @@ -86,7 +90,9 @@ describe('_default/04-messages/10-message_receipts.md', () => { // The user confirms delivery, then the control user does: only the control's // `message.delivered` reaches the other members. - const { message } = await observerChannel.sendMessage({ text: 'delivered?' }); + const { message } = await observerChannel.sendMessage({ + message: { text: 'delivered?' }, + }); const latest_delivered_messages = [{ cid, id: message.id }]; await chatClient.markChannelsDelivered({ latest_delivered_messages }); await control.markChannelsDelivered({ latest_delivered_messages }); @@ -122,7 +128,7 @@ describe('_default/04-messages/10-message_receipts.md', () => { // The user reads the channel, then the control user does: only the control's // `message.read` reaches the other members. - await observerChannel.sendMessage({ text: 'read?' }); + await observerChannel.sendMessage({ message: { text: 'read?' } }); const readerChannel = chatClient.channel('messaging', channelId); await readerChannel.watch(); const controlChannel = control.channel('messaging', channelId); diff --git a/test/docs-snippets/pages/chat/05-features/02-events.test.ts b/test/docs-snippets/pages/chat/05-features/02-events.test.ts index 9cb6cd7176..a35d7b1111 100644 --- a/test/docs-snippets/pages/chat/05-features/02-events.test.ts +++ b/test/docs-snippets/pages/chat/05-features/02-events.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, Event, StreamChat } from '../../../../../src'; +import type { Channel, Event, EventPayload, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, @@ -22,7 +22,9 @@ describe('_default/05-features/02-events.md', () => { // the events of other tests. const createWatchedChannel = async () => { const id = uniqueId('channel'); - const channel = client.channel('messaging', id, { members: [userId, otherId] }); + const channel = client.channel('messaging', id, { + members: [{ user_id: userId }, { user_id: otherId }], + }); cleanup.channels.push(`messaging:${id}`); await channel.watch(); return channel; @@ -30,7 +32,7 @@ describe('_default/05-features/02-events.md', () => { // Drops the listeners a snippet registered on a channel, once its test is done. const removeListeners = (channel: Channel) => { - channel.listeners = {}; + channel.listeners.clear(); }; const sendAsOther = (channel: Channel, text: string) => @@ -117,7 +119,7 @@ describe('_default/05-features/02-events.md', () => { // COPY: channelId="my-conversation-123", johnId="john", jackId="jack" // Watch a channel with presence enabled to receive user presence events const channel = client.channel('messaging', channelId, { - members: [johnId, jackId], + members: [{ user_id: johnId }, { user_id: jackId }], }); const state = await channel.watch({ presence: true }); @@ -131,7 +133,7 @@ describe('_default/05-features/02-events.md', () => { // #endregion snippet presenceListener = onSpy.mock.results[0]?.value as { unsubscribe: () => void }; - expect(state.channel.id).toBe(channelId); + expect(state.channel?.id).toBe(channelId); expect(state.members.map((m) => m.user_id).sort()).toEqual([johnId, jackId].sort()); // Jack comes online, then goes offline. @@ -155,39 +157,41 @@ describe('_default/05-features/02-events.md', () => { it('listens for connection events', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - const onSpy = vi.spyOn(client, 'on'); - let listener: { unsubscribe: () => void } | undefined; + const subscribeSpy = vi.spyOn(client.wsConnection.state, 'subscribeWithSelector'); + let unsubscribe: (() => void) | undefined; try { // #region snippet docs="chat/_default/05-features/02-events.md" heading="Connection Events" tab="JavaScript" index=1 - client.on('connection.changed', (e) => { - if (e.online) { - console.log('the connection is up!'); - } else { - console.log('the connection is down!'); - } - }); + // the WebSocket's status is a state store: the handler runs once with + // the current value, then on every change + client.wsConnection.state.subscribeWithSelector( + ({ isHealthy }) => ({ isHealthy }), + ({ isHealthy }) => { + if (isHealthy) { + console.log('the connection is up!'); + } else { + console.log('the connection is down!'); + } + }, + ); // #endregion snippet - listener = onSpy.mock.results[0]?.value as { unsubscribe: () => void }; + unsubscribe = subscribeSpy.mock.results[0]?.value as () => void; + // the first call reports the current (healthy) state + expect(logSpy).toHaveBeenCalledWith('the connection is up!'); + logSpy.mockClear(); - // Simulate the browser going offline and back online. - client.wsConnection?.onlineStatusChanged(new globalThis.Event('offline')); - await retry(() => { - expect(logSpy).toHaveBeenCalledWith('the connection is down!'); - return Promise.resolve(); - }); - client.wsConnection?.onlineStatusChanged(new globalThis.Event('online')); + // Close the socket and open it again, as an app going to the background would. + await client.closeConnection(); + expect(logSpy).toHaveBeenCalledWith('the connection is down!'); + await client.openConnection(); await retry(() => { expect(logSpy).toHaveBeenCalledWith('the connection is up!'); return Promise.resolve(); }); - await retry(() => { - expect(client.wsConnection?.isHealthy).toBe(true); - return Promise.resolve(); - }); + expect(client.wsConnection.isHealthy).toBe(true); } finally { - listener?.unsubscribe(); - onSpy.mockRestore(); + unsubscribe?.(); + subscribeSpy.mockRestore(); logSpy.mockRestore(); } }); @@ -199,7 +203,7 @@ describe('_default/05-features/02-events.md', () => { // #region snippet docs="chat/_default/05-features/02-events.md" heading="Stop Listening for Events" tab="JavaScript" index=1 // remove the handler from all client events - const myClientEventListener = client.on('connection.changed', myClientEventHandler); + const myClientEventListener = client.on('message.new', myClientEventHandler); myClientEventListener.unsubscribe(); // remove the handler from all events on a channel @@ -211,17 +215,16 @@ describe('_default/05-features/02-events.md', () => { const clientControl = vi.fn(); const channelControl = vi.fn(); const controls = [ - client.on('connection.changed', clientControl), + client.on('message.new', clientControl), channel.on('message.new', channelControl), ]; try { - client.dispatchEvent({ type: 'connection.changed', online: true }); await sendAsOther(channel, 'Nobody listens'); await retry(() => { expect(channelControl).toHaveBeenCalled(); + expect(clientControl).toHaveBeenCalled(); return Promise.resolve(); }); - expect(clientControl).toHaveBeenCalled(); expect(myClientEventHandler).not.toHaveBeenCalled(); expect(myChannelEventHandler).not.toHaveBeenCalled(); } finally { @@ -235,14 +238,16 @@ describe('_default/05-features/02-events.md', () => { otherClient = await getClientSideClient({ id: otherId, name: 'Jack' }); const otherChannel = otherClient.channel(channel.type, channel.id); await otherChannel.watch(); - const received: Event[] = []; + const received: EventPayload<'friendship_request'>[] = []; otherChannel.on('friendship_request', (event) => received.push(event)); // #region snippet docs="chat/_default/05-features/02-events.md" heading="To a channel" tab="JavaScript" index=1 // sends an event for the current user to all connect clients on the channel await channel.sendEvent({ - type: 'friendship_request', - text: 'Hey there, long time no see!', + event: { + type: 'friendship_request', + custom: { text: 'Hey there, long time no see!' }, + }, }); // #endregion snippet @@ -250,9 +255,9 @@ describe('_default/05-features/02-events.md', () => { expect(received).toHaveLength(1); return Promise.resolve(); }); - expect(received[0].text).toBe('Hey there, long time no see!'); - expect(received[0].user?.id).toBe(userId); - expect(received[0].cid).toBe(channel.cid); + expect(received[0].custom.text).toBe('Hey there, long time no see!'); + // `user` and `cid` are on the wire, but not on the custom event type + expect(received[0]).toMatchObject({ user: { id: userId }, cid: channel.cid }); await disconnectClients(otherClient); otherClient = undefined; diff --git a/test/docs-snippets/pages/chat/05-features/03-unread.test.ts b/test/docs-snippets/pages/chat/05-features/03-unread.test.ts index a708218ef3..69971a3b5b 100644 --- a/test/docs-snippets/pages/chat/05-features/03-unread.test.ts +++ b/test/docs-snippets/pages/chat/05-features/03-unread.test.ts @@ -19,7 +19,7 @@ describe('_default/05-features/03-unread.md', () => { const token = createUserToken(userId); // Not connected yet: the first snippet connects it. const client = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); let firstMessageId = ''; @@ -67,12 +67,13 @@ describe('_default/05-features/03-unread.md', () => { console.log(user?.me?.unread_threads); // number of unread threads // Step 2: Listen to events for real-time updates + // (only some events carry the counts, so check for the field first) client.on((event) => { - if (event.total_unread_count !== undefined) { + if ('total_unread_count' in event && event.total_unread_count !== undefined) { console.log(event.total_unread_count); } - if (event.unread_channels !== undefined) { + if ('unread_channels' in event && event.unread_channels !== undefined) { console.log(event.unread_channels); } }); @@ -102,7 +103,7 @@ describe('_default/05-features/03-unread.md', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Unread Counts - Server side" tab="JavaScript" index=1 - const response = await client.getUnreadCount(); + const response = await client.unreadCounts(); console.log(response.total_unread_count); // total unread count for user console.log(response.channels); // distribution of unread counts across channels console.log(response.channel_type); // distribution of unread counts across channel types @@ -158,7 +159,7 @@ describe('_default/05-features/03-unread.md', () => { // #region snippet docs="chat/_default/05-features/03-unread.md" heading="Mark All As Read" tab="JavaScript" index=1 // client-side - await client.markAllRead(); + await client.markChannelsRead(); // #endregion snippet expect(await serverUnread()).toBe(0); @@ -179,19 +180,21 @@ describe('_default/05-features/03-unread.md', () => { // { // id: '2fe6019c-872f-482a-989e-ecf4f786501b', // role: 'user', - // created_at: '2019-04-24T13:09:19.664378Z', - // updated_at: '2019-04-24T13:09:23.784642Z', - // last_active: '2019-04-24T13:09:23.781641Z', + // created_at: 1556111359664378000, // unix nanoseconds + // updated_at: 1556111363784642000, + // last_active: 1556111363781641000, // online: true // }, - // last_read: '2019-04-24T13:09:21.623Z', + // last_read: 1556111361623000000, + // unread_messages: 0, // } //} // #endregion snippet expect(Object.keys(channel.state.read).sort()).toEqual([otherId, userId].sort()); expect(channel.state.read[userId].user.id).toBe(userId); - expect(channel.state.read[userId].last_read).toBeInstanceOf(Date); + expect(channel.state.read[userId].last_read).toEqual(expect.any(Number)); + expect(channel.state.read[userId].unread_messages).toBe(0); expect(logSpy).toHaveBeenCalledWith(channel.state.read); } finally { logSpy.mockRestore(); diff --git a/test/docs-snippets/pages/chat/05-features/04-typing_indicators.test.ts b/test/docs-snippets/pages/chat/05-features/04-typing_indicators.test.ts index 38926ca871..186c3c409a 100644 --- a/test/docs-snippets/pages/chat/05-features/04-typing_indicators.test.ts +++ b/test/docs-snippets/pages/chat/05-features/04-typing_indicators.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, Event, StreamChat } from '../../../../../src'; +import type { Channel, EventPayload, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, @@ -20,18 +20,20 @@ describe('_default/05-features/04-typing_indicators.md', () => { // A fresh channel per test, watched by both users, with a parent message for thread typing. const createWatchedChannels = async () => { const id = uniqueId('channel'); - const channel = client.channel('messaging', id, { members: [userId, otherId] }); + const channel = client.channel('messaging', id, { + members: [{ user_id: userId }, { user_id: otherId }], + }); cleanup.channels.push(`messaging:${id}`); await channel.watch(); const otherChannel = otherClient.channel('messaging', id); await otherChannel.watch(); - const { message } = await channel.sendMessage({ text: 'Thread parent' }); + const { message } = await channel.sendMessage({ message: { text: 'Thread parent' } }); return { channel, otherChannel, parentId: message.id }; }; // Records the typing events a watched channel receives. const recordTypingEvents = (channel: Channel) => { - const events: Event[] = []; + const events: EventPayload<'typing.start' | 'typing.stop'>[] = []; channel.on((event) => { if (event.type === 'typing.start' || event.type === 'typing.stop') { events.push(event); @@ -97,7 +99,7 @@ describe('_default/05-features/04-typing_indicators.md', () => { }); await channel.stopTyping(thread_id); } finally { - otherChannel.listeners = {}; + otherChannel.listeners.clear(); } }); @@ -159,7 +161,7 @@ describe('_default/05-features/04-typing_indicators.md', () => { }); } finally { logSpy.mockRestore(); - channel.listeners = {}; + channel.listeners.clear(); } }); }); diff --git a/test/docs-snippets/pages/chat/05-features/05-presence_format.test.ts b/test/docs-snippets/pages/chat/05-features/05-presence_format.test.ts index 4ea159ca82..b86ec0c1eb 100644 --- a/test/docs-snippets/pages/chat/05-features/05-presence_format.test.ts +++ b/test/docs-snippets/pages/chat/05-features/05-presence_format.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { StreamChat } from '../../../../../src'; -import type { Event } from '../../../../../src'; +import type { EventPayload } from '../../../../../src'; import { createUserToken, disconnectClients, @@ -30,7 +30,7 @@ describe('_default/05-features/05-presence_format.md', () => { // Records the presence events of `userId` the client receives. const recordPresence = (userId: string) => { - const events: Event[] = []; + const events: EventPayload<'user.presence.changed'>[] = []; const { unsubscribe } = client.on('user.presence.changed', (event) => { if (event.user?.id === userId) events.push(event); }); @@ -56,19 +56,19 @@ describe('_default/05-features/05-presence_format.md', () => { // If you pass presence: true to channel.watch it will watch the list of user presence changes. // Note that you can listen to at most 10 users using this API call const channel = client.channel('messaging', channelId, { - members: [johnId, jackId], - color: 'green', + members: [{ user_id: johnId }, { user_id: jackId }], + custom: { color: 'green' }, }); const state = await channel.watch({ presence: true }); - // queryChannels allows you to listen to the members of the channels that are returned + // queryChannelsAndHydrate allows you to listen to the members of the channels that are returned // so this does the same thing as above and listens to online status changes for john and jack - const channels = await client.queryChannels( - { color: 'green' }, - { last_message_at: -1 }, - { presence: true }, - ); + const channels = await client.queryChannelsAndHydrate({ + filter_conditions: { 'custom.color': 'green' }, + sort: [{ field: 'last_message_at', direction: -1 }], + presence: true, + }); // #endregion snippet expect(state.members.map((m) => m.user_id).sort()).toEqual([johnId, jackId].sort()); @@ -95,15 +95,15 @@ describe('_default/05-features/05-presence_format.md', () => { const id = uniqueId('channel'); cleanup.channels.push(`messaging:${id}`); await client - .channel('messaging', id, { members: [johnId, userId] }) + .channel('messaging', id, { members: [{ user_id: johnId }, { user_id: userId }] }) .watch({ presence: true }); const { events, unsubscribe } = recordPresence(userId); try { - const original = userClient.partialUpdateUser.bind(userClient); + const original = userClient.updateUsersPartial.bind(userClient); const responses: Awaited>[] = []; - userClient.partialUpdateUser = async (update) => { - const response = await original(update); + vi.spyOn(userClient, 'updateUsersPartial').mockImplementation(async (request) => { + const response = await original(request); responses.push(response); if (responses.length === 1) { // after "become invisible": the user is invisible and appears offline @@ -116,21 +116,19 @@ describe('_default/05-features/05-presence_format.md', () => { }); } return response; - }; + }); const client = userClient; // #region snippet docs="chat/_default/05-features/05-presence_format.md" heading="Invisible" tab="JavaScript" index=1 // COPY: userId="unique_user_id" // become invisible - await client.partialUpdateUser({ - id: userId, - set: { invisible: true }, + await client.updateUsersPartial({ + users: [{ id: userId, set: { invisible: true } }], }); // become visible - await client.partialUpdateUser({ - id: userId, - set: { invisible: false }, + await client.updateUsersPartial({ + users: [{ id: userId, set: { invisible: false } }], }); // #endregion snippet @@ -156,7 +154,7 @@ describe('_default/05-features/05-presence_format.md', () => { await serverClient.upsertUsers([{ id: userId }]); const token = createUserToken(userId); const client = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); extraClients.push(client); const connectSpy: { reply?: Awaited> } = {}; @@ -212,7 +210,8 @@ describe('_default/05-features/05-presence_format.md', () => { console.log(member.user?.last_active); // #endregion snippet - expect(logSpy.mock.calls).toEqual([[true], [expect.any(String)]]); + // `last_active` is in unix nanoseconds + expect(logSpy.mock.calls).toEqual([[true], [expect.any(Number)]]); } finally { logSpy.mockRestore(); } diff --git a/test/docs-snippets/pages/chat/05-features/07-polls_api.test.ts b/test/docs-snippets/pages/chat/05-features/07-polls_api.test.ts index f8cbb923a7..a83ee74ab1 100644 --- a/test/docs-snippets/pages/chat/05-features/07-polls_api.test.ts +++ b/test/docs-snippets/pages/chat/05-features/07-polls_api.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Channel, CreatePollData, PollAnswer, StreamChat } from '../../../../../src'; +import type { Channel, CreatePollRequest, StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, @@ -23,13 +23,15 @@ describe(DOCS, () => { let channel: Channel; /** Creates a poll as the connected user and sends it in a message. */ - const sendPoll = async (data: Partial = {}) => { + const sendPoll = async (data: Partial = {}) => { const { poll } = await client.createPoll({ name: uniqueId('poll'), options: [{ text: 'A' }, { text: 'B' }, { text: 'C' }], ...data, }); - const { message } = await channel.sendMessage({ text: 'Vote!', poll_id: poll.id }); + const { message } = await channel.sendMessage({ + message: { text: 'Vote!', poll_id: poll.id }, + }); return { poll, pollId: poll.id, @@ -54,7 +56,7 @@ describe(DOCS, () => { cleanup.channelTypes.push(channelType); await waitForChannelTypePropagation(); client = await getClientSideClient({ id: userId }); - channel = client.channel(channelType, channelId, { members: [userId] }); + channel = client.channel(channelType, channelId, { members: [{ user_id: userId }] }); cleanup.channels.push(`${channelType}:${channelId}`); await channel.watch(); }); @@ -76,13 +78,16 @@ describe(DOCS, () => { name: 'Where should we host our next event?', options: [{ text: 'Amsterdam' }, { text: 'Boulder' }], }); - await channel.sendMessage({ text: 'Vote now!', poll_id: poll.id }); + await channel.sendMessage({ message: { text: 'Vote now!', poll_id: poll.id } }); // Vote on a poll - await client.castPollVote(messageId, pollId, { option_id: firstOptionId }); + await client.castPollVote( + { message_id: messageId, poll_id: pollId }, + { vote: { option_id: firstOptionId } }, + ); // Retrieve poll results - const { poll: retrievedPoll } = await client.getPoll(pollId); + const { poll: retrievedPoll } = await client.getPoll({ poll_id: pollId }); console.log(retrievedPoll.vote_counts_by_option); // { 'option-id': 5 } // #endregion snippet @@ -125,8 +130,10 @@ describe(DOCS, () => { }); const { message } = await channel.sendMessage({ - text: 'We want to know your opinion!', - poll_id: poll.id, + message: { + text: 'We want to know your opinion!', + poll_id: poll.id, + }, }); // message.poll contains all relevant poll data @@ -146,20 +153,20 @@ describe(DOCS, () => { options: [ { text: 'Amsterdam, The Netherlands', - foo: 'bar', + custom: { foo: 'bar' }, }, { text: 'Boulder, CO', - foo: 'baz', + custom: { foo: 'baz' }, }, ], - foo: 'bar', + custom: { foo: 'bar' }, }); // #endregion snippet - expect(poll.poll).toMatchObject({ foo: 'bar' }); - expect(poll.poll.options[0]).toMatchObject({ foo: 'bar' }); - expect(poll.poll.options[1]).toMatchObject({ foo: 'baz' }); + expect(poll.poll.custom).toMatchObject({ foo: 'bar' }); + expect(poll.poll.options[0].custom).toMatchObject({ foo: 'bar' }); + expect(poll.poll.options[1].custom).toMatchObject({ foo: 'baz' }); }); it('casts votes on options', async () => { @@ -171,17 +178,18 @@ describe(DOCS, () => { // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Send vote on option" tab="JavaScript" index=1 // COPY: firstOptionId="some-option-id-328904342" - const { vote } = await chatClient.castPollVote(messageId, pollId, { - option_id: firstOptionId, - }); + const { vote } = await chatClient.castPollVote( + { message_id: messageId, poll_id: pollId }, + { vote: { option_id: firstOptionId } }, + ); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(pollId); const response = await pollInstance?.castVote(optionId, messageId); // #endregion snippet - expect(vote.option_id).toBe(firstOptionId); - expect(response?.vote.option_id).toBe(optionId); + expect(vote?.option_id).toBe(firstOptionId); + expect(response?.vote?.option_id).toBe(optionId); const serverPoll = await getServerPoll(pollId); expect(serverPoll.vote_counts_by_option).toEqual({ [firstOptionId]: 1, @@ -196,17 +204,18 @@ describe(DOCS, () => { const answerText = 'Somewhere sunny'; // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Send an answer (if answers are configured to be allowed)" tab="JavaScript" index=1 - const { vote } = await chatClient.castPollVote(messageId, pollId, { - answer_text: 'some-option-id-328904342', - }); + const { vote } = await chatClient.castPollVote( + { message_id: messageId, poll_id: pollId }, + { vote: { answer_text: 'some-option-id-328904342' } }, + ); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(pollId); const response = await pollInstance?.addAnswer(answerText, messageId); // #endregion snippet - expect((vote as PollAnswer).answer_text).toBe('some-option-id-328904342'); - expect((response?.vote as PollAnswer | undefined)?.answer_text).toBe(answerText); + expect(vote?.answer_text).toBe('some-option-id-328904342'); + expect(response?.vote?.answer_text).toBe(answerText); // Adding an answer replaces the previous one. const serverPoll = await getServerPoll(pollId); expect(serverPoll.answers_count).toBe(1); @@ -218,23 +227,31 @@ describe(DOCS, () => { it('removes votes', async () => { const { messageId, pollId, optionIds } = await sendPoll(); const chatClient = client; - const { - vote: { id: voteId }, - } = await chatClient.castPollVote(messageId, pollId, { option_id: optionIds[0] }); - const { - vote: { id: anotherVoteId }, - } = await chatClient.castPollVote(messageId, pollId, { option_id: optionIds[1] }); + const castVote = async (option_id: string) => { + const { vote } = await chatClient.castPollVote( + { message_id: messageId, poll_id: pollId }, + { vote: { option_id } }, + ); + if (!vote) throw new Error('castPollVote returned no vote'); + return vote.id; + }; + const voteId = await castVote(optionIds[0]); + const anotherVoteId = await castVote(optionIds[1]); await chatClient.polls.getPoll(pollId); // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Removing a vote" tab="JavaScript" index=1 - const { vote } = await chatClient.removePollVote(messageId, pollId, voteId); + const { vote } = await chatClient.deletePollVote({ + message_id: messageId, + poll_id: pollId, + vote_id: voteId, + }); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(pollId); const response = await pollInstance?.removeVote(anotherVoteId, messageId); // #endregion snippet - expect(vote.id).toBe(voteId); + expect(vote?.id).toBe(voteId); expect(response).toBeDefined(); const serverPoll = await getServerPoll(pollId); expect(serverPoll.vote_count).toBe(0); @@ -244,22 +261,28 @@ describe(DOCS, () => { const { pollId } = await sendPoll(); const chatClient = client; await chatClient.polls.getPoll(pollId); - const closePoll = vi.spyOn(chatClient, 'closePoll'); + const updatePollPartial = vi.spyOn(chatClient, 'updatePollPartial'); try { // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Closing a poll" tab="JavaScript" index=1 - await chatClient.closePoll(pollId); + await chatClient.updatePollPartial( + { poll_id: pollId }, + { set: { is_closed: true } }, + ); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(pollId); await pollInstance?.close(); // #endregion snippet - expect(closePoll).toHaveBeenCalled(); + expect(updatePollPartial).toHaveBeenCalledWith( + { poll_id: pollId }, + { set: { is_closed: true } }, + ); expect(pollInstance?.data.is_closed).toBe(true); expect((await getServerPoll(pollId)).is_closed).toBe(true); } finally { - closePoll.mockRestore(); + updatePollPartial.mockRestore(); } }); @@ -268,7 +291,7 @@ describe(DOCS, () => { const chatClient = client; // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Retrieving a poll" tab="JavaScript" index=1 - const poll = await chatClient.getPoll(pollId); + const poll = await chatClient.getPoll({ poll_id: pollId }); // or if you're using PollManager await chatClient.polls.getPoll(pollId); @@ -295,12 +318,12 @@ describe(DOCS, () => { { id: option1Id, text: 'Amsterdam, The Netherlands', - foo: 'bar', + custom: { foo: 'bar' }, }, { id: option2Id, text: 'Boulder, CO', - foo: 'baz', + custom: { foo: 'baz' }, }, ], }); @@ -308,24 +331,27 @@ describe(DOCS, () => { expect(updatedPoll.poll.name).toBe('Where should we not go to?'); expect(updatedPoll.poll.options).toMatchObject([ - { id: option1Id, text: 'Amsterdam, The Netherlands', foo: 'bar' }, - { id: option2Id, text: 'Boulder, CO', foo: 'baz' }, + { id: option1Id, text: 'Amsterdam, The Netherlands', custom: { foo: 'bar' } }, + { id: option2Id, text: 'Boulder, CO', custom: { foo: 'baz' } }, ]); }); it('partially updates a poll', async () => { - const { pollId } = await sendPoll({ custom_property: 'value' }); + const { pollId } = await sendPoll({ custom: { custom_property: 'value' } }); const chatClient = client; // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Partial update" tab="JavaScript" index=1 - const updatedPoll = await chatClient.partialUpdatePoll(pollId, { - set: { name: 'Where should we not go to?' }, - unset: ['custom_property'], - }); + const updatedPoll = await chatClient.updatePollPartial( + { poll_id: pollId }, + { + set: { name: 'Where should we not go to?' }, + unset: ['custom_property'], + }, + ); // #endregion snippet expect(updatedPoll.poll.name).toBe('Where should we not go to?'); - expect(updatedPoll.poll).not.toHaveProperty('custom_property'); + expect(updatedPoll.poll.custom).not.toHaveProperty('custom_property'); }); it('deletes polls', async () => { @@ -335,7 +361,7 @@ describe(DOCS, () => { await chatClient.polls.getPoll(anotherPollId); // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Deleting a poll" tab="JavaScript" index=1 - await chatClient.deletePoll(pollId); + await chatClient.deletePoll({ poll_id: pollId }); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(anotherPollId); @@ -353,9 +379,10 @@ describe(DOCS, () => { await chatClient.polls.getPoll(pollId); // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="**Add poll option**" tab="JavaScript" index=1 - const pollOption = await chatClient.createPollOption(pollId, { - text: 'Another option', - }); + const pollOption = await chatClient.createPollOption( + { poll_id: pollId }, + { text: 'Another option' }, + ); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(pollId); @@ -379,11 +406,14 @@ describe(DOCS, () => { await chatClient.polls.getPoll(pollId); // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Update poll option" tab="JavaScript" index=1 - const updatedPollOption = await chatClient.updatePollOption(pollId, { - id: optionId, - text: 'Updated option', - my_custom_property: 'my_custom_value', - }); + const updatedPollOption = await chatClient.updatePollOption( + { poll_id: pollId }, + { + id: optionId, + text: 'Updated option', + custom: { my_custom_property: 'my_custom_value' }, + }, + ); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(pollId); @@ -396,7 +426,7 @@ describe(DOCS, () => { expect(updatedPollOption.poll_option).toMatchObject({ id: optionId, text: 'Updated option', - my_custom_property: 'my_custom_value', + custom: { my_custom_property: 'my_custom_value' }, }); expect(pollOption?.poll_option).toMatchObject({ id: optionId, @@ -413,7 +443,7 @@ describe(DOCS, () => { await chatClient.polls.getPoll(pollId); // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Delete poll option" tab="JavaScript" index=1 - await chatClient.deletePollOption(pollId, optionId); + await chatClient.deletePollOption({ poll_id: pollId, option_id: optionId }); // or if you're using the reactive poll instance const pollInstance = chatClient.polls.fromState(pollId); @@ -432,15 +462,18 @@ describe(DOCS, () => { const [option1Id, option2Id, option3Id] = optionIds; const chatClient = client; for (const option_id of [option1Id, option2Id, option3Id]) { - await chatClient.castPollVote(messageId, poll.id, { option_id }); + await chatClient.castPollVote( + { message_id: messageId, poll_id: poll.id }, + { vote: { option_id } }, + ); } // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Querying votes" tab="JavaScript" index=1 // retrieve all votes on either option1Id or option2Id - const filter = { - option_id: { $in: [option1Id, option2Id] }, - }; - const { votes } = await chatClient.queryPollVotes(poll.id, filter); + const { votes } = await chatClient.queryPollVotes( + { poll_id: poll.id }, + { filter: { option_id: { $in: [option1Id, option2Id] } } }, + ); // #endregion snippet expect(votes.map((vote) => vote.option_id).sort()).toEqual( @@ -451,19 +484,23 @@ describe(DOCS, () => { it('queries polls', async () => { const { pollId: closedPollId } = await sendPoll(); const { pollId: openPollId } = await sendPoll(); - await client.closePoll(closedPollId); + await client.updatePollPartial( + { poll_id: closedPollId }, + { set: { is_closed: true } }, + ); const chatClient = client; // #region snippet docs="chat/_default/05-features/07-polls_api.md" heading="Querying polls" tab="JavaScript" index=1 // retrieve all polls that are closed for voting sorted by created_at - const { polls } = await chatClient.queryPolls({ is_closed: true }, [ - { created_at: -1 }, - ]); + const { polls } = await chatClient.queryPolls({ + filter: { is_closed: true }, + sort: [{ field: 'created_at', direction: -1 }], + }); // or if you're using PollManager const { polls: pollInstances } = await chatClient.polls.queryPolls( { is_closed: true }, - [{ created_at: -1 }], + [{ field: 'created_at', direction: -1 }], ); // the returned polls will be ones with reactive state // #endregion snippet diff --git a/test/docs-snippets/pages/chat/05-features/08-location_sharing.test.ts b/test/docs-snippets/pages/chat/05-features/08-location_sharing.test.ts index 45406bf1fc..d1437d51be 100644 --- a/test/docs-snippets/pages/chat/05-features/08-location_sharing.test.ts +++ b/test/docs-snippets/pages/chat/05-features/08-location_sharing.test.ts @@ -27,7 +27,11 @@ describe('_default/05-features/08-location_sharing.md', () => { await serverClient.upsertUsers([{ id: otherId }]); client = await getClientSideClient({ id: userId }); cleanup.channels.push(`${channelType}:${channelId}`); - await client.channel(channelType, channelId, { members: [userId, otherId] }).create(); + await client + .channel(channelType, channelId, { + members: [{ user_id: userId }, { user_id: otherId }], + }) + .create(); // config_overrides can only be set server-side await serverClient.chat .channel(channelType, channelId) @@ -55,7 +59,7 @@ describe('_default/05-features/08-location_sharing.md', () => { }); // #endregion snippet - const { message } = await client.getMessage(messageId); + const { message } = await client.getMessage({ id: messageId }); expect(message.shared_location).toMatchObject({ latitude: 10, longitude: 10 }); expect(message.shared_location?.end_at).toBeUndefined(); }); @@ -72,7 +76,7 @@ describe('_default/05-features/08-location_sharing.md', () => { // Live location differs from the static location by the termination timestamp end_at. await channel.sendSharedLocation({ created_by_device_id: 'device-id', - end_at: '2225-07-22T09:30:12.507Z', + end_at: new Date(Date.now() + 60 * 60 * 1000), // 1 hour from now latitude: 10, longitude: 10, message_id: messageId, @@ -80,20 +84,23 @@ describe('_default/05-features/08-location_sharing.md', () => { // #endregion snippet } - const { message: started } = await client.getMessage(messageId); - expect(started.shared_location?.end_at).toMatch(/^2225-07-22T09:30:12/); + const { message: started } = await client.getMessage({ id: messageId }); + // `end_at` comes back as unix nanoseconds, about an hour from now. + const endAtMs = (started.shared_location?.end_at ?? 0) / 1e6; + expect(endAtMs).toBeGreaterThan(Date.now() + 50 * 60 * 1000); + expect(endAtMs).toBeLessThan(Date.now() + 70 * 60 * 1000); // #region snippet docs="chat/_default/05-features/08-location_sharing.md" heading="Updating live location" tab="JavaScript" index=1 // COPY: messageId="message-id" // simple call to update a location - await client.updateLocation({ + await client.updateLiveLocation({ latitude: 1, longitude: 2, message_id: messageId, }); // #endregion snippet - const { active_live_locations } = await client.getSharedLocations(); + const { active_live_locations } = await client.getUserLiveLocations(); expect( active_live_locations.find((location) => location.message_id === messageId), ).toMatchObject({ latitude: 1, longitude: 2 }); @@ -111,7 +118,7 @@ describe('_default/05-features/08-location_sharing.md', () => { } await retry(async () => { - const { active_live_locations: active } = await client.getSharedLocations(); + const { active_live_locations: active } = await client.getUserLiveLocations(); expect(active.map((location) => location.message_id)).not.toContain(messageId); }); }); @@ -151,6 +158,9 @@ describe('_default/05-features/08-location_sharing.md', () => { // to stop watching and reporting the manager subscriptions have cleaned up manager.unregisterSubscriptions(); + + // release the manager when it is no longer needed + manager.dispose(); // #endregion snippet expect(manager.stateIsReady).toBe(true); @@ -194,12 +204,16 @@ describe('_default/05-features/08-location_sharing.md', () => { const messageId = uniqueId('message'); await channel.sendSharedLocation({ created_by_device_id: 'device-id', - end_at: '2225-07-22T09:30:12.507Z', + end_at: new Date(Date.now() + 60 * 60 * 1000), latitude: 10, longitude: 10, message_id: messageId, }); - await client.updateLocation({ latitude: 1, longitude: 2, message_id: messageId }); + await client.updateLiveLocation({ + latitude: 1, + longitude: 2, + message_id: messageId, + }); await retry(() => { expect(seen).toContainEqual(['message.new', true]); @@ -207,8 +221,11 @@ describe('_default/05-features/08-location_sharing.md', () => { return Promise.resolve(); }); } finally { + // Remove the listeners registered here (the snippet doesn't keep the unsubscribes). + onSpy.mock.results.forEach((r) => { + if (r.type === 'return') r.value.unsubscribe(); + }); onSpy.mockRestore(); - channel.listeners = {}; } }); }); diff --git a/test/docs-snippets/pages/chat/05-features/09-translation.test.ts b/test/docs-snippets/pages/chat/05-features/09-translation.test.ts index 39356858e7..ecc0ed3526 100644 --- a/test/docs-snippets/pages/chat/05-features/09-translation.test.ts +++ b/test/docs-snippets/pages/chat/05-features/09-translation.test.ts @@ -33,7 +33,7 @@ describe(DOCS, () => { let spanishClient: StreamChat; let channel: Channel; const languageClient = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); /** Sends a poll from the English user in an auto-translated channel with a Spanish member. */ @@ -54,7 +54,7 @@ describe(DOCS, () => { }); const { message } = await client .channel(pollChannelType, pollChannelId) - .sendMessage({ text: 'Vote now!', poll_id: createdPoll.id }); + .sendMessage({ message: { text: 'Vote now!', poll_id: createdPoll.id } }); return message; }; @@ -72,7 +72,7 @@ describe(DOCS, () => { await waitForChannelTypePropagation(); client = await getClientSideClient({ id: userId, language: 'en' }); spanishClient = await getClientSideClient({ id: spanishUserId, language: 'es' }); - channel = client.channel('messaging', channelId, { members: [userId] }); + channel = client.channel('messaging', channelId, { members: [{ user_id: userId }] }); cleanup.channels.push(`messaging:${channelId}`); await channel.watch(); }); @@ -88,12 +88,17 @@ describe(DOCS, () => { try { // #region snippet docs="chat/_default/05-features/09-translation.md" heading="Message Translation Endpoint" tab="JavaScript" index=1 await channel.sendMessage({ - id: messageID, - text: 'Hello, I would like to have more information about your product.', + message: { + id: messageID, + text: 'Hello, I would like to have more information about your product.', + }, }); // returns the message.text translated into French - const response = await client.translateMessage(messageID, 'fr'); + const response = await client.translateMessage( + { id: messageID }, + { language: 'fr' }, + ); // the translation will be added to the i18n object console.log(response.message.i18n?.fr_text); @@ -128,9 +133,9 @@ describe(DOCS, () => { const languageUser = await getServerUser(serverClient, languageUserId); expect(languageUser.language).toBe('en'); - expect(languageClient.activeChannels[`messaging:${meltingPotId}`]?.initialized).toBe( - true, - ); + expect( + languageClient.channelManager.get(`messaging:${meltingPotId}`)?.initialized, + ).toBe(true); const meltingPot = await getServerChannel(serverClient, `messaging:${meltingPotId}`); expect(meltingPot.channel?.created_by?.id).toBe(languageUserId); }); @@ -138,7 +143,8 @@ describe(DOCS, () => { it('reads poll translations', async () => { const sent = await sendPollMessage(); // the Spanish member reads the message - const message: MessageResponse = (await spanishClient.getMessage(sent.id)).message; + const message: MessageResponse = (await spanishClient.getMessage({ id: sent.id })) + .message; const client = spanishClient; // #region snippet docs="chat/_default/05-features/09-translation.md" heading="Poll translation" tab="JavaScript" index=1 diff --git a/test/docs-snippets/pages/chat/05-features/10-advanced/11-slow_mode_and_throttling.test.ts b/test/docs-snippets/pages/chat/05-features/10-advanced/11-slow_mode_and_throttling.test.ts index 8e2672014b..46bb76e61f 100644 --- a/test/docs-snippets/pages/chat/05-features/10-advanced/11-slow_mode_and_throttling.test.ts +++ b/test/docs-snippets/pages/chat/05-features/10-advanced/11-slow_mode_and_throttling.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Message, StreamChat } from '../../../../../../src'; +import type { MessageRequest, StreamChat } from '../../../../../../src'; import { disconnectClients, getClientSideClient, @@ -31,7 +31,9 @@ describe('_default/05-features/10-advanced/11-slow_mode_and_throttling.md', () = it('enables and disables slow mode', async () => { const channelId = uniqueId('channel'); - const channel = client.channel('messaging', channelId, { members: [userId] }); + const channel = client.channel('messaging', channelId, { + members: [{ user_id: userId }], + }); cleanup.channels.push(`messaging:${channelId}`); await channel.create(); // Only admins and moderators can change the cooldown (a channel creator gets 403 code 17). @@ -52,7 +54,7 @@ describe('_default/05-features/10-advanced/11-slow_mode_and_throttling.md', () = // #endregion snippet const results = await Promise.all(enableSpy.mock.results.map((r) => r.value)); - expect(results.map((r) => r.channel.cooldown)).toEqual([1, 30]); + expect(results.map((r) => r.channel?.cooldown)).toEqual([1, 30]); expect(channel.data?.cooldown ?? 0).toBe(0); const serverChannel = await getServerChannel(serverClient, channel.cid); expect(serverChannel.channel?.cooldown ?? 0).toBe(0); @@ -73,13 +75,13 @@ describe('_default/05-features/10-advanced/11-slow_mode_and_throttling.md', () = // A regular member, so the cooldown applies. const channel = memberClient.channel('messaging', channelId); await channel.watch(); - const msg: Message = { text: 'Hello' }; + const message: MessageRequest = { text: 'Hello' }; const calls: Array<[string, number]> = []; const disableSendMessageUI = () => calls.push(['disable', Date.now()]); const enableSendMessageUI = () => calls.push(['enable', Date.now()]); // #region snippet docs="chat/_default/05-features/10-advanced/11-slow_mode_and_throttling.md" heading="Channel Slow Mode" tab="JavaScript" index=2 - const p = channel.sendMessage(msg); + const p = channel.sendMessage({ message }); const cooldown = channel.data?.cooldown; if (cooldown != null && cooldown > 0) { @@ -95,7 +97,9 @@ describe('_default/05-features/10-advanced/11-slow_mode_and_throttling.md', () = // #endregion snippet // The API rejects a second message during the cooldown. - await expect(channel.sendMessage({ text: 'Too soon' })).rejects.toThrow(); + await expect( + channel.sendMessage({ message: { text: 'Too soon' } }), + ).rejects.toThrow(); await retry(() => { expect(calls.map(([name]) => name)).toEqual(['disable', 'enable']); diff --git a/test/docs-snippets/pages/chat/05-features/10-advanced/12-drafts.test.ts b/test/docs-snippets/pages/chat/05-features/10-advanced/12-drafts.test.ts index c2cc6373e4..b590f1e8df 100644 --- a/test/docs-snippets/pages/chat/05-features/10-advanced/12-drafts.test.ts +++ b/test/docs-snippets/pages/chat/05-features/10-advanced/12-drafts.test.ts @@ -17,14 +17,18 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { const createChannel = async () => { const channelId = uniqueId('channel'); - const channel = client.channel('messaging', channelId, { members: [userId] }); + const channel = client.channel('messaging', channelId, { + members: [{ user_id: userId }], + }); cleanup.channels.push(`messaging:${channelId}`); await channel.create(); return channel; }; const sendParent = async (channel: Channel) => { - const { message } = await channel.sendMessage({ text: 'parent message' }); + const { message } = await channel.sendMessage({ + message: { text: 'parent message' }, + }); return message.id; }; @@ -44,18 +48,17 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { // #region snippet docs="chat/_default/05-features/10-advanced/12-drafts.md" heading="Creating a draft message" tab="JavaScript" index=1 const draft = await channel.createDraft({ - text: 'this is a draft message', + message: { text: 'this is a draft message' }, }); // Update the draft const updatedDraft = await channel.createDraft({ - text: 'this is an updated draft message', + message: { text: 'this is an updated draft message' }, }); // Create a draft on a thread const threadDraft = await channel.createDraft({ - text: 'this is a draft message', - parent_id: parentMessageId, + message: { text: 'this is a draft message', parent_id: parentMessageId }, }); // #endregion snippet @@ -74,8 +77,10 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { it('deletes a draft message', async () => { const channel = await createChannel(); const parentMessageId = await sendParent(channel); - await channel.createDraft({ text: 'channel draft' }); - await channel.createDraft({ text: 'thread draft', parent_id: parentMessageId }); + await channel.createDraft({ message: { text: 'channel draft' } }); + await channel.createDraft({ + message: { text: 'thread draft', parent_id: parentMessageId }, + }); // #region snippet docs="chat/_default/05-features/10-advanced/12-drafts.md" heading="Deleting a draft message" tab="JavaScript" index=1 // Channel draft @@ -95,8 +100,10 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { it('loads a draft message', async () => { const channel = await createChannel(); const parentMessageId = await sendParent(channel); - await channel.createDraft({ text: 'channel draft' }); - await channel.createDraft({ text: 'thread draft', parent_id: parentMessageId }); + await channel.createDraft({ message: { text: 'channel draft' } }); + await channel.createDraft({ + message: { text: 'thread draft', parent_id: parentMessageId }, + }); // #region snippet docs="chat/_default/05-features/10-advanced/12-drafts.md" heading="Loading a draft message" tab="JavaScript" index=1 // Channel draft @@ -117,9 +124,9 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { const channel2 = await createChannel(); const otherChannel = await createChannel(); // Sequential, so `created_at: -1` puts channel2's draft before channel1's. - await channel1.createDraft({ text: 'draft 1' }); - await channel2.createDraft({ text: 'draft 2' }); - await otherChannel.createDraft({ text: 'other draft' }); + await channel1.createDraft({ message: { text: 'draft 1' } }); + await channel2.createDraft({ message: { text: 'draft 2' } }); + await otherChannel.createDraft({ message: { text: 'other draft' } }); const channel1Cid = channel1.cid; const channel2Cid = channel2.cid; const querySpy = vi.spyOn(client, 'queryDrafts'); @@ -134,11 +141,13 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { filter: { channel_cid: { $in: [channel1Cid, channel2Cid] }, }, - sort: [{ created_at: -1 }], + sort: [{ field: 'created_at', direction: -1 }], }); // #endregion snippet - expect(querySpy.mock.calls[1][0]?.sort).toEqual([{ created_at: -1 }]); + expect(querySpy.mock.calls[1][0]?.sort).toEqual([ + { field: 'created_at', direction: -1 }, + ]); querySpy.mockRestore(); expect(response.drafts.map((d) => d.channel_cid)).toEqual( expect.arrayContaining([channel1Cid, channel2Cid, otherChannel.cid]), @@ -151,7 +160,7 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { // The opposite direction proves the sort is applied. const ascending = await client.queryDrafts({ filter: { channel_cid: { $in: [channel1Cid, channel2Cid] } }, - sort: [{ created_at: 1 }], + sort: [{ field: 'created_at', direction: 1 }], }); expect(ascending.drafts.map((d) => d.channel_cid)).toEqual([ channel1Cid, @@ -163,7 +172,9 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { const channel = await createChannel(); for (let i = 0; i < 7; i++) { const parentId = await sendParent(channel); - await channel.createDraft({ text: `thread draft ${i}`, parent_id: parentId }); + await channel.createDraft({ + message: { text: `thread draft ${i}`, parent_id: parentId }, + }); } // #region snippet docs="chat/_default/05-features/10-advanced/12-drafts.md" heading="Pagination" tab="JavaScript" index=1 @@ -201,7 +212,7 @@ describe('_default/05-features/10-advanced/12-drafts.md', () => { }); // #endregion snippet - await channel.createDraft({ text: 'draft for the event' }); + await channel.createDraft({ message: { text: 'draft for the event' } }); await retry(() => { expect(logSpy).toHaveBeenCalledWith('channel_cid', channel.cid); return Promise.resolve(); diff --git a/test/docs-snippets/pages/chat/05-features/10-advanced/13-private_messaging.test.ts b/test/docs-snippets/pages/chat/05-features/10-advanced/13-private_messaging.test.ts index 6d4eabd243..6c529942db 100644 --- a/test/docs-snippets/pages/chat/05-features/10-advanced/13-private_messaging.test.ts +++ b/test/docs-snippets/pages/chat/05-features/10-advanced/13-private_messaging.test.ts @@ -44,7 +44,9 @@ describe('_default/05-features/10-advanced/13-private_messaging.md', () => { client = await getClientSideClient({ id: userId }); cleanup.channels.push(`messaging:${channelId}`); await client - .channel('messaging', channelId, { members: [userId, janeId, bobId] }) + .channel('messaging', channelId, { + members: [{ user_id: userId }, { user_id: janeId }, { user_id: bobId }], + }) .create(); }); @@ -61,9 +63,11 @@ describe('_default/05-features/10-advanced/13-private_messaging.md', () => { // Send a message only visible to Jane const message = await channel.sendMessage({ - text: 'Hi Jane, your driver John will be at your location in 1 minute', - type: 'system', - restricted_visibility: [janeId], + message: { + text: 'Hi Jane, your driver John will be at your location in 1 minute', + type: 'system', + restricted_visibility: [janeId], + }, }); // #endregion snippet diff --git a/test/docs-snippets/pages/chat/05-features/10-advanced/15-pending_messages.test.ts b/test/docs-snippets/pages/chat/05-features/10-advanced/15-pending_messages.test.ts index c1056e4d6c..f3bba46bbd 100644 --- a/test/docs-snippets/pages/chat/05-features/10-advanced/15-pending_messages.test.ts +++ b/test/docs-snippets/pages/chat/05-features/10-advanced/15-pending_messages.test.ts @@ -23,7 +23,11 @@ describe('_default/05-features/10-advanced/15-pending_messages.md', () => { await serverClient.upsertUsers([{ id: otherId }]); client = await getClientSideClient({ id: userId }); cleanup.channels.push(`messaging:${channelId}`); - await client.channel('messaging', channelId, { members: [userId, otherId] }).create(); + await client + .channel('messaging', channelId, { + members: [{ user_id: userId }, { user_id: otherId }], + }) + .create(); // Pending messages can only be sent server-side. for (const text of ['pending 1', 'pending 2']) { @@ -50,7 +54,7 @@ describe('_default/05-features/10-advanced/15-pending_messages.md', () => { // #region snippet docs="chat/_default/05-features/10-advanced/15-pending_messages.md" heading="Querying pending messages" tab="JavaScript" index=1 // COPY: pendingMessageId="pending_message_id", pendingMessageId1="pending_message_id_1", pendingMessageId2="pending_message_id_2" // To retrieve a single message - const response = await client.getMessage(pendingMessageId); + const response = await client.getMessage({ id: pendingMessageId }); // To retrieve several messages from one channel const messages = await channel.getMessagesById([ @@ -65,7 +69,7 @@ describe('_default/05-features/10-advanced/15-pending_messages.md', () => { // Pending messages are only visible to their sender. const otherClient = await getClientSideClient({ id: otherId }); try { - await expect(otherClient.getMessage(pendingMessageId)).rejects.toThrow( + await expect(otherClient.getMessage({ id: pendingMessageId })).rejects.toThrow( /doesn't exist/, ); } finally { @@ -77,16 +81,19 @@ describe('_default/05-features/10-advanced/15-pending_messages.md', () => { // #region snippet docs="chat/_default/05-features/10-advanced/15-pending_messages.md" heading="Query channels" tab="JavaScript" index=1 // COPY: userId="thierry" // Querying multiple channels returns each channel's pending messages - const channels = await client.queryChannels( - { type: 'messaging', members: { $in: [userId] } }, - [{ last_message_at: -1 }], - ); + const channels = await client.queryChannelsAndHydrate({ + filter_conditions: { type: 'messaging', members: { $in: [userId] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + }); + + // Pending messages of the first channel + const pendingMessages = channels[0]?.state.pending_messages; // #endregion snippet - const channel = channels.find((c) => c.id === channelId); - expect(channel).toBeDefined(); - expect( - (channel?.state.pending_messages ?? []).map((p) => p.message.id).sort(), - ).toEqual([...pendingIds].sort()); + // The user's only channel is the one with the pending messages. + expect(channels.map((c) => c.id)).toEqual([channelId]); + expect((pendingMessages ?? []).map((p) => p.message?.id).sort()).toEqual( + [...pendingIds].sort(), + ); }); }); diff --git a/test/docs-snippets/pages/chat/12-best_practices/02-moderation.test.ts b/test/docs-snippets/pages/chat/12-best_practices/02-moderation.test.ts index 19f163d823..c67241925c 100644 --- a/test/docs-snippets/pages/chat/12-best_practices/02-moderation.test.ts +++ b/test/docs-snippets/pages/chat/12-best_practices/02-moderation.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { FlagMessageResponse, StreamChat } from '../../../../../src'; +import type { StreamChat } from '../../../../../src'; import { disconnectClients, getClientSideClient, @@ -12,6 +12,7 @@ import { getServerMember, sendServerMessage, } from '../../../helpers/server'; +import { retry } from '../../../helpers/wait'; const DOCS = '_default/12-best_practices/02-moderation.md'; @@ -38,6 +39,14 @@ describe(DOCS, () => { return message.id; }; + /** The flags on `messageId`'s review queue item (server-side). */ + const flagsOn = async (messageId: string) => { + const { items } = await serverClient.moderation.queryReviewQueue({ + filter: { entity_type: 'stream:chat:v1:message', entity_id: messageId }, + }); + return items.flatMap((item) => item.flags); + }; + beforeAll(async () => { cleanup.users.push(ownerId, adminId, jamesBondId, spammerId, ...blockTargets); await serverClient.upsertUsers( @@ -47,7 +56,7 @@ describe(DOCS, () => { adminClient = await getClientSideClient({ id: adminId, role: 'admin' }); const channel = client.channel('messaging', channelId, { - members: [ownerId, spammerId], + members: [{ user_id: ownerId }, { user_id: spammerId }], }); cleanup.channels.push(`messaging:${channelId}`); await channel.create(); @@ -60,7 +69,7 @@ describe(DOCS, () => { it('adds a member with the moderator role', async () => { const id = uniqueId('channel'); - const channel = client.channel('messaging', id, { members: [ownerId] }); + const channel = client.channel('messaging', id, { members: [{ user_id: ownerId }] }); cleanup.channels.push(`messaging:${id}`); await channel.create(); @@ -78,7 +87,7 @@ describe(DOCS, () => { it('enables and disables slow mode', async () => { const id = uniqueId('channel'); - const channel = client.channel('messaging', id, { members: [ownerId] }); + const channel = client.channel('messaging', id, { members: [{ user_id: ownerId }] }); cleanup.channels.push(`messaging:${id}`); await channel.create(); // Changing the cooldown needs UpdateChannelCooldown (a channel creator gets 403 code 17). @@ -99,7 +108,7 @@ describe(DOCS, () => { // #endregion snippet const results = await Promise.all(enableSpy.mock.results.map((r) => r.value)); - expect(results.map((r) => r.channel.cooldown)).toEqual([1, 30]); + expect(results.map((r) => r.channel?.cooldown)).toEqual([1, 30]); const serverChannel = await getServerChannel(serverClient, channel.cid); expect(serverChannel.channel?.cooldown ?? 0).toBe(0); }); @@ -109,40 +118,40 @@ describe(DOCS, () => { flaggedMessageIds.push(messageId); // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Flag" tab="JavaScript" index=1 + // A user's flag on a message is recorded once: flagging it again doesn't update the reason or custom data // Flag a message - const flag = await client.flagMessage(messageId); + const flag = await client.moderation.flagMessage(messageId); // Flag with a reason and custom data - const flagWithReason = await client.flagMessage(messageId, { - reason: 'spammy_user', + const flagWithReason = await client.moderation.flagMessage(messageId, 'spammy_user', { custom: { user_comment: 'This user is spamming.', }, }); // #endregion snippet - expect(flag.flag.target_message_id).toBe(messageId); - expect(flag.flag.user.id).toBe(ownerId); - expect(flagWithReason.flag.reason).toBe('spammy_user'); - expect(flagWithReason.flag.custom).toEqual({ - user_comment: 'This user is spamming.', + // Both flags land on the message's review queue item. + expect(flag.item_id).toEqual(expect.any(String)); + expect(flagWithReason.item_id).toBe(flag.item_id); + await retry(async () => { + const flags = await flagsOn(messageId); + // Only the first flag is recorded, without a reason. + expect(flags.map((f) => [f.user_id, f.reason])).toEqual([[ownerId, undefined]]); }); }); it('flags a message with reasons and custom data', async () => { const messageID = await sendSpamMessage('Buy more!'); flaggedMessageIds.push(messageID); - const flagSpy = vi.spyOn(client, 'flagMessage'); + const flagSpy = vi.spyOn(client.moderation, 'flagMessage'); // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Reasons & custom data" tab="JavaScript" index=1 + // A user's flag on a message is recorded once: flagging it again doesn't update the reason or custom data // flag with a reason - let flag = await client.flagMessage(messageID, { - reason: 'spammy_user', - }); + let flag = await client.moderation.flagMessage(messageID, 'spammy_user'); // flag with a reason and additional custom data - flag = await client.flagMessage(messageID, { - reason: 'spammy_user', + flag = await client.moderation.flagMessage(messageID, 'spammy_user', { custom: { user_comment: 'This user is spamming the homepage.', page: 'homepage', @@ -150,25 +159,28 @@ describe(DOCS, () => { }); // flag with only custom data - flag = await client.flagMessage(messageID, { + flag = await client.moderation.flagMessage(messageID, undefined, { custom: { page: 'homepage', }, }); // #endregion snippet - const [withReason, withReasonAndCustom]: FlagMessageResponse[] = await Promise.all( - flagSpy.mock.results.map((r) => r.value), - ); + const responses = await Promise.all(flagSpy.mock.results.map((r) => r.value)); flagSpy.mockRestore(); - expect(withReason.flag.reason).toBe('spammy_user'); - expect(withReasonAndCustom.flag.reason).toBe('spammy_user'); - expect(withReasonAndCustom.flag.custom).toEqual({ - user_comment: 'This user is spamming the homepage.', - page: 'homepage', + // All three flags land on the message's review queue item. + expect(responses.map((r) => r.item_id)).toEqual([ + flag.item_id, + flag.item_id, + flag.item_id, + ]); + await retry(async () => { + const flags = await flagsOn(messageID); + // Only the first flag is recorded: its reason, no custom data. + expect(flags.map((f) => [f.user_id, f.reason, f.custom])).toEqual([ + [ownerId, 'spammy_user', undefined], + ]); }); - expect(flag.flag.target_message_id).toBe(messageID); - expect(flag.flag.custom).toEqual({ page: 'homepage' }); }); it('queries flagged messages from the review queue', async () => { @@ -177,10 +189,10 @@ describe(DOCS, () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); try { // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Query Flagged Messages" tab="JavaScript" index=1 - const response = await client.moderation.queryReviewQueue( - { entity_type: 'stream:chat:v1:message' }, - [{ created_at: -1 }], - ); + const response = await client.moderation.queryReviewQueue({ + filter: { entity_type: 'stream:chat:v1:message' }, + sort: [{ field: 'created_at', direction: -1 }], + }); for (const item of response.items) { console.log(item.message?.id); @@ -230,7 +242,7 @@ describe(DOCS, () => { expect(before.blocks.map((b) => b.blocked_user_id)).toContain(blockedUser); // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Unblock user" tab="JavaScript" index=1 - await client.unBlockUser(blockedUser); + await client.unblockUser(blockedUser); // #endregion snippet // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="List of Blocked Users" tab="JavaScript" index=1 @@ -263,7 +275,7 @@ describe(DOCS, () => { // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Server Side" tab="JavaScript" index=2 // Unblocks a user on behalf of the connected user. try { - await chatClient.unBlockUser(userId); + await chatClient.unblockUser(userId); } catch (err) { console.log('Error unblocking user:', err); } diff --git a/test/docs-snippets/pages/chat/javascript/01-quick_start/01-plain_js_introduction.test.ts b/test/docs-snippets/pages/chat/javascript/01-quick_start/01-plain_js_introduction.test.ts index 7d748bcfa3..39c40bdcb9 100644 --- a/test/docs-snippets/pages/chat/javascript/01-quick_start/01-plain_js_introduction.test.ts +++ b/test/docs-snippets/pages/chat/javascript/01-quick_start/01-plain_js_introduction.test.ts @@ -44,7 +44,7 @@ describe(PAGE, () => { // #endregion snippet expect(client.userID).toBe(userId); - expect(client.wsConnection?.isHealthy).toBe(true); + expect(client.wsConnection.isHealthy).toBe(true); const user = await getServerUser(serverClient, userId); expect(user.name).toBe('Jim Lahey'); expect(user.image).toBe('https://i.imgur.com/fR9Jz14.png'); @@ -55,7 +55,7 @@ describe(PAGE, () => { // COPY: apiKey="{{ api_key }}", channelId="travel" const client = StreamChat.getInstance(apiKey); const channel = client.channel('messaging', channelId, { - name: 'Awesome channel about traveling', + custom: { name: 'Awesome channel about traveling' }, }); // fetch the channel state, subscribe to future updates @@ -63,21 +63,23 @@ describe(PAGE, () => { // #endregion snippet expect(client.userID).toBe(userId); - expect(state.channel.id).toBe(channelId); - expect(state.channel.name).toBe('Awesome channel about traveling'); + expect(state.channel?.id).toBe(channelId); + expect(state.channel?.custom.name).toBe('Awesome channel about traveling'); expect(channel.initialized).toBe(true); // #region snippet docs="chat/javascript/01-quick_start/01-plain_js_introduction.md" heading="Messages" tab="JavaScript" index=1 const text = 'I’m mowing the air Rand, I’m mowing the air.'; const response = await channel.sendMessage({ - text, - customField: '123', + message: { + text, + custom: { customField: '123' }, + }, }); // #endregion snippet expect(response.message.text).toBe(text); - expect(response.message.customField).toBe('123'); + expect(response.message.custom.customField).toBe('123'); const { message } = await serverClient.chat.getMessage({ id: response.message.id }); expect(message.custom.customField).toBe('123'); @@ -86,7 +88,9 @@ describe(PAGE, () => { // #region snippet docs="chat/javascript/01-quick_start/01-plain_js_introduction.md" heading="Events" tab="JavaScript" index=1 channel.on('message.new', (event) => { console.log('received a new message', event.message?.text); - console.log(`Now have ${channel.state.messages.length} stored in local state`); + console.log( + `Now have ${channel.messagePaginator.items?.length} stored in local state`, + ); }); // #endregion snippet @@ -102,7 +106,7 @@ describe(PAGE, () => { }); } finally { logSpy.mockRestore(); - channel.listeners = {}; + channel.listeners.clear(); } }); }); diff --git a/test/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts b/test/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts index 7cd2f8d96d..801ed7e839 100644 --- a/test/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts +++ b/test/docs-snippets/pages/platform/02-identity-and-access/04-multi-tenancy.test.ts @@ -79,18 +79,20 @@ describe('platform/02-identity-and-access/04-multi-tenancy.md', () => { it('queries channels by team', async () => { const client = globalClient; - const spy = vi.spyOn(client, 'queryChannels'); + const spy = vi.spyOn(client, 'queryChannelsAndHydrate'); let results: Channel[][]; try { // #region snippet docs="platform/02-identity-and-access/04-multi-tenancy.md" heading="Querying chat channels" tab="JavaScript" index=1 // query all channels from teams that user is a part of - await client.queryChannels({}); + await client.queryChannelsAndHydrate({ filter_conditions: {} }); // query all channels from all teams - await client.queryChannels({ team: {} }); + await client.queryChannelsAndHydrate({ filter_conditions: { team: {} } }); // query all channels with no teams - await client.queryChannels({ team: { $eq: null } }); + await client.queryChannelsAndHydrate({ + filter_conditions: { team: { $eq: null } }, + }); // #endregion snippet results = await Promise.all(spy.mock.results.map((result) => result.value)); } finally { diff --git a/test/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts b/test/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts index ded77a06dc..3f4236c07a 100644 --- a/test/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts +++ b/test/docs-snippets/pages/platform/03-events-and-notifications/01-webhooks.test.ts @@ -23,9 +23,11 @@ describe('platform/03-events-and-notifications/01-webhooks.md', () => { // #region snippet docs="platform/03-events-and-notifications/01-webhooks.md" heading="Request info" tab="JavaScript" index=1 client = new StreamChat(apiKey, { - axiosRequestConfig: { - headers: { - 'x-stream-ext': 'device-id=123', + client: { + axiosRequestConfig: { + headers: { + 'x-stream-ext': 'device-id=123', + }, }, }, }); @@ -37,9 +39,11 @@ describe('platform/03-events-and-notifications/01-webhooks.md', () => { requests.push(config); return config; }); - client.options.allowServerSideConnect = true; + client.config.setConfig('client', { allowServerSideConnect: true }); await client.connectUser({ id: userId }, createUserToken(userId)); - await client.queryChannels({ members: { $in: [userId] } }); + await client.queryChannelsAndHydrate({ + filter_conditions: { members: { $in: [userId] } }, + }); expect(requests.length).toBeGreaterThan(0); for (const request of requests) { diff --git a/test/docs-snippets/pages/platform/03-events-and-notifications/04-push-devices.test.ts b/test/docs-snippets/pages/platform/03-events-and-notifications/04-push-devices.test.ts index 80797c219c..6e5e11fdde 100644 --- a/test/docs-snippets/pages/platform/03-events-and-notifications/04-push-devices.test.ts +++ b/test/docs-snippets/pages/platform/03-events-and-notifications/04-push-devices.test.ts @@ -10,8 +10,8 @@ import { uniqueId } from '../../../helpers/ids'; // Devices are registered on a Firebase push provider of the test app, named by // STREAM_PUSH_PROVIDER_NAME (.env). Named providers make the app a multi-bundle config, so -// `addDevice` needs the name: without it, code 4 'push provider "" with type "firebase" not found'. -const PROVIDER = 'firebase'; +// `createDevice` needs the name: without it, code 4 'push provider "" with type "firebase" not found'. +const PROVIDER = 'firebase' as const; const PROVIDER_NAME = process.env.STREAM_PUSH_PROVIDER_NAME; /** Appended to the names of the tests that need a provider, so a skip says why. */ const needsProvider = PROVIDER_NAME @@ -41,25 +41,29 @@ describe('platform/03-events-and-notifications/04-push-devices.md', () => { const push_provider = 'apn'; // registers the device for the connected user - await client.addDevice(id, push_provider); + await client.createDevice({ id, push_provider }); // if multi providers - await client.addDevice(id, push_provider, undefined, push_provider_name); + await client.createDevice({ id, push_provider, push_provider_name }); // #endregion snippet - const { devices } = await client.getDevices(); - expect(devices?.map((device) => device.id)).toContain(id); + const { devices } = await client.listDevices(); + expect(devices.map((device) => device.id)).toContain(id); }); it.skipIf(!PROVIDER_NAME)( `lists the devices of the connected user${needsProvider}`, async () => { const deviceId = uniqueId('device'); - await client.addDevice(deviceId, PROVIDER, undefined, PROVIDER_NAME); + await client.createDevice({ + id: deviceId, + push_provider: PROVIDER, + push_provider_name: PROVIDER_NAME, + }); // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="Listing from the chat client SDKs" tab="JavaScript" index=1 // lists the devices of the connected user - const { devices } = await client.getDevices(); + const { devices } = await client.listDevices(); // #endregion snippet expect(devices).toEqual([ @@ -70,27 +74,26 @@ describe('platform/03-events-and-notifications/04-push-devices.md', () => { user_id: userId, }), ]); - await client.removeDevice(deviceId); + await client.deleteDevice({ id: deviceId }); }, ); it.skipIf(!PROVIDER_NAME)(`removes a device${needsProvider}`, async () => { - await client.addDevice( - '2ffca4ad6599adc9b5202d15a5286d33c19547d472cd09de44219cda5ac30207', - PROVIDER, - undefined, - PROVIDER_NAME, - ); - expect((await client.getDevices()).devices).toHaveLength(1); + await client.createDevice({ + id: '2ffca4ad6599adc9b5202d15a5286d33c19547d472cd09de44219cda5ac30207', + push_provider: PROVIDER, + push_provider_name: PROVIDER_NAME, + }); + expect((await client.listDevices()).devices).toHaveLength(1); // #region snippet docs="platform/03-events-and-notifications/04-push-devices.md" heading="Removing from the chat client SDKs" tab="JavaScript" index=1 const id = '2ffca4ad6599adc9b5202d15a5286d33c19547d472cd09de44219cda5ac30207'; // removes the device from the connected user - await client.removeDevice(id); + await client.deleteDevice({ id }); // #endregion snippet - const { devices } = await client.getDevices(); + const { devices } = await client.listDevices(); expect(devices).toEqual([]); }); }); diff --git a/test/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts b/test/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts index e9400b79b1..2872e8a367 100644 --- a/test/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts +++ b/test/docs-snippets/pages/platform/03-events-and-notifications/05-push-preferences.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { StreamChat } from '../../../../../src'; -import type { UpsertPushPreferencesResponse } from '../../../../../src'; +import { nsToMs, StreamChat } from '../../../../../src'; +import type { TimestampNS, UpsertPushPreferencesResponse } from '../../../../../src'; import { createUserToken, disconnectClients, @@ -12,7 +12,7 @@ import { uniqueId } from '../../../helpers/ids'; // Push delivery isn't checked: reading the saved preferences back is enough. User-level // preferences come back in the connect response (`me.push_preferences`), channel-member -// preferences on `channel.push_preferences` after `queryChannels`. +// preferences on `channel.push_preferences` after `queryChannelsAndHydrate`. describe('platform/03-events-and-notifications/05-push-preferences.md', () => { const serverClient = getServerClient(); const cleanup = new Cleanup(serverClient); @@ -26,7 +26,7 @@ describe('platform/03-events-and-notifications/05-push-preferences.md', () => { /** The user's own push preferences, as a fresh connection of the same user sees them. */ const getUserPreferences = async () => { const reader = new StreamChat(process.env.STREAM_API_KEY as string, { - allowServerSideConnect: true, + client: { allowServerSideConnect: true }, }); try { const response = await reader.connectUser({ id: userId }, createUserToken(userId)); @@ -38,17 +38,19 @@ describe('platform/03-events-and-notifications/05-push-preferences.md', () => { /** Channel-member push preferences of the connected user, by cid. */ const getChannelPreferences = async () => { - const channels = await client.queryChannels({ - cid: { $in: [general, announcements, urgentAlerts, socialChat] }, + const channels = await client.queryChannelsAndHydrate({ + filter_conditions: { + cid: { $in: [general, announcements, urgentAlerts, socialChat] }, + }, }); return Object.fromEntries( channels.map((channel) => [channel.cid, channel.push_preferences]), ); }; - const isAbout = (actual: string | undefined, expected: Date) => - actual !== undefined && - Math.abs(new Date(actual).getTime() - expected.getTime()) < 1000; + /** `disabled_until` comes back as a unix-nanosecond timestamp. */ + const isAbout = (actual: TimestampNS | undefined, expected: Date) => + actual !== undefined && Math.abs(nsToMs(actual) - expected.getTime()) < 1000; beforeAll(async () => { cleanup.users.push(userId); @@ -71,11 +73,13 @@ describe('platform/03-events-and-notifications/05-push-preferences.md', () => { it('sets user-level preferences', async () => { // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="User-Level Preferences" tab="JavaScript" index=1 // Set user-level push preferences for the current user - await client.setPushPreferences([ - { - chat_level: 'mentions', - }, - ]); + await client.updatePushNotificationPreferences({ + preferences: [ + { + chat_level: 'mentions', + }, + ], + }); // #endregion snippet expect((await getUserPreferences())?.chat_level).toBe('mentions'); @@ -85,16 +89,18 @@ describe('platform/03-events-and-notifications/05-push-preferences.md', () => { // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Channel-Member Preferences" tab="JavaScript" index=1 // COPY: general="messaging:general", announcements="messaging:announcements" // Set per-channel preferences for the current user - await client.setPushPreferences([ - { - channel_cid: general, - chat_level: 'none', - }, - { - channel_cid: announcements, - chat_level: 'all', - }, - ]); + await client.updatePushNotificationPreferences({ + preferences: [ + { + channel_cid: general, + chat_level: 'none', + }, + { + channel_cid: announcements, + chat_level: 'all', + }, + ], + }); // #endregion snippet const preferences = await getChannelPreferences(); @@ -106,17 +112,19 @@ describe('platform/03-events-and-notifications/05-push-preferences.md', () => { // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Client-Side Usage" tab="JavaScript" index=1 // COPY: general="messaging:general" // Client-side - can only update current user's preferences - await client.setPushPreferences([ - { - // user_id is optional and will be automatically set to current user - chat_level: 'mentions', - }, - { - // Set preferences for a specific channel - channel_cid: general, - chat_level: 'all', - }, - ]); + await client.updatePushNotificationPreferences({ + preferences: [ + { + // user_id is optional and will be automatically set to current user + chat_level: 'mentions', + }, + { + // Set preferences for a specific channel + channel_cid: general, + chat_level: 'all', + }, + ], + }); // #endregion snippet expect((await getUserPreferences())?.chat_level).toBe('mentions'); @@ -124,23 +132,27 @@ describe('platform/03-events-and-notifications/05-push-preferences.md', () => { }); it('creates a "Do Not Disturb" mode', async () => { - const spy = vi.spyOn(client, 'setPushPreferences'); + const spy = vi.spyOn(client, 'updatePushNotificationPreferences'); let responses: UpsertPushPreferencesResponse[]; try { // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="1: Creating a \"Do Not Disturb\" Mode" tab="JavaScript" index=1 // Disable all push notifications - await client.setPushPreferences([ - { - chat_level: 'none', - }, - ]); + await client.updatePushNotificationPreferences({ + preferences: [ + { + chat_level: 'none', + }, + ], + }); // Later, re-enable all push notifications - await client.setPushPreferences([ - { - chat_level: 'all', - }, - ]); + await client.updatePushNotificationPreferences({ + preferences: [ + { + chat_level: 'all', + }, + ], + }); // #endregion snippet responses = await Promise.all(spy.mock.results.map((result) => result.value)); } finally { @@ -157,20 +169,22 @@ describe('platform/03-events-and-notifications/05-push-preferences.md', () => { // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="2: Channel-Specific Notification Settings" tab="JavaScript" index=1 // COPY: general="messaging:general", urgentAlerts="messaging:urgent-alerts", socialChat="messaging:social-chat" // Set different preferences for different channels - await client.setPushPreferences([ - { - channel_cid: general, - chat_level: 'mentions', // Default: mentions only - }, - { - channel_cid: urgentAlerts, - chat_level: 'all', // Always notify for urgent alerts - }, - { - channel_cid: socialChat, - chat_level: 'none', // Never notify for social chat - }, - ]); + await client.updatePushNotificationPreferences({ + preferences: [ + { + channel_cid: general, + chat_level: 'mentions', // Default: mentions only + }, + { + channel_cid: urgentAlerts, + chat_level: 'all', // Always notify for urgent alerts + }, + { + channel_cid: socialChat, + chat_level: 'none', // Never notify for social chat + }, + ], + }); // #endregion snippet const preferences = await getChannelPreferences(); @@ -185,25 +199,29 @@ describe('platform/03-events-and-notifications/05-push-preferences.md', () => { // Disable push notifications for 2 hours for the current user const twoHoursFromNow = new Date(Date.now() + 2 * 60 * 60 * 1000); - await client.setPushPreferences([ - { - chat_level: 'all', - disabled_until: twoHoursFromNow.toISOString(), - }, - ]); + await client.updatePushNotificationPreferences({ + preferences: [ + { + chat_level: 'all', + disabled_until: twoHoursFromNow, + }, + ], + }); // Disable push notifications for a specific channel until tomorrow const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(9, 0, 0, 0); // 9 AM tomorrow - await client.setPushPreferences([ - { - channel_cid: general, - chat_level: 'all', - disabled_until: tomorrow.toISOString(), - }, - ]); + await client.updatePushNotificationPreferences({ + preferences: [ + { + channel_cid: general, + chat_level: 'all', + disabled_until: tomorrow, + }, + ], + }); // #endregion snippet const userPreferences = await getUserPreferences(); @@ -218,27 +236,31 @@ describe('platform/03-events-and-notifications/05-push-preferences.md', () => { // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Setting granular preferences" tab="JavaScript" index=1 // COPY: general="messaging:general" // User-level: only receive pushes for direct mentions and thread replies - await client.setPushPreferences([ - { - chat_preferences: { - default_preference: 'none', - direct_mentions: 'all', - thread_replies: 'all', + await client.updatePushNotificationPreferences({ + preferences: [ + { + chat_preferences: { + default_preference: 'none', + direct_mentions: 'all', + thread_replies: 'all', + }, }, - }, - ]); + ], + }); // Channel-member level: mute @channel and @here in a noisy channel - await client.setPushPreferences([ - { - channel_cid: general, - chat_preferences: { - default_preference: 'all', - channel_mentions: 'none', - here_mentions: 'none', + await client.updatePushNotificationPreferences({ + preferences: [ + { + channel_cid: general, + chat_preferences: { + default_preference: 'all', + channel_mentions: 'none', + here_mentions: 'none', + }, }, - }, - ]); + ], + }); // #endregion snippet const userPreferences = await getUserPreferences(); @@ -257,17 +279,19 @@ describe('platform/03-events-and-notifications/05-push-preferences.md', () => { }); it('switches from chat_preferences back to chat_level', async () => { - await client.setPushPreferences([ - { chat_preferences: { default_preference: 'none' } }, - ]); + await client.updatePushNotificationPreferences({ + preferences: [{ chat_preferences: { default_preference: 'none' } }], + }); // #region snippet docs="platform/03-events-and-notifications/05-push-preferences.md" heading="Switching between chat_level and chat_preferences" tab="JavaScript" index=1 // Switch from granular preferences back to a simple level - await client.setPushPreferences([ - { - chat_level: 'all_mentions', - }, - ]); + await client.updatePushNotificationPreferences({ + preferences: [ + { + chat_level: 'all_mentions', + }, + ], + }); // #endregion snippet const userPreferences = await getUserPreferences(); @@ -281,13 +305,15 @@ describe('platform/03-events-and-notifications/05-push-preferences.md', () => { // Set call-level preferences with temporary disabling const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000); - await client.setPushPreferences([ - { - user_id: userId, - call_level: 'all', - disabled_until: oneHourFromNow.toISOString(), - }, - ]); + await client.updatePushNotificationPreferences({ + preferences: [ + { + user_id: userId, + call_level: 'all', + disabled_until: oneHourFromNow, + }, + ], + }); // #endregion snippet const userPreferences = await getUserPreferences(); diff --git a/test/docs-snippets/setup.ts b/test/docs-snippets/setup.ts index 45eb31df9f..b8688815ca 100644 --- a/test/docs-snippets/setup.ts +++ b/test/docs-snippets/setup.ts @@ -1,4 +1,3 @@ -import { config } from 'dotenv'; import path from 'node:path'; import { afterAll, beforeAll } from 'vitest'; import { getServerClient } from './helpers/clients'; @@ -8,7 +7,12 @@ import { diffAppSnapshots, takeAppSnapshot } from './helpers/snapshot'; import type { AppSnapshot } from './helpers/snapshot'; import { retry } from './helpers/wait'; -config({ path: path.join(__dirname, '.env'), quiet: true }); +// Node's built-in `.env` loader; a missing file is left to the credentials check below. +try { + process.loadEnvFile(path.join(__dirname, '.env')); +} catch { + // no .env +} if (!process.env.STREAM_API_KEY || !process.env.STREAM_API_SECRET) { throw new Error( diff --git a/test/types/custom-events.ts b/test/types/custom-events.ts new file mode 100644 index 0000000000..189595e76c --- /dev/null +++ b/test/types/custom-events.ts @@ -0,0 +1,33 @@ +/** + * Compile-time contract for custom event types declared through `CustomEventTypes` augmentation. + * Type-checked, never executed. + * + * The augmentation is global to the program, so it also proves that `src/` still compiles once an + * app declares a custom event type (a bare `keyof CustomEventTypes` in the `Event` union used to + * break every `event.` read in the SDK). + */ +import type { Event, EventPayload, StreamChat } from 'stream-chat'; + +declare module 'stream-chat' { + interface CustomEventTypes { + friendship_request: true; + } + interface CustomEventData { + text?: string; + } +} + +declare const client: StreamChat; +declare const event: Event; + +export const eventType: string = event.type; + +client.on('friendship_request', (e) => { + const text: string | undefined = e.custom.text; + return text; +}); + +export const payload: EventPayload<'friendship_request'>['custom']['text'] = 'hi'; + +// @ts-expect-error -- an undeclared custom field is rejected +export const unknownField = ({} as EventPayload<'friendship_request'>).custom.unknown; diff --git a/test/types/custom-filters.ts b/test/types/custom-filters.ts new file mode 100644 index 0000000000..80cb21b123 --- /dev/null +++ b/test/types/custom-filters.ts @@ -0,0 +1,66 @@ +/** + * Compile-time contract for filters over augmented custom data. Type-checked, never executed. + * + * An object-valued custom field must not become a typed `custom.` filter key: it would + * conflict with the primitive `custom.${string}` index signature and break every filter type built + * from the custom data (the SDK's own search sources included). + */ +import type { ChannelFilters } from 'stream-chat'; + +declare module 'stream-chat' { + interface CustomChannelData { + filter_test_color?: string; + filter_test_detail?: { user_id: number; nested?: { label: string } }; + filter_test_count?: number; + filter_test_flag?: boolean; + filter_test_date?: Date; + filter_test_level?: 'low' | 'high'; + filter_test_tags?: string[]; + } +} + +export const primitiveField: ChannelFilters = { 'custom.filter_test_color': 'red' }; +export const nestedField: ChannelFilters = { 'custom.filter_test_detail.user_id': 1 }; + +// Every primitive kind keeps its own type, alongside object-valued fields on the same interface. +export const mixedFields: ChannelFilters = { + 'custom.filter_test_color': { $in: ['red', 'blue'] }, + 'custom.filter_test_count': { $gt: 1 }, + 'custom.filter_test_flag': true, + 'custom.filter_test_date': { $lt: new Date() }, + 'custom.filter_test_level': 'low', + 'custom.filter_test_detail.nested.label': { $autocomplete: 'x' }, +}; +export const nullableEq: ChannelFilters = { 'custom.filter_test_color': { $eq: null } }; +export const inLogical: ChannelFilters = { + $or: [ + { 'custom.filter_test_count': 1 }, + { 'custom.filter_test_detail.user_id': { $gte: 2 } }, + ], +}; +// Object- and array-valued fields fall back to the primitive index signature. +export const objectExists: ChannelFilters = { + 'custom.filter_test_detail': { $exists: true }, +}; +export const arrayContains: ChannelFilters = { + 'custom.filter_test_tags': { $contains: 'a' }, +}; + +// @ts-expect-error -- a typed primitive custom field rejects a value of another type +export const wrongType: ChannelFilters = { 'custom.filter_test_color': { a: 1 } }; +// @ts-expect-error -- a number field rejects a string operand +export const wrongNumber: ChannelFilters = { 'custom.filter_test_count': { $gt: 'x' } }; +// @ts-expect-error -- a boolean field rejects a string +export const wrongBoolean: ChannelFilters = { 'custom.filter_test_flag': 'true' }; +// @ts-expect-error -- a Date field rejects a string +export const wrongDate: ChannelFilters = { 'custom.filter_test_date': 'yesterday' }; +// @ts-expect-error -- a literal-union field rejects other strings +export const wrongLiteral: ChannelFilters = { 'custom.filter_test_level': 'medium' }; +export const wrongNested: ChannelFilters = { + // @ts-expect-error -- a nested path rejects an object value + 'custom.filter_test_detail.user_id': { a: 1 }, +}; +export const wrongInLogical: ChannelFilters = { + // @ts-expect-error -- typed fields are still checked inside logical operators + $or: [{ 'custom.filter_test_count': 'x' }], +}; diff --git a/test/types/team-filter.ts b/test/types/team-filter.ts new file mode 100644 index 0000000000..04f383051d --- /dev/null +++ b/test/types/team-filter.ts @@ -0,0 +1,15 @@ +/** + * Compile-time contract for the channel `team` filter. Type-checked, never executed. + * + * With multi-tenancy, an empty `team` filter queries across all teams, so `team: {}` must be + * accepted alongside the usual operators. Other keys must keep rejecting an empty object. + */ +import type { ChannelFilters } from 'stream-chat'; + +export const allTeams: ChannelFilters = { team: {} }; +export const noTeam: ChannelFilters = { team: { $eq: null } }; +export const oneTeam: ChannelFilters = { team: 'red' }; +export const someTeams: ChannelFilters = { team: { $in: ['red', 'blue'] } }; + +// @ts-expect-error -- only `team` gives `{}` a meaning +export const emptyType: ChannelFilters = { type: {} }; diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index f115d55366..d10c940d5f 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -4916,10 +4916,8 @@ describe('Channel provisional (created from members, without an id)', () => { it('refuses any other request before it is sent', async () => { const channel = fromMembers(); - await expect(channel.sendEvent({ event: { type: 'custom' } })).rejects.toThrow( - NO_ID, - ); - // a generated endpoint: it throws as it is called + // generated endpoints: they throw as they are called + expect(() => channel.sendEvent({ event: { type: 'custom' } })).toThrow(NO_ID); expect(() => channel.truncate()).toThrow(NO_ID); await expect(channel.queryMembers()).rejects.toThrow(NO_ID); await expect( @@ -5010,3 +5008,28 @@ describe('Channel query on a disposed instance', () => { expect(sendRequest).not.toHaveBeenCalled(); }); }); + +describe('Channel.updateAIState', () => { + it('sends the AI indicator fields under custom, where the API reads them', async () => { + const client = await getClientWithUser(); + const channel = client.channel('messaging', 'ai-channel'); + const sendEvent = vi.spyOn(client, 'sendEvent').mockResolvedValue({}); + + await channel.updateAIState('message-1', 'AI_STATE_THINKING', { ai_message: 'hi' }); + + expect(sendEvent).toHaveBeenCalledWith( + { id: 'ai-channel', type: 'messaging' }, + { + event: { + type: 'ai_indicator.update', + custom: { + ai_message: 'hi', + ai_state: 'AI_STATE_THINKING', + message_id: 'message-1', + }, + }, + }, + undefined, + ); + }); +}); diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 57941dade2..75c291785f 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -1629,6 +1629,33 @@ describe('StreamChat.queryThreads', () => { sinon.restore(); }); + + it('returns the registered instance of a thread, so activating it keeps it managed', async () => { + const client = await getClientWithUser(); + const parentMessage = generateMsg(); + const rawThread = generateThreadResponse( + mockChannelQueryResponse.channel, + parentMessage, + ); + sinon.stub(client, 'queryThreads').resolves({ threads: [rawThread] }); + sinon.stub(client, 'getThread').resolves({ thread: rawThread }); + + const { + threads: [registered], + } = await client.queryThreadsAndHydrate(); + registered.activate(); + + const { + threads: [requeried], + } = await client.queryThreadsAndHydrate(); + const fetched = await client.getThreadAndHydrate(parentMessage.id); + + expect(requeried).to.equal(registered); + expect(fetched).to.equal(registered); + expect(client.threads.get(parentMessage.id)).to.equal(registered); + + sinon.restore(); + }); }); describe('StreamChat.queryReactions', () => { diff --git a/test/unit/signing.test.js b/test/unit/signing.test.js index 10780f5c38..e2d892fc08 100644 --- a/test/unit/signing.test.js +++ b/test/unit/signing.test.js @@ -1,4 +1,4 @@ -import { UserFromToken } from '../../src'; +import { DevToken, StreamChat, UserFromToken } from '../../src'; import { describe, expect, it } from 'vitest'; @@ -22,4 +22,22 @@ describe('Signing', () => { expect(UserFromToken(token)).to.be.undefined; }); }); + + describe('DevToken', () => { + it('builds the same unsigned token as v9', () => { + expect(DevToken('amin')).to.equal( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYW1pbiJ9.devtoken', + ); + }); + + it('round-trips through UserFromToken, including non-Latin-1 ids', () => { + expect(UserFromToken(DevToken('amin'))).to.equal('amin'); + expect(() => DevToken('ünicode-✓')).not.to.throw(); + }); + + it('is exposed as client.devToken', () => { + const client = new StreamChat('key'); + expect(client.devToken('amin')).to.equal(DevToken('amin')); + }); + }); }); diff --git a/test/unit/utils.test.js b/test/unit/utils.test.js index 562aa4a009..d42ffffde0 100644 --- a/test/unit/utils.test.js +++ b/test/unit/utils.test.js @@ -40,7 +40,14 @@ describe('axiosParamsSerializer', () => { }, b: [1, 2, 3], }, - output: 'a=%7B%22b%22%3A1%2C%22c%22%3A2%2C%22d%22%3Anull%7D&b=%5B1%2C2%2C3%5D', + output: 'a=%7B%22b%22%3A1%2C%22c%22%3A2%2C%22d%22%3Anull%7D&b=1%2C2%2C3', + }, + { + input: { + ids: ['a', 'b'], + objects: [{ a: 1 }], + }, + output: 'ids=a%2Cb&objects=%5B%7B%22a%22%3A1%7D%5D', }, ]; it('should serialize params', () => { diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index bc21e5757a..ee71d840cf 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -68,7 +68,7 @@ Two things to know when rewriting call sites: 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` (removed outright — 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`. +`updateAppSettings`, `revokeUserToken`, `revokeUsersToken`, `testPushSettings`, `testSQSSettings`, `testSNSSettings`, `createToken`, 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` (removed outright — 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 @@ -453,6 +453,20 @@ The SDK itself gets channels through `client.channelManager.ensure()`, so a spy sees only your own calls; spy on `client.channelManager.ensure` (called with `{ type, id, data }`) to see every channel the client gets. +#### `client.setGuestUser` + +```ts +// v9 +client.setGuestUser(user: UserResponse); + +// v10 +client.setGuestUser(user: UserRequest, requestOptions?); +``` + +`user` is the generated `UserRequest` (`id`, plus optional `name`, `image`, `language`, `invisible`, +`privacy_settings` and `custom`), the shape `createGuest` sends. App custom fields go under `custom` +rather than at the root. + #### `client.setAnonymousUser` alias ```ts @@ -474,7 +488,7 @@ 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.dispatchEvent(event: Event); // Event union is now WSEvent | LocalEvent | CustomEventTypeUnion client.api.errorFromResponse(response); // moved to ApiClient // client.sendFile — REMOVED, and there is no `client.api.sendFile` either. Multipart encoding // now lives in `ApiClient.sendRequest`; use the upload methods below. @@ -746,7 +760,7 @@ import { verifySignature, verifyAndParseWebhook, parseSqs, parseSns } from 'stre // v10 — nothing to import. Move the handler to @stream-io/node-sdk. ``` -Also gone from `stream-chat`, from the same module: `verifySignature`, `CheckSignature`, `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, `InvalidWebhookError`, and `InvalidWebhookErrorMessages`. `signing.ts` now exports exactly one function, `UserFromToken` — the client-side JWT payload decoder. The JWT minting helpers (`JWTUserToken`, `JWTServerToken`, `DevToken`) are gone too. +Also gone from `stream-chat`, from the same module: `verifySignature`, `CheckSignature`, `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, `InvalidWebhookError`, and `InvalidWebhookErrorMessages`. `signing.ts` now exports `UserFromToken` (the client-side JWT payload decoder) and `DevToken` (the unsigned development token, also `client.devToken(userId)`; it needs no secret). The secret-signing JWT helpers (`JWTUserToken`, `JWTServerToken`) are gone. Webhook verification is inherently server-side work: it needs the API secret, which v10 refuses to hold. Port the handler to [`@stream-io/node-sdk`](https://github.com/GetStream/stream-node), which keeps the v9 method names on the client (`client.verifyWebhook`, `client.verifyAndParseWebhook`, `client.parseSqs`, `client.parseSns`) and takes the secret from construction. See [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md#webhook--sns--sqs) for the mapping table. @@ -783,7 +797,7 @@ Both are `@internal`. The consumer-visible effects: - `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). +- `channel.sendEvent(event)` — replaced by `channel.sendEvent(request: { event })`, the generated request (`event: { type, parent_id?, custom? }`). - `channel.getConfig()` — **REMOVED**. Use the `channel.serverConfig` **getter**, which returns the same value: this channel's server configuration (`ChannelConfigWithInfo`) — mostly type-level, but narrowed by the channel's own `config_overrides` where it has any. It is a property now, not a call — `channel.getConfig()?.uploads` becomes `channel.serverConfig?.uploads`. If you mock it in tests, note that `vi.fn()` cannot stand in for a getter. - **Not** to be confused with `channel.config`, which is new and different: the channel's _resolved_ configuration, where a handful of server flags have been combined with what you registered through `client.config`. See the table under "Composer & attachment shape" for which fields live where. @@ -821,10 +835,15 @@ local state. `channel.messageOperations.send` is the full send — see channel.sendEvent(event: Event); // v10 -channel.sendEvent(request: { event: Event }); -// Event now unions the generated WSEvent, the SDK-only LocalEvent, and keyof CustomEventTypes. +channel.sendEvent(request: { event: EventRequest }, requestOptions?); +// EventRequest = { type: string; parent_id?: string; custom?: CustomEventData } ``` +Event data goes under `custom` (`{ event: { type: 'friendship_request', custom: { text } } }`): top-level +fields other than `type` and `parent_id` are dropped by the API. The server stamps `created_at` and `user`. +It is the generated method, so on a channel without an id it throws synchronously (`isn't yet created`) +rather than returning a rejected promise. + #### `channel.search` ```ts @@ -936,12 +955,15 @@ channel.truncate(request?: TruncateChannelRequest); channel.acceptInvite(options?: UpdateChannelOptions); channel.rejectInvite(options?: UpdateChannelOptions); -// v10 — options type renamed -channel.acceptInvite(options?: ChannelUpdateOptions); -channel.rejectInvite(options?: ChannelUpdateOptions); +// v10 — options typed as the generated request +channel.acceptInvite(options?: UpdateChannelRequest, requestOptions?); +channel.rejectInvite(options?: UpdateChannelRequest, requestOptions?); ``` -`ChannelUpdateOptions` = `Omit`. +`options` is the full generated `UpdateChannelRequest`, so it still carries `message` (the system message +sent with the accept / reject), as v9's `UpdateChannelOptions` did. The other membership helpers +(`addMembers`, `inviteMembers`, …) take `ChannelUpdateOptions` (`Omit`), +because they take `message` as a separate positional argument. #### `channel.mute` / `channel.unmute` @@ -985,13 +1007,16 @@ 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` +// v10 — positional rename to `messageId` / `parentId`; keystroke / stopTyping drop `options` channel.muteStatus(); // same return shape channel.sendAction(messageId, formData); -channel.keystroke(parentId?, options?); -channel.stopTyping(parentId?, options?); +channel.keystroke(parentId?, requestOptions?); +channel.stopTyping(parentId?, requestOptions?); ``` +`keystroke` and `stopTyping` lost their `options?: { user_id }` parameter: sending typing events on behalf of +another user is server-side only. The second argument is now `requestOptions`. + #### `channel.markRead` / `channel.markAsReadRequest` **Semantic swap** — read carefully: @@ -1247,7 +1272,7 @@ 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. +Both still take `Event` — the union shape of `Event` itself changed (now `WSEvent | LocalEvent | CustomEventTypeUnion`), but the parameter type name did not. --- diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index e902445658..9cbb2d8b77 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -37,11 +37,11 @@ managers warn rather than fail. But 18 and 20 are no longer tested. See the note below on what this means if you deploy the WebSocket client on Node 18 or 20. - **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. -- Two barrels removed from the package root, one added: **`./events` and `./base64` are gone; `./logger` is new.** `./signing` survives with exactly one export left, `UserFromToken`. 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. +- Two barrels removed from the package root, one added: **`./events` and `./base64` are gone; `./logger` is new.** `./signing` survives with two exports left, `UserFromToken` and `DevToken`. 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. - **`connection.changed` is removed.** Connectivity is published as two reactive stores, `client.wsConnection.state` for this client's socket (or its long-poll, once `enableWSFallback` has switched to it) and `client.networkConnection.state` for the device's network. A handler for the event simply stops firing, with no compile error in plain JavaScript, and a "connection lost" banner has to hold a drop itself where the event used to. The socket's own `isHealthy` is unchanged; what moved is where you read it. See below. - **Watching waits instead of degrading.** A request that watches a channel or subscribes to presence is held until the WebSocket handshake produces the connection id the server keys that subscription by, rather than being sent without one and silently registering nothing. It throws only when no socket is open and none is being opened. An explicit `watch: false` is never held. See below. - **The WebSocket connect endpoint moved to `/api/v2/connect`.** The hello event is now `connection.ok` rather than `health.check`. The long-poll fallback works as in v9, against `/api/v2/longpoll`, with `enableWSFallback` moved into the `wsConnection` configuration and `client.defaultWSTimeoutWithFallback` gone. -- `Event` (type name) is kept, but its shape widened: `Event = (WSEvent & { received_at?: TimestampNS }) | LocalEvent | keyof CustomEventTypes`. `EventPayload<''>` narrows to a specific event. +- `Event` (type name) is kept, but its shape changed: `Event = (WSEvent & { received_at?: TimestampNS }) | LocalEvent | CustomEventTypeUnion`, where each key augmented into `CustomEventTypes` becomes an event object whose data sits under `custom` (typed by `CustomEventData`) instead of at the root. `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** (inline `Filters<{ … }>` on each request type) — previously-permissive filter objects may stop type-checking. Only one operator per field is allowed, and `null` is no longer a valid `$in` element. `QueryPollsFilters`, `QueryVotesFilters`, and `ReminderFilters` were the last hand-written holdouts and now derive from their request types too. - `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`. @@ -120,7 +120,7 @@ Any consumer doing `import { Campaign, Segment, ChannelBatchUpdater, EVENT_MAP, ### `./signing` is down to one export -The barrel is still there, but it holds a **single** function: `UserFromToken`. Everything else it used to carry — `JWTUserToken`, `JWTServerToken`, `DevToken`, `verifySignature`, `CheckSignature`, `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, `InvalidWebhookError`, `InvalidWebhookErrorMessages` — needed the API secret or a Node builtin, and went out with the server-side surface. See [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md). +The barrel is still there, but it holds only `UserFromToken` and `DevToken` (also exposed as `client.devToken(userId)`). Everything else it used to carry — `JWTUserToken`, `JWTServerToken`, `verifySignature`, `CheckSignature`, `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`, `parseEvent`, `InvalidWebhookError`, `InvalidWebhookErrorMessages` — needed the API secret or a Node builtin, and went out with the server-side surface. See [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md). `UserFromToken` itself changed implementation: it decodes the JWT payload with the global `atob` instead of the removed `base64-js` helpers. It runs on the `connectUser` path, so older React Native / Hermes targets — Hermes only gained `atob` / `btoa` around React Native 0.74 — must install a base64 polyfill before the first `connectUser`, or connecting throws `ReferenceError: atob is not defined`. Verify with `typeof atob` on the target rather than by version number; browsers, Node 16+, Bun, and Deno all have it natively. @@ -241,7 +241,14 @@ type LocalEvent = ( export type Event = | (WSEvent & { received_at?: number }) | LocalEvent - | keyof CustomEventTypes; + | CustomEventTypeUnion; + +// One event object per key augmented into `CustomEventTypes`; `never` when none are declared. +type CustomEventTypeUnion = { + [K in keyof CustomEventTypes]: { type: K } & Omit & { + received_at?: TimestampNS; + }; +}[keyof CustomEventTypes]; export type EventType = Event['type'] | 'all'; export type EventHandler = (event: Extract) => void; @@ -255,7 +262,7 @@ export type EventPayload = Extract< | 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`. | +| `import { Event } from 'stream-chat'` | still `import { Event } from 'stream-chat'` — the alias is retained; the union it resolves to is now `WSEvent \| LocalEvent \| CustomEventTypeUnion`. | | `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. | @@ -294,6 +301,32 @@ declare module 'stream-chat' { 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. +What changed is where a custom event's data lives. v9's `Event` spread `CustomEventData` over its root; +in v10 a custom event is the generated `CustomEvent` shape (`type`, `created_at`, `custom`), and its data +is under `custom`, typed by `CustomEventData`. That is also where the API reads it when you send one — +top-level fields other than `type` and `parent_id` are dropped: + +```ts +declare module 'stream-chat' { + interface CustomEventTypes { + friendship_request: true; + } + interface CustomEventData { + text?: string; + } +} + +// v9 +await channel.sendEvent({ type: 'friendship_request', text: 'hi' }); +client.on('friendship_request', (event) => event.text); + +// v10 +await channel.sendEvent({ + event: { type: 'friendship_request', custom: { text: 'hi' } }, +}); +client.on('friendship_request', (event) => event.custom.text); +``` + > **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. --- @@ -967,7 +1000,9 @@ query that lands before `activate()` reuses it rather than creating a second one has only its parent message, so it starts stale and loads its thread data once, when it is first activated. `client.threads.get(id)` only looks a thread up, and `client.threads.paginator.getItem(id)` answers -whether the list holds it. +whether the list holds it. `client.queryThreadsAndHydrate()` and `client.getThreadAndHydrate()` also +return the registered instance for a thread `client.threads` already holds, rather than a second +`Thread` for the same id, so the instance you get is the one that receives events. A thread no longer listens to the client itself: `client.threads` hands each thread event to the threads it holds, the way `client.polls` does for polls. A thread therefore gets events only once @@ -1487,7 +1522,7 @@ For any of these that survive as a generated shape, the replacement is the gener For each source file that touches the SDK: -1. **Delete removed imports.** `EVENT_MAP`, `Campaign*`, `Segment*`, `ChannelBatchUpdater`, `encodeBase64`, `decodeBase64`, the webhook / JWT helpers from `./signing` (`verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature`, `CheckSignature`, `InvalidWebhookError`, `JWTUserToken`, `JWTServerToken`, `DevToken`), `Role` (rename), `MessageResponseBase`, `FormatMessageResponse`, `PredefinedFilterSort(Param)`, and any of the removed type utilities. `Event`, `CustomEventTypes`, and `UserFromToken` are kept — do not delete them. +1. **Delete removed imports.** `EVENT_MAP`, `Campaign*`, `Segment*`, `ChannelBatchUpdater`, `encodeBase64`, `decodeBase64`, the webhook / JWT helpers from `./signing` (`verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature`, `CheckSignature`, `InvalidWebhookError`, `JWTUserToken`, `JWTServerToken`), `Role` (rename), `MessageResponseBase`, `FormatMessageResponse`, `PredefinedFilterSort(Param)`, and any of the removed type utilities. `Event`, `CustomEventTypes`, `UserFromToken` and `DevToken` are kept — do not delete them. 2. **Rename `Role` → `RoleName`** at every import + annotation site. 3. **Rewrite event-handler callback types where needed.** `Event` is still valid (its union widened) — prefer `EventPayload<'…'>` for narrowed access. Custom event-type augmentation still goes on `CustomEventTypes`, unchanged from v9. Rename any imports of the plural `EventTypes` to the singular `EventType`. 4. **Guard `channel.state.membership` reads** with `?.` — it's `undefined` on freshly constructed channels. diff --git a/v9-to-v10-migration-guide-server-side.md b/v9-to-v10-migration-guide-server-side.md index e8a85fe793..c80316f92f 100644 --- a/v9-to-v10-migration-guide-server-side.md +++ b/v9-to-v10-migration-guide-server-side.md @@ -22,8 +22,8 @@ Any one of these means yes — this guide applies to you: - You construct `stream-chat` with a non-empty second positional argument: `new StreamChat(key, 'secret', ...)` — including via env vars like `process.env.STREAM_SECRET` / `STREAM_API_SECRET`. - You read `client.secret` or call `client._isUsingServerAuth()`. - You pass `client: { allowServerSideConnect: true }` as the client configuration. -- You call any admin method: `createToken`, `devToken`, `revokeUserToken(s)`, `updateAppSettings`, `getAppSettings`, `deleteUser`, `partialUpdateUser(s)`, `restoreUsers`, `deactivateUser(s)`, `reactivateUser(s)`, `exportUser(s)`, `createChannelType`, `updateChannelType`, `deleteChannelType`, `listChannelTypes`, `getChannelType`, `createCommand` / `updateCommand` / `deleteCommand` / `listCommands` / `getCommand`, `createRole` / `deleteRole` / `listRoles`, `createPermission` / `updatePermission` / `deletePermission` / `getPermission` / `listPermissions`, `createBlockList` / `updateBlockList` / `deleteBlockList` / `getBlockList` / `listBlockLists` / `importBlockList`, `upsertPushProvider` / `deletePushProvider` / `listPushProviders`, `checkPush` / `checkSNS` / `checkSQS`, `createImport` / `createImportURL` / `getImport` / `listImports`, retention-policy admin (`setRetentionPolicy` / `deleteRetentionPolicy` / `getRetentionPolicy` / `getRetentionPolicyRuns`), moderation config admin, campaign / segment methods. -- You call `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature`, or `CheckSignature` — the JWT helpers `JWTUserToken` / `JWTServerToken` / `DevToken` — these were all removed from `stream-chat/signing`. +- You call any admin method: `createToken`, `revokeUserToken(s)`, `updateAppSettings`, `getAppSettings`, `deleteUser`, `partialUpdateUser(s)`, `restoreUsers`, `deactivateUser(s)`, `reactivateUser(s)`, `exportUser(s)`, `createChannelType`, `updateChannelType`, `deleteChannelType`, `listChannelTypes`, `getChannelType`, `createCommand` / `updateCommand` / `deleteCommand` / `listCommands` / `getCommand`, `createRole` / `deleteRole` / `listRoles`, `createPermission` / `updatePermission` / `deletePermission` / `getPermission` / `listPermissions`, `createBlockList` / `updateBlockList` / `deleteBlockList` / `getBlockList` / `listBlockLists` / `importBlockList`, `upsertPushProvider` / `deletePushProvider` / `listPushProviders`, `checkPush` / `checkSNS` / `checkSQS`, `createImport` / `createImportURL` / `getImport` / `listImports`, retention-policy admin (`setRetentionPolicy` / `deleteRetentionPolicy` / `getRetentionPolicy` / `getRetentionPolicyRuns`), moderation config admin, campaign / segment methods. +- You call `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature`, or `CheckSignature` — the JWT helpers `JWTUserToken` / `JWTServerToken` — these were all removed from `stream-chat/signing`. (`DevToken`, which needs no secret, stays.) - You import from removed barrel paths: `stream-chat/dist/.../campaign`, `.../segment`, `.../channel_batch_updater`, `.../events`, `.../base64`. - You upload files from Node — `channel.sendFile(fs.createReadStream(...))`, `client.uploadFile(buffer, ...)`, or anything else that hands a `Buffer` / readable stream to the upload methods. See [Uploads from Node are gone](#uploads-from-node-are-gone). - You pass `user_id` overrides to per-user methods (`banUser`, `blockUser`, `muteUser`, `flagMessage`, `flagUser`) — those overrides are gone from `stream-chat@10` because they only made sense server-side. @@ -76,14 +76,13 @@ Only chat / user / moderation surfaces are enumerated here — the guide focuses ### Token generation -| v9 (`stream-chat`) | v10 (`@stream-io/node-sdk`) | Notes | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `client.createToken(userId, exp?, iat?)` | `client.generateUserToken({ user_id, exp?, iat?, validity_in_seconds? })` | Old name kept as deprecated `createToken(userId, exp?, iat?)`. | -| `client.createToken(userId)` (no expiry) | `client.generatePermanentUserToken({ user_id })` | Explicitly permanent — no `exp` claim. | -| `client.devToken(userId)` | _Not exposed._ Use `generateUserToken` in tests, or sign your own with a hardcoded secret. | Dev tokens were a v9-only shortcut. | -| `client.createCallToken(userId, cids)` | `client.generateCallToken({ user_id, call_cids, role?, exp?, iat?, validity_in_seconds? })` | Old name kept as deprecated `createCallToken(userIdOrObject, cids, exp?, iat?)`. | -| `client.revokeUserToken(id, before?)` / `revokeUsersToken(...)` | Not on node-sdk yet — mint with a fresh `iat` / rotate the app secret. | | -| `JWTUserToken(secret, userId, extra?, opts?)` / `JWTServerToken(secret, opts?)` / `DevToken(userId)` (formerly importable from `stream-chat` or `stream-chat/signing`) | _Removed from `stream-chat`._ Use `client.generateUserToken` etc. — the node SDK signs internally with the secret you passed at construction. | | +| v9 (`stream-chat`) | v10 (`@stream-io/node-sdk`) | Notes | +| ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `client.createToken(userId, exp?, iat?)` | `client.generateUserToken({ user_id, exp?, iat?, validity_in_seconds? })` | Old name kept as deprecated `createToken(userId, exp?, iat?)`. | +| `client.createToken(userId)` (no expiry) | `client.generatePermanentUserToken({ user_id })` | Explicitly permanent — no `exp` claim. | +| `client.createCallToken(userId, cids)` | `client.generateCallToken({ user_id, call_cids, role?, exp?, iat?, validity_in_seconds? })` | Old name kept as deprecated `createCallToken(userIdOrObject, cids, exp?, iat?)`. | +| `client.revokeUserToken(id, before?)` / `revokeUsersToken(...)` | Not on node-sdk yet — mint with a fresh `iat` / rotate the app secret. | | +| `JWTUserToken(secret, userId, extra?, opts?)` / `JWTServerToken(secret, opts?)` (formerly importable from `stream-chat` or `stream-chat/signing`) | _Removed from `stream-chat`._ Use `client.generateUserToken` etc. — the node SDK signs internally with the secret you passed at construction. | | ### Webhook / SNS / SQS @@ -309,7 +308,7 @@ await client.uploadFile({ - `client.secret` / `client._isUsingServerAuth()` — construct the client with a secret and check for its presence yourself if you must. - `client.validateServerSideAuth()` — the node SDK is always server-side. - Per-call `user_id` overrides on `banUser` / `blockUser` / `flagMessage` / `flagUser` — pass `user_id` in the request payload (all node-sdk mutations that act on behalf of a user take an explicit `user_id`). -- `JWTUserToken` / `JWTServerToken` / `DevToken` / `verifySignature` / `CheckSignature` / `InvalidWebhookError` / `InvalidWebhookErrorMessages` re-exported from `stream-chat` — all gone. `signing.ts` on `stream-chat` now exposes only `UserFromToken` (a client-side JWT decoder). Consume equivalents from `@stream-io/node-sdk`. +- `JWTUserToken` / `JWTServerToken` / `verifySignature` / `CheckSignature` / `InvalidWebhookError` / `InvalidWebhookErrorMessages` re-exported from `stream-chat` — all gone. `signing.ts` on `stream-chat` now exposes only `UserFromToken` (a client-side JWT decoder) and `DevToken`. Consume equivalents from `@stream-io/node-sdk`. - `stream-chat` runtime deps that used to underwrite the server surface — `jsonwebtoken`, `ws`, `isomorphic-ws`, `base64-js`, `form-data` — have been dropped from `package.json#dependencies`. `stream-chat@10` imports none of them; if a bundler still resolves one out of your tree, it is a stale lockfile, not the SDK. - `@types/jsonwebtoken` and `@types/ws` were v9 **runtime** dependencies, so their types leaked into your project for free. They are gone. Any of your own code annotated with `jwt.Secret`, `jwt.SignOptions`, `WebSocket.CloseEvent`, `WebSocket.Data` etc. now needs those packages in your own `devDependencies`. - The keep-alive `https.Agent` that v9 installed on the axios instance in Node — see [HTTP keep-alive is no longer configured for you](#http-keep-alive-is-no-longer-configured-for-you). @@ -592,7 +591,7 @@ Before shipping the migrated backend: - [ ] `client.generateUserToken({ user_id: 'demo' })` returns a JWT that `stream-chat`'s `connectUser` accepts against the same API key. - [ ] `client.verifyAndParseWebhook(rawBody, signature)` round-trips a captured production event. - [ ] An admin call (`client.chat.queryChannels(...)`, `client.upsertUsers(...)`) succeeds against the dashboard. -- [ ] All uses of `verifyWebhook` / `parseSqs` / `parseSns` / `createToken` / `JWTUserToken` / `DevToken` are imported from `@stream-io/node-sdk`, not `stream-chat`. +- [ ] All uses of `verifyWebhook` / `parseSqs` / `parseSns` / `createToken` / `JWTUserToken` are imported from `@stream-io/node-sdk`, not `stream-chat`. - [ ] No remaining imports from `stream-chat/dist/.../campaign`, `.../segment`, `.../channel_batch_updater`, `.../events`, `.../base64`. - [ ] If you kept a `stream-chat@10` client for WS: it is constructed **without** a secret, and `client.allowServerSideConnect: true` is set. - [ ] If you deploy on Node 18/20: `client.wsConnection.webSocketImpl` is wired to the `ws` package and `ws` is pinned in `dependencies`. On Node 22+, no `webSocketImpl` is needed.