From 47dc9f4b14c81c5be62b203076cbef569908b2a3 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 19 Aug 2026 13:37:56 -0500 Subject: [PATCH 01/17] fix: remove hand-written getPinnedMessages method --- src/channel.ts | 26 -------- src/gen/chat/ChannelApi.ts | 33 ++++++++++ src/gen/chat/ChatApi.ts | 60 +++++++++++++++++++ src/gen/model-decoders/decoders.ts | 7 +++ src/gen/models/index.ts | 20 +++++-- .../paginators/PinnedMessagePaginator.ts | 8 +-- src/types.ts | 18 ++---- .../paginators/PinnedMessagePaginator.test.ts | 4 ++ 8 files changed, 128 insertions(+), 48 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 667122e8e9..82d1ed458f 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -34,7 +34,6 @@ import type { EventPayload, EventType, FileUploadInput, - GetRepliesAPIResponse, LocalMessage, MarkReadRequest, MarkReadResponse, @@ -42,8 +41,6 @@ import type { MessageRequest, MessageResponse, MessageSetType, - PinnedMessagePaginationOptions, - PinnedMessagesSort, QueryMembersPayload, ReactionAPIResponse, ReactionRequest, @@ -1524,29 +1521,6 @@ export class Channel extends ChannelApi { return data; } - // TODO: find out v2 equivalent - /** - * List pinned messages of the channel. - * - * @param options - Pagination params, e.g. `{ limit: 10, id_lte: 10 }`. - * @param sort - Defines sorting direction of pinned messages (optional, defaults to `[]`). - * @returns A response with a list of messages. - */ - async getPinnedMessages( - options: PinnedMessagePaginationOptions, - sort: PinnedMessagesSort = [], - ) { - return await this.getClient().api.get( - this._channelURL() + '/pinned_messages', - { - payload: { - ...options, - sort, - }, - }, - ); - } - /** * List the reactions; supports pagination. * diff --git a/src/gen/chat/ChannelApi.ts b/src/gen/chat/ChannelApi.ts index 0b2fc43c53..01b0e608d2 100644 --- a/src/gen/chat/ChannelApi.ts +++ b/src/gen/chat/ChannelApi.ts @@ -9,6 +9,7 @@ import type { EventResponse, GetDraftResponse, GetManyMessagesResponse, + GetPinnedMessagesResponse, HideChannelRequest, HideChannelResponse, MarkReadRequest, @@ -20,6 +21,7 @@ import type { SendMessageResponse, ShowChannelRequest, ShowChannelResponse, + SortParamRequest, TruncateChannelRequest, TruncateChannelResponse, UpdateChannelPartialRequest, @@ -316,6 +318,37 @@ export class ChannelApi { ); } + getPinnedMessages( + request?: { + limit?: number; + offset?: number; + id_gte?: string; + id_gt?: string; + id_lte?: string; + id_lt?: string; + pinned_at_after_or_equal?: Date; + pinned_at_after?: Date; + pinned_at_before_or_equal?: Date; + pinned_at_before?: Date; + id_around?: string; + pinned_at_around?: Date; + sort?: Array; + member_custom_include?: Array; + }, + requestOptions?: StreamRequestOptions, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.getPinnedMessages( + { id: this.id, type: this.type, ...request }, + requestOptions, + ); + } + getOrCreate( request?: ChannelGetOrCreateRequest & { connection_id?: string }, requestOptions?: StreamRequestOptions, diff --git a/src/gen/chat/ChatApi.ts b/src/gen/chat/ChatApi.ts index 05367025e5..95bd3da9f2 100644 --- a/src/gen/chat/ChatApi.ts +++ b/src/gen/chat/ChatApi.ts @@ -35,6 +35,7 @@ import type { GetManyMessagesResponse, GetMessageResponse, GetOGResponse, + GetPinnedMessagesResponse, GetReactionsResponse, GetRepliesResponse, GetThreadResponse, @@ -1006,6 +1007,65 @@ export class ChatApi { return { ...response.body, metadata: response.metadata }; } + async getPinnedMessages( + request: { + type: string; + id: string; + limit?: number; + offset?: number; + id_gte?: string; + id_gt?: string; + id_lte?: string; + id_lt?: string; + pinned_at_after_or_equal?: Date; + pinned_at_after?: Date; + pinned_at_before_or_equal?: Date; + pinned_at_before?: Date; + id_around?: string; + pinned_at_around?: Date; + sort?: Array; + member_custom_include?: Array; + }, + requestOptions?: StreamRequestOptions, + ): Promise> { + const queryParams = { + limit: request?.limit, + offset: request?.offset, + id_gte: request?.id_gte, + id_gt: request?.id_gt, + id_lte: request?.id_lte, + id_lt: request?.id_lt, + pinned_at_after_or_equal: request?.pinned_at_after_or_equal, + pinned_at_after: request?.pinned_at_after, + pinned_at_before_or_equal: request?.pinned_at_before_or_equal, + pinned_at_before: request?.pinned_at_before, + id_around: request?.id_around, + pinned_at_around: request?.pinned_at_around, + sort: request?.sort, + member_custom_include: request?.member_custom_include, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'GET', + '/api/v2/chat/channels/{type}/{id}/pinned_messages', + pathParams, + queryParams, + undefined, + undefined, + requestOptions, + ); + + decoders['GetPinnedMessagesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + async getOrCreateChannel( request: ChannelGetOrCreateRequest & { type: string; diff --git a/src/gen/model-decoders/decoders.ts b/src/gen/model-decoders/decoders.ts index cad9ed3ecb..5d943bf236 100644 --- a/src/gen/model-decoders/decoders.ts +++ b/src/gen/model-decoders/decoders.ts @@ -943,6 +943,13 @@ decoders['GetMessageResponse'] = (input?: { [key: string]: any }) => { return decode(typeMappings, input); }; +decoders['GetPinnedMessagesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + messages: { type: 'MessageResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + decoders['GetReactionsResponse'] = (input?: { [key: string]: any }) => { const typeMappings: TypeMapping = { reactions: { type: 'ReactionResponse', isSingle: false }, diff --git a/src/gen/models/index.ts b/src/gen/models/index.ts index 33ce758da2..974e8b4ef1 100644 --- a/src/gen/models/index.ts +++ b/src/gen/models/index.ts @@ -1070,7 +1070,7 @@ export interface BulkActionAppealsRequest { reject_appeal?: RejectAppealRequestPayload; /** - * Configuration for restore action + * Configuration for restore action. State-aware: reverses whichever of a delete, a block, or a shadow block currently applies to the content (including both a delete and a block/shadow block at once). */ restore?: RestoreActionRequestPayload; @@ -1080,7 +1080,7 @@ export interface BulkActionAppealsRequest { unban?: UnbanActionRequestPayload; /** - * Configuration for unblock action + * Deprecated: use restore instead — it now also reverses a block or shadow block. Configuration for unblock action. */ unblock?: UnblockActionRequestPayload; } @@ -4597,6 +4597,18 @@ export interface GetOGResponse { giphy?: Images; } +export interface GetPinnedMessagesResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Messages + */ + messages: Array; +} + export interface GetReactionsResponse { duration: string; @@ -11371,7 +11383,7 @@ export interface SubmitActionRequest { reject_appeal?: RejectAppealRequestPayload; /** - * Configuration for restore action + * Configuration for restore action. State-aware: reverses whichever of a delete, a block, or a shadow block currently applies to the content (including both a delete and a block/shadow block at once). */ restore?: RestoreActionRequestPayload; @@ -11386,7 +11398,7 @@ export interface SubmitActionRequest { unban?: UnbanActionRequestPayload; /** - * Configuration for unblock action + * Deprecated: use restore instead — it now also reverses a block or shadow block. Configuration for unblock action. */ unblock?: UnblockActionRequestPayload; } diff --git a/src/pagination/paginators/PinnedMessagePaginator.ts b/src/pagination/paginators/PinnedMessagePaginator.ts index a2ed97fe36..e113b6ea7b 100644 --- a/src/pagination/paginators/PinnedMessagePaginator.ts +++ b/src/pagination/paginators/PinnedMessagePaginator.ts @@ -88,10 +88,10 @@ export class PinnedMessagePaginator extends MessageIntervalPaginator { this.config.doRequest = async ( options: MessageQueryShape, ): Promise<{ cursor?: PaginatorCursor; items: LocalMessage[] }> => { - const { messages } = await this.channel.getPinnedMessages( - options as PinnedMessagePaginationOptions, - [{ direction: 1, field: 'pinned_at' }], - ); + const { messages } = await this.channel.getPinnedMessages({ + ...(options as PinnedMessagePaginationOptions), + sort: pinnedAtSort, + }); const items = messages.map(formatMessage); return { cursor: this.getCursorFromQueryResults({ items }), items }; }; diff --git a/src/types.ts b/src/types.ts index ea3d9c7bb9..02ea6f2e13 100644 --- a/src/types.ts +++ b/src/types.ts @@ -339,20 +339,10 @@ export type MessagePaginationOptions = PaginationOptions & { id_around?: string; }; -export type PinnedMessagePaginationOptions = { - id_around?: string; - id_gt?: string; - id_gte?: string; - id_lt?: string; - id_lte?: string; - limit?: number; - offset?: number; - pinned_at_after?: string | Date; - pinned_at_after_or_equal?: string | Date; - pinned_at_around?: string | Date; - pinned_at_before?: string | Date; - pinned_at_before_or_equal?: string | Date; -}; +export type PinnedMessagePaginationOptions = Omit< + Parameters[0], + 'id' | 'member_custom_include' | 'sort' | 'type' +>; export type GetRepliesRequest = Parameters[0]; export type QueryMembersOptions = Partial>; diff --git a/test/unit/pagination/paginators/PinnedMessagePaginator.test.ts b/test/unit/pagination/paginators/PinnedMessagePaginator.test.ts index a4c7fc220f..588522dc60 100644 --- a/test/unit/pagination/paginators/PinnedMessagePaginator.test.ts +++ b/test/unit/pagination/paginators/PinnedMessagePaginator.test.ts @@ -46,6 +46,10 @@ describe('PinnedMessagePaginator', () => { await paginator.executeQuery(); expect(getPinnedMessages).toHaveBeenCalledTimes(1); + // sort travels inside the request object (generated ChannelApi signature), not as a 2nd arg + expect(getPinnedMessages).toHaveBeenCalledWith( + expect.objectContaining({ sort: [{ direction: 1, field: 'pinned_at' }] }), + ); expect(paginator.items?.map((m) => m.id)).toEqual(['a', 'b', 'c']); }); From d4ab78377f9d0bfdef49ad83ff21c58b35f3ee87 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 19 Aug 2026 16:13:30 -0500 Subject: [PATCH 02/17] fix: derive OwnUserBase from the generated user shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OwnUserBase` hand-listed the fields that exist on `OwnUserResponse` but not on `UserResponse`. `client._handleUserEvent` turns that list into a runtime lookup (`isOwnUserBaseProperty`) and uses it to decide which keys survive a `user.updated` event — so a field missing from the list is deleted off `client.user`. The list had drifted from the spec in both directions: it omitted `latest_hidden_channels` and carried a phantom `roles` that `OwnUserResponse` has never had. Deriving the type makes the two impossible to desynchronise. Also drops two stale `Omit` keys and one dead helper found alongside it. BREAKING CHANGES: * `Device`, `DeviceFields` and `BaseDeviceFields` are removed. Use the generated `DeviceResponse`. The shapes differ: `created_at` is `Date` (was `string` — the decoders always produced a `Date`, so the old annotation was wrong), `push_provider` widens to `string`, `user_id` is required, `provider` and `user` are gone, and `hardware_id` / `voip` are new. * `OwnUserBase` keeps its name but changes shape. It gains `latest_hidden_channels?: Array`, loses `roles?: string[]` (a field `OwnUserResponse` does not have — reads always returned `undefined`; the nearest real field is `teams_role`), types `devices` as `Array`, and drops `| null` from `total_unread_count_by_team`. * `channel._channelURL()` is removed with no replacement. It built a URL string for the hand-rolled request layer that no longer exists; nothing in the SDK called it. BEHAVIOUR FIX: * `client.user.latest_hidden_channels` is no longer deleted on every `user.updated` event for the connected user. Because a `user.updated` body is a plain `UserResponse` and the hand-written list omitted the field, it was pruned on every such event and read back as `undefined` regardless of server state. NON-BREAKING (both widen): * `ChannelUpdateOptions` no longer omits `'members'` from `UpdateChannelRequest` — that key does not exist on the request (it has `add_members` / `remove_members`), so the omit was a silent no-op. * `PinnedMessagePaginationOptions` no longer omits `'member_custom_include'`. The endpoint accepts it, so omitting it was narrowing the API. Co-Authored-By: Claude Opus 5 (1M context) --- src/channel.ts | 14 ------ src/types.ts | 52 +++++++---------------- src/utils.ts | 10 ++--- test/unit/client.test.js | 47 ++++++++++++++++++++ v9-to-v10-migration-guide-methods.md | 4 +- v9-to-v10-migration-guide-type-renames.md | 49 +++++++++++++++++++++ 6 files changed, 119 insertions(+), 57 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 82d1ed458f..c368f90008 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -2624,20 +2624,6 @@ export class Channel extends ChannelApi { ); }; - /** - * Returns the channel url. - * - * @returns The channel url. - */ - _channelURL = () => { - if (!this.id) { - throw new Error('channel id is not defined'); - } - return `${this.getClient().baseURL}/channels/${encodeURIComponent( - this.type, - )}/${encodeURIComponent(this.id)}`; - }; - _checkInitialized() { if (!this.initialized && !this.offlineMode) { throw Error( diff --git a/src/types.ts b/src/types.ts index 02ea6f2e13..0ec7505132 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,7 +14,6 @@ import type { ChannelConfigWithInfo, ChannelInput, ChannelMemberResponse, - ChannelMute, ChannelOwnCapability, ChannelResponse, ChannelStateResponseFields, @@ -26,8 +25,6 @@ import type { OwnUserResponse, PollResponseData, PollVoteResponseData, - PrivacySettingsResponse, - PushPreferencesResponse, QueryChannelsRequest, QueryMembersPayload, QueryPollsRequest, @@ -100,7 +97,7 @@ export type Flag = { user?: UserResponse; }; -export type ChannelUpdateOptions = Omit; +export type ChannelUpdateOptions = Omit; export type ConnectAPIResponse = Promise; @@ -174,20 +171,19 @@ export type UnmuteUserResponse = APIResponse & { non_existing_users?: string[]; }; -export type OwnUserBase = { - channel_mutes: ChannelMute[]; - devices: Device[]; - mutes: UserMuteResponse[]; - total_unread_count: number; - unread_channels: number; - unread_count: number; - unread_threads: number; - invisible?: boolean; - privacy_settings?: PrivacySettingsResponse; - push_preferences?: PushPreferencesResponse; - roles?: string[]; - total_unread_count_by_team?: Record | null; -}; +/** + * The fields that exist on the connected user (`OwnUserResponse`) but not on a plain + * `UserResponse` — i.e. the own-user-only slice of the user object. + * + * Derived, never hand-listed: `client._handleUserEvent` uses this set (through + * `isOwnUserBaseProperty`) to decide which keys survive a `user.updated` event, so a field + * missing from it is silently deleted off `client.user`. Deriving it means a spec change + * cannot desynchronise the two. + */ +export type OwnUserBase = Pick< + OwnUserResponse, + Exclude +>; export type ReactionAPIResponse = APIResponse & { message: MessageResponse; @@ -341,7 +337,7 @@ export type MessagePaginationOptions = PaginationOptions & { export type PinnedMessagePaginationOptions = Omit< Parameters[0], - 'id' | 'member_custom_include' | 'sort' | 'type' + 'id' | 'sort' | 'type' >; export type GetRepliesRequest = Parameters[0]; @@ -674,24 +670,6 @@ export type Configs = Record; export type ConnectionOpen = EventPayload<'health.check'> | EventPayload<'connection.ok'>; -export type Device = DeviceFields & { - provider?: string; - user?: UserResponse; - user_id?: string; -}; - -export type BaseDeviceFields = { - id: string; - push_provider: PushProvider; - push_provider_name?: string; -}; - -export type DeviceFields = BaseDeviceFields & { - created_at: string; - disabled?: boolean; - disabled_reason?: string; -}; - export type FirebaseConfig = { apn_template?: string; credentials_json?: string; diff --git a/src/utils.ts b/src/utils.ts index bf40bba31c..468621640d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -59,16 +59,16 @@ export function isOwnUserBaseProperty(property: string) { } = { channel_mutes: true, devices: true, + invisible: true, + latest_hidden_channels: true, mutes: true, + privacy_settings: true, + push_preferences: true, total_unread_count: true, + total_unread_count_by_team: true, unread_channels: true, unread_count: true, unread_threads: true, - invisible: true, - privacy_settings: true, - roles: true, - push_preferences: true, - total_unread_count_by_team: true, }; return ownUserBaseProperties[property as keyof OwnUserBase]; diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 5332eea91e..d42fdb143b 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -1415,6 +1415,53 @@ describe('user.updated propagates to message + pinned paginators', () => { }); }); +describe('user.updated preserves the own-user-only fields on client.user', () => { + let client; + + beforeEach(async () => { + client = await getClientWithUser({ id: 'own-user' }); + }); + + // Regression: `OwnUserBase` used to be a hand-maintained field list that had drifted from + // `OwnUserResponse` — it omitted `latest_hidden_channels`, so every `user.updated` event + // deleted that field off `client.user`. The list is derived now; this pins the behaviour. + it('keeps own-user fields the event body does not carry, and drops the rest', () => { + client.user = { + ...client.user, + // own-user-only — must all survive an event that omits them + channel_mutes: [], + devices: [], + invisible: false, + latest_hidden_channels: ['messaging:hidden'], + mutes: [], + privacy_settings: { read_receipts: { enabled: true } }, + push_preferences: {}, + total_unread_count: 3, + total_unread_count_by_team: { red: 1 }, + unread_channels: 1, + unread_count: 3, + unread_threads: 0, + // not an own-user field — the event omitting it means it was cleared server-side + image: 'https://example.com/old.png', + }; + client._user = { ...client.user }; + + client._handleClientEvent({ + type: 'user.updated', + user: { id: 'own-user', name: 'New Name' }, + }); + + expect(client.user.latest_hidden_channels).toEqual(['messaging:hidden']); + expect(client.user.total_unread_count).toBe(3); + expect(client.user.total_unread_count_by_team).toEqual({ red: 1 }); + expect(client.user.unread_threads).toBe(0); + expect(client.user.privacy_settings).toEqual({ read_receipts: { enabled: true } }); + expect(client.user.invisible).toBe(false); + expect(client.user.name).toBe('New Name'); + expect(client.user.image).toBeUndefined(); + }); +}); + describe('user.messages.deleted (client-level, cross-channel)', () => { let client; const bannedUser = { id: 'banned-user' }; diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index 01d02555ba..e6c71f6e88 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -656,7 +656,9 @@ Webhook verification is inherently server-side work: it needs the API secret, wh ### Constructor and lifecycle -`getClient()`, `getConfig()`, `clean()`, `_channelURL()`, `_checkInitialized()`, `_initializeState(...)`, `_disconnect()`, and `create(options?)` are unchanged. +`getClient()`, `getConfig()`, `clean()`, `_checkInitialized()`, `_initializeState(...)`, `_disconnect()`, and `create(options?)` are unchanged. + +`channel._channelURL()` — **REMOVED after `10.0.0-rc.4`**, no replacement. It built a `{baseURL}/channels/{type}/{id}` string for the hand-rolled request layer that no longer exists; every request now goes through the generated API client, which resolves its own paths. Nothing in the SDK called it. If you were using it to build a URL yourself, construct it inline. ### Removed with a rename → note diff --git a/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index b348386249..1c1cb67e56 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -146,6 +146,55 @@ These v9 names look like they'd be caught by the same rewrite pass but are **not - `PollResponse_old` — the only remaining `_old`-suffixed compat holder. Still used internally by `poll.ts` / `poll_manager.ts` / `offline-support/types.ts` and not a simple alias (it intersects the generated `PollResponseData` with a local `PollEnrichData` overlay). Its own story; do not rename here. - `AppSettings` is the only "settings"-ish name that IS renamed here (to `AppResponseFields`). Do not confuse it with `AppSettingsAPIResponse` (also removed; renamed to `GetApplicationResponse`) — they were two different aliases that shared a prefix. +## Removed after `10.0.0-rc.4` — convergence on the generated types + +Skip this section if you are upgrading from v9; everything below is already reflected in the tables above. It exists for integrations pinned to the `rc` dist-tag, because these types still shipped in `10.0.0-rc.4` and are deleted afterwards. **No back-compat alias remains for any of them.** + +Every removal here has the same rationale: the type restated something the OpenAPI generator already emits, so it was one more place a spec change had to be mirrored by hand — and several had already drifted from the spec they were copied from. + +### Own-user and device shapes + +| Removed after `rc.4` | Replacement | Detail | +| -------------------- | ---------------- | ----------------------------------------------------------- | +| `Device` | `DeviceResponse` | ⚠️ **Shape change.** See [below](#device--deviceresponse). | +| `DeviceFields` | `DeviceResponse` | Same — the v10 trio collapsed into the one generated shape. | +| `BaseDeviceFields` | `DeviceResponse` | Same. | + +`OwnUserBase` keeps its name but **changes shape**: it is now derived as `Pick>` rather than hand-listed. + +- **Gained** `latest_hidden_channels?: Array` — previously missing from the list, which caused a real defect (see below). +- **Lost** `roles?: string[]` — this field does not exist on `OwnUserResponse` at all. If you were reading it, the value was always `undefined`; the nearest real field is `teams_role?: Record`. +- `devices` is now `Array` instead of `Device[]`, and `total_unread_count_by_team` is `Record` instead of `Record | null`. + +#### Behaviour fix — `client.user.latest_hidden_channels` no longer disappears + +`client._handleUserEvent` prunes `client.user` on every `user.updated` event: any key that the event body does not carry, and that is not an own-user-only field, is deleted. It decided "own-user-only" from `OwnUserBase` via `isOwnUserBaseProperty()`. + +Because the hand-written list omitted `latest_hidden_channels`, and a `user.updated` event body is a plain `UserResponse` (which has no such field), **every `user.updated` event for the connected user deleted `client.user.latest_hidden_channels`**. Reading it after any user update returned `undefined` regardless of server state. Deriving the type fixes this; no call-site change is required. + +#### `Device` → `DeviceResponse` + +| Field | v10 `Device` (removed) | `DeviceResponse` | +| --------------------- | --------------------------------------------- | ------------------ | +| `created_at` | `string` | `Date` | +| `push_provider` | `'firebase' \| 'apn' \| 'huawei' \| 'xiaomi'` | `string` | +| `user_id` | `string \| undefined` | `string` | +| `provider`, `user` | present | **gone** | +| `hardware_id`, `voip` | **absent** | present (optional) | + +`created_at` is the one that bites: the response decoders have always produced a `Date` here, so the old `string` annotation was wrong. Call sites doing `new Date(device.created_at)` still work; ones doing `device.created_at.slice(...)` were already broken at runtime and now fail to compile. + +### Stale `Omit` keys — two types quietly widened + +Neither is a rename; both **gain** surface, so no call site breaks. + +| Type | Was | Now | +| -------------------------------- | ----------------------------------------------------------- | --------------------------------------- | +| `ChannelUpdateOptions` | `Omit` | `Omit` | +| `PinnedMessagePaginationOptions` | omits `'id' \| 'member_custom_include' \| 'sort' \| 'type'` | omits `'id' \| 'sort' \| 'type'` | + +`UpdateChannelRequest` has no `members` key (it has `add_members` / `remove_members`), so that omit was a no-op left over from an older payload shape. `member_custom_include` **is** accepted by `getPinnedMessages`, so omitting it was narrowing the API — it can now be passed through. + ## Verification After applying the renames, `yarn types` should pass. If a call site errors with `Cannot find name 'X'` where X is one of the v9 names in the left column, the rewrite is incomplete. From 9d590ff4d6f833d96ab51d1071b8e6d3b33c53e5 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 19 Aug 2026 16:18:46 -0500 Subject: [PATCH 03/17] refactor: drop type aliases orphaned by the server-side split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-one exported types in `types.ts` described admin surface that left this package when the server-side API moved to `@stream-io/node-sdk` — push-provider credentials, permission policies, blocklists, channel-type config. None had a reference anywhere in `src`, and no endpoint in this SDK returns them. Two more were restating a generated union rather than deriving from it, so they are now read off `ChannelConfigWithInfo` instead of deleted. BREAKING CHANGES: * Removed with no replacement: `APNConfig`, `AsyncModerationOptions`, `BlockList`, `CommandVariants`, `FirebaseConfig`, `GetRepliesRequest`, `GiphyVersions`, `HuaweiConfig`, `Policy`, `PolicyRequest`, `Product`, `PushProviderAPN`, `PushProviderCommon`, `PushProviderConfig`, `PushProviderFirebase`, `PushProviderHuawei`, `PushProviderID`, `PushProviderXiaomi`, `UR`, `VotesFiltersOptions`, `XiaomiConfig`. * `GetRepliesAPIResponse` is removed. Use the generated `GetRepliesResponse`. It was `APIResponse & { messages: MessageResponse[] }` with no reference in `src`; the generated shape is what `client.getReplies()` actually resolves to, wrapped in `StreamResponse<…>` so it also carries `metadata`. * `Product` was an `enum`, i.e. a runtime value in the bundle — not just a type. `import { Product } from 'stream-chat'` now fails at runtime, not only at compile time. Inline the string: `'chat'`, `'video'`, `'moderation'`, `'feeds'`. * `UR` (`Record`) was a v9 type utility with no remaining callers. Inline `Record`. * `Automod` and `AutomodBehavior` are NARROWED. They are now `ChannelConfigWithInfo['automod']` and `ChannelConfigWithInfo['automod_behavior']` — exactly `'disabled' | 'simple' | 'AI'` and `'flag' | 'block' | 'shadow_block'`. Both previously carried a `| (string & {})` tail, so they accepted any string and the documented values were a hint rather than a constraint. Assigning an arbitrary string now fails to compile. Reads of `channel.getConfig().automod` are unaffected. KEPT deliberately, despite having no reference in `src`: * `PushProvider` — derives from `CreateDeviceRequest['push_provider']` and names the union `client.createDevice()` accepts. * `ThreadFilters`, `TranslationLanguage` — derived aliases documented as v10 targets for v9 renames, and part of the `*Filters` family that derives per-endpoint operator constraints from the request types. Note: `test/typescript/unit-test.ts` still imports `PolicyRequest` and `UR`. That harness is already broken independently (it calls 32 client methods removed in the server-side split) and is out of scope for this PR. Co-Authored-By: Claude Opus 5 (1M context) --- src/types.ts | 180 +--------------------- v9-to-v10-migration-guide-type-renames.md | 35 ++++- 2 files changed, 37 insertions(+), 178 deletions(-) diff --git a/src/types.ts b/src/types.ts index 0ec7505132..d22899115f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,6 @@ import type { AxiosRequestConfig, AxiosResponse } from 'axios'; import type { StableWSConnection } from './connection'; -import type { - CustomChannelData, - CustomCommandData, - CustomEventTypes, -} from './custom_types'; +import type { CustomChannelData, CustomEventTypes } from './custom_types'; import type { NotificationManager } from './notifications'; import type { RESERVED_UPDATED_MESSAGE_FIELDS } from './constants'; import type { @@ -19,7 +15,6 @@ import type { ChannelStateResponseFields, CreateDeviceRequest, DraftPayloadResponse, - Images, MessageResponse, ModerationPayload, OwnUserResponse, @@ -65,8 +60,6 @@ export type RequireAtLeastOne = { [K in keyof T]-?: Required> & Partial>; }[keyof T]; -export type UR = Record; - export type Unpacked = T extends (infer U)[] ? U : T extends (...args: any[]) => infer U @@ -149,17 +142,6 @@ export type PartialThreadUpdate = { export type GetThreadOptions = Omit[0], 'message_id'>; -export enum Product { - Chat = 'chat', - Video = 'video', - Moderation = 'moderation', - Feeds = 'feeds', -} - -export type GetRepliesAPIResponse = APIResponse & { - messages: MessageResponse[]; -}; - export type MuteUserResponse = APIResponse & { mute?: UserMuteResponse; mutes?: Array; @@ -281,29 +263,10 @@ export type ChannelStateOptions = { withResponse?: boolean; }; -export type PolicyRequest = { - action: 'Deny' | 'Allow' | (string & {}); - /** - * @description User-friendly policy name - */ - name: string; - /** - * @description Whether policy applies to resource owner or not - */ - owner: boolean; - priority: number; - /** - * @description List of resources to apply policy to - */ - resources: string[]; - /** - * @description List of roles to apply policy to - */ - roles: string[]; -}; - -export type Automod = 'disabled' | 'simple' | 'AI' | (string & {}); -export type AutomodBehavior = 'flag' | 'block' | 'shadow_block' | (string & {}); +/** The channel-type automod mode, as reported by `channel.getConfig()`. */ +export type Automod = ChannelConfigWithInfo['automod']; +/** What automod does when it trips, as reported by `channel.getConfig()`. */ +export type AutomodBehavior = ChannelConfigWithInfo['automod_behavior']; export type MuteUserOptions = { client_id?: string; @@ -340,7 +303,6 @@ export type PinnedMessagePaginationOptions = Omit< 'id' | 'sort' | 'type' >; -export type GetRepliesRequest = Parameters[0]; export type QueryMembersOptions = Partial>; export type StreamChatOptions = { @@ -494,12 +456,6 @@ export type ChannelFilters = NonNullable; @@ -559,27 +515,6 @@ export type VoteSort = SortParamRequest[]; * Base Types */ -export type APNConfig = { - auth_key?: string; - auth_type?: string; - bundle_id?: string; - development?: boolean; - enabled?: boolean; - host?: string; - key_id?: string; - notification_template?: string; - p12_cert?: string; - team_id?: string; -}; - -export type AsyncModerationOptions = { - callback?: { - mode?: 'CALLBACK_MODE_NONE' | 'CALLBACK_MODE_REST' | 'CALLBACK_MODE_TWIRP'; - server_url?: string; - }; - timeout_ms?: number; -}; - // export type Attachment = ReplacePropertyTypes< // Attachment, // { custom: CustomAttachmentData & { file_size?: number; mime_type?: string } } @@ -587,17 +522,6 @@ export type AsyncModerationOptions = { export type OGAttachment = RequireLiteral; -export type BlockList = { - name: string; - words: string[]; - team?: string; - type?: string; - validate?: boolean; - is_confusable_folding_enabled?: boolean; - is_leet_check_enabled?: boolean; - is_plural_check_enabled?: boolean; -}; - export type ChannelData = ReplacePropertyTypes< ChannelInput, { custom: CustomChannelData } @@ -605,92 +529,10 @@ export type ChannelData = ReplacePropertyTypes< export type PushProvider = CreateDeviceRequest['push_provider']; -export type PushProviderConfig = PushProviderCommon & - PushProviderID & - PushProviderAPN & - PushProviderFirebase & - PushProviderHuawei & - PushProviderXiaomi; - -export type PushProviderID = { - name: string; - type: PushProvider; -}; - -export type PushProviderCommon = { - created_at: string; - updated_at: string; - description?: string; - disabled_at?: string; - disabled_reason?: string; -}; - -export type PushProviderAPN = { - apn_auth_key?: string; - apn_auth_type?: 'token' | 'certificate'; - apn_development?: boolean; - apn_host?: string; - apn_key_id?: string; - apn_notification_template?: string; - apn_p12_cert?: string; - apn_team_id?: string; - apn_topic?: string; -}; - -export type PushProviderFirebase = { - firebase_apn_template?: string; - firebase_credentials?: string; - firebase_data_template?: string; - firebase_notification_template?: string; - firebase_server_key?: string; -}; - -export type PushProviderHuawei = { - huawei_app_id?: string; - huawei_app_secret?: string; -}; - -export type PushProviderXiaomi = { - xiaomi_package_name?: string; - xiaomi_secret?: string; -}; - -export type CommandVariants = - | 'all' - | 'ban' - | 'fun_set' - | 'giphy' - | 'moderation_set' - | 'mute' - | 'unban' - | 'unmute' - | keyof CustomCommandData; - export type Configs = Record; export type ConnectionOpen = EventPayload<'health.check'> | EventPayload<'connection.ok'>; -export type FirebaseConfig = { - apn_template?: string; - credentials_json?: string; - data_template?: string; - enabled?: boolean; - notification_template?: string; - server_key?: string; -}; - -export type HuaweiConfig = { - enabled?: boolean; - id?: string; - secret?: string; -}; - -export type XiaomiConfig = { - enabled?: boolean; - package_name?: string; - secret?: string; -}; - export type MessageLabel = | 'deleted' | 'ephemeral' @@ -710,17 +552,6 @@ export type PermissionObject = { roles?: string[]; }; -export type Policy = { - action?: 0 | 1; - created_at?: string; - name?: string; - owner?: boolean; - priority?: number; - resources?: string[]; - roles?: string[] | null; - updated_at?: string; -}; - export type TokenOrProvider = null | string | TokenProvider | undefined; export type TokenProvider = () => Promise; @@ -958,7 +789,6 @@ export type DeleteMessageOptions = Omit[0], export type SendMessageAPIResponse = StreamResponse; export type UpdateMessageOptions = Omit; export type UpdateMessageAPIResponse = StreamResponse; -export type GiphyVersions = keyof Images; export type TranslationLanguage = TranslateMessageRequest['language']; export type FileReferenceBase = { diff --git a/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index 1c1cb67e56..139b577575 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -1,6 +1,6 @@ # v9 → v10 Migration Guide — Type Renames -> Scope: this guide covers **type aliases that were hand-rolled in `types.ts` and have been removed in v10 in favor of a differently-named type**. In most cases, the removed alias pointed at a type generated from the OpenAPI spec (imported from `./gen/models`), and the v10 target is that generated name — now re-exported directly from `stream-chat`. A handful of entries (`Automod`, `AutomodBehavior`, `TranslationLanguage`) resolve to a hand-authored alias in `src/types.ts` instead of a raw gen re-export; those are noted per row. Consumers should switch to the v10 name in every case. +> Scope: this guide covers **type aliases that were hand-rolled in `types.ts` and have been removed in v10 in favor of a differently-named type**. In most cases, the removed alias pointed at a type generated from the OpenAPI spec (imported from `./gen/models`), and the v10 target is that generated name — now re-exported directly from `stream-chat`. A handful of entries (`Automod`, `AutomodBehavior`, `TranslationLanguage`) resolve to a derived alias in `src/types.ts` — a lookup on a generated type rather than a raw re-export; those are noted per row. Consumers should switch to the v10 name in every case. > > This document is written for AI agents doing mechanical rewrites. Each entry lists the v9 name, the v10 name, and the file(s) where the type is exported from. All v10 names are still importable from the package root (`stream-chat`) or from `stream-chat/dist/types` — nothing has moved outside the package surface. > @@ -34,8 +34,8 @@ v10 exposes two generated types whose names collide with v9 aliases that pointed | `AppSettingsAPIResponse` | `GetApplicationResponse` | Return type of `client.getAppSettings()`. | | `AutomodDetails` | `AutomodDetailsResponse` | | | `ChannelAPIResponse` | `ChannelStateResponseFields` | The per-channel entry inside a `queryChannels` response (fields only, no top-level `duration`). | -| `ChannelConfigAutomod` | `Automod` | The v10 `Automod` is a hand-authored string union (`'disabled' \| 'simple' \| 'AI' \| (string & {})`) in `src/types.ts`, not a generated re-export. Values are identical to v9. | -| `ChannelConfigAutomodBehavior` | `AutomodBehavior` | Same story — hand-authored union (`'flag' \| 'block' \| 'shadow_block' \| (string & {})`) in `src/types.ts`. Values are identical to v9. | +| `ChannelConfigAutomod` | `Automod` | ⚠️ **Narrowed after `rc.4`.** `Automod` is now `ChannelConfigWithInfo['automod']` — exactly `'disabled' \| 'simple' \| 'AI'`. It previously carried a `\| (string & {})` tail that let any string through. | +| `ChannelConfigAutomodBehavior` | `AutomodBehavior` | ⚠️ **Narrowed after `rc.4`.** Now `ChannelConfigWithInfo['automod_behavior']` — exactly `'flag' \| 'block' \| 'shadow_block'`, without the `\| (string & {})` tail. | | `ChannelQueryOptions` | `ChannelGetOrCreateRequest` | Payload for `channel.watch()`, `channel.create()`, and `channel.query()`. The v9 alias masked the OpenAPI name; v10 uses the generated name directly. | | `CommandResponse` | `Command` | Slash-command descriptor — matches the shape stored under `channel.getConfig().commands`. | | `CreatePollData` | `CreatePollRequest` | Payload for `client.createPoll()` / `PollManager.createPoll()`. | @@ -195,6 +195,35 @@ Neither is a rename; both **gain** surface, so no call site breaks. `UpdateChannelRequest` has no `members` key (it has `add_members` / `remove_members`), so that omit was a no-op left over from an older payload shape. `member_custom_include` **is** accepted by `getPinnedMessages`, so omitting it was narrowing the API — it can now be passed through. +### Orphans of the server-side split — removed, no replacement + +These described admin/server-side surface (push-provider credentials, permission policies, blocklists, channel-type config) that moved to `@stream-io/node-sdk` when the server-side API left this package. Nothing in the SDK referenced them and no endpoint here returns them. + +`APNConfig`, `AsyncModerationOptions`, `BlockList`, `CommandVariants`, `FirebaseConfig`, `GetRepliesRequest`, `GiphyVersions`, `HuaweiConfig`, `Policy`, `PolicyRequest`, `Product`, `PushProviderAPN`, `PushProviderCommon`, `PushProviderConfig`, `PushProviderFirebase`, `PushProviderHuawei`, `PushProviderID`, `PushProviderXiaomi`, `UR`, `VotesFiltersOptions`, `XiaomiConfig`. + +Two notes: + +- **`Product` was an `enum`**, so it was a runtime value in the bundle, not just a type. `import { Product } from 'stream-chat'` fails at runtime now, not only at compile time. Inline the string (`'chat'`, `'video'`, `'moderation'`, `'feeds'`). +- **`UR`** (`Record`) was a v9 type utility that outlived its callers. It joins `Readable`, `KnownKeys`, `PartializeKeys` and `UnknownType` in [Type utilities dropped](./v9-to-v10-migration-guide-other.md#type-utilities-dropped) — inline `Record`. + +`PushProvider` is **kept** — it is `CreateDeviceRequest['push_provider']`, the union `client.createDevice()` accepts, and it derives from the generated request rather than restating it. + +### `Automod` / `AutomodBehavior` narrowed + +Both now read their union off the generated channel config instead of restating it: + +```ts +// before rc.4 +type Automod = 'disabled' | 'simple' | 'AI' | (string & {}); +type AutomodBehavior = 'flag' | 'block' | 'shadow_block' | (string & {}); + +// after +type Automod = ChannelConfigWithInfo['automod']; // 'disabled' | 'simple' | 'AI' +type AutomodBehavior = ChannelConfigWithInfo['automod_behavior']; // 'flag' | 'block' | 'shadow_block' +``` + +The `| (string & {})` tail meant the unions accepted _any_ string — the documented values were a hint, not a constraint. Assigning an arbitrary string to one of these now fails to compile. `channel.getConfig().automod` reads are unaffected; the generated config has always had the narrow type. + ## Verification After applying the renames, `yarn types` should pass. If a call site errors with `Cannot find name 'X'` where X is one of the v9 names in the left column, the rewrite is incomplete. From 30a34a926c4d74928104dc77fd908e0330b95a30 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 19 Aug 2026 16:23:46 -0500 Subject: [PATCH 04/17] refactor: collapse type aliases that restated generated types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six exported types were structurally identical to something `src/gen` already emits, verified by compiling mutual-assignability assertions rather than by inspection. Four more were hand-written copies of request-type field sets; those keep their names but are derived now, so a spec change updates them instead of drifting past them. The twelve sort aliases were all exactly `SortParamRequest[]` — twelve names for one type. Unlike the `*Filters` aliases, which resolve to per-endpoint `Filters<{...}>` shapes carrying that endpoint's declared operators, a sort alias narrowed nothing. BREAKING CHANGES: * Removed, replacement is structurally identical (pure find/replace): - `ChannelData` -> `ChannelInput`. Was `ReplacePropertyTypes`, but `ChannelInput.custom` is already `CustomChannelData`, so the mapped type was a no-op. - `PollResponse_old` -> `PollResponseData`. Was `PollResponseData & PollEnrichData`; all six `PollEnrichData` fields are already on `PollResponseData`. - `PollEnrichData` -> `PollResponseData`. Fully subsumed. - `LiveLocationPayload` -> `SharedLocation`. Was `RequireLiteral`, and its only consumer immediately did `Omit<…, 'end_at'>`, undoing the requirement. - `Pager` -> the request type's own `limit` / `next` / `prev`. - `ReplacePropertyTypes` -> none. Type utility whose last consumer was `ChannelData`. * All twelve sort aliases are removed: `BannedUsersSort`, `ChannelSort`, `DraftSort`, `MemberSort`, `PinnedMessagesSort`, `PollSort`, `ReactionSort`, `ReminderSort`, `SearchMessageSort`, `ThreadSort`, `UserSort`, `VoteSort`. Use `SortParamRequest[]`. Note the brackets — the alias WAS the array, so `ChannelSort` becomes `SortParamRequest[]`, not `SortParamRequest`. Type-only; no runtime change. * `ChannelOptions` keeps its name, changes shape. Now `Omit`. It GAINS `member_custom_include?: Array` (the endpoint has always accepted it; the hand copy never mirrored it) and LOSES `user_id?: string`, which `QueryChannelsRequest` does not have — anything set there was silently dropped. * `UserOptions`, `QueryPollsOptions`, `QueryVotesOptions` keep their names and are now derived (`Omit`, `Omit`, `Omit`). All three are field-for-field what they were; deriving them means they can no longer drift. KEPT deliberately: * `ChannelUpdateOptions` and the `*Filters` family. Both were already derived, so they restate nothing and self-update. A filter alias also carries real per-endpoint information (its declared operators) that a sort alias never did. Co-Authored-By: Claude Opus 5 (1M context) --- src/channel.ts | 6 +- src/client.ts | 14 +- src/messageComposer/LocationComposer.ts | 9 +- .../middleware/textComposer/mentions.ts | 15 +- src/offline-support/types.ts | 11 +- src/pagination/paginators/ChannelPaginator.ts | 20 +-- .../paginators/ReminderPaginator.ts | 10 +- src/pagination/utility.queryChannel.ts | 2 +- src/poll.ts | 20 +-- src/poll_manager.ts | 10 +- src/search/ChannelMemberSearchSource.ts | 6 +- src/search/ChannelSearchSource.ts | 6 +- src/search/MessageSearchSource.ts | 11 +- src/search/UserSearchSource.ts | 8 +- src/types.ts | 136 +++--------------- test/unit/pagination/filterCompiler.test.ts | 4 +- .../paginators/ChannelPaginator.test.ts | 8 +- .../search/ChannelMemberSearchSource.test.ts | 4 +- test/unit/search/UserSearchSource.test.ts | 4 +- test/unit/threads.test.ts | 6 +- test/unit/utils.test.ts | 2 +- v9-to-v10-migration-guide-other.md | 2 +- v9-to-v10-migration-guide-sort.md | 24 +++- v9-to-v10-migration-guide-type-renames.md | 33 ++++- 24 files changed, 157 insertions(+), 214 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index c368f90008..338bdcaa71 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -21,8 +21,8 @@ import type { AIState, APIResponse, BanUserOptions, - ChannelData, ChannelGetOrCreateRequest, + ChannelInput, ChannelMemberResponse, ChannelResponse, ChannelStateResponseFields, @@ -143,7 +143,7 @@ export type ChannelInstanceConfig = { export class Channel extends ChannelApi { _client: StreamChat; data: Partial | undefined; - _data: ChannelData; + _data: ChannelInput; cid: string; /** */ listeners: Map>; @@ -191,7 +191,7 @@ export class Channel extends ChannelApi { client: StreamChat, type: string, id: string | undefined, - data: ChannelData, + data: ChannelInput, ) { const validTypeRe = /^[\w_-]+$/; const validIDRe = /^[\w!_-]+$/; diff --git a/src/client.ts b/src/client.ts index 77a5ae02a3..8ea52c468a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -24,7 +24,7 @@ import type { APIResponse, AppIdentifier, BanUserOptions, - ChannelData, + ChannelInput, ChannelMute, ChannelOptions, ChannelResponse, @@ -1529,12 +1529,12 @@ export class StreamChat extends ChatApi { * @param custom - Custom data to attach to the channel (optional, defaults to `{}`). * @returns The channel object; initialize it using `channel.watch()`. */ - channel(channelType: string, channelId?: string | null, custom?: ChannelData): Channel; - channel(channelType: string, custom?: ChannelData): Channel; + channel(channelType: string, channelId?: string | null, custom?: ChannelInput): Channel; + channel(channelType: string, custom?: ChannelInput): Channel; channel( channelType: string, - channelIdOrCustom?: string | ChannelData | null, - custom: ChannelData = {}, + channelIdOrCustom?: string | ChannelInput | null, + custom: ChannelInput = {}, ) { if (!this.userId) { throw Error('Call connectUser or connectAnonymousUser before creating a channel'); @@ -1581,7 +1581,7 @@ export class StreamChat extends ChatApi { * @param custom - Custom data to attach to the channel. * @returns The channel object; initialize it using `channel.watch()`. */ - getChannelByMembers = (channelType: string, custom: ChannelData) => { + getChannelByMembers = (channelType: string, custom: ChannelInput) => { // Check if the channel already exists. // Only allow 1 channel object per cid const memberIds = (custom.members ?? []).map((member) => @@ -1645,7 +1645,7 @@ export class StreamChat extends ChatApi { * @param custom - Custom data to attach to the channel. * @returns The channel object; initialize it using `channel.watch()`. */ - getChannelById = (channelType: string, channelId: string, custom: ChannelData) => { + getChannelById = (channelType: string, channelId: string, custom: ChannelInput) => { if (typeof channelId === 'string' && ~channelId.indexOf(':')) { throw Error(`Invalid channel id ${channelId}, can't contain the : character`); } diff --git a/src/messageComposer/LocationComposer.ts b/src/messageComposer/LocationComposer.ts index 7738113c9a..43596d66ce 100644 --- a/src/messageComposer/LocationComposer.ts +++ b/src/messageComposer/LocationComposer.ts @@ -1,11 +1,6 @@ import { StateStore } from '../store'; import type { MessageComposer } from './messageComposer'; -import type { - DraftMessage, - LiveLocationPayload, - LocalMessage, - SharedLocation, -} from '../types'; +import type { DraftMessage, LocalMessage, SharedLocation } from '../types'; export type Coords = { latitude: number; longitude: number }; @@ -18,7 +13,7 @@ export type StaticLocationPreview = SharedLocation & { message_id?: string; }; -export type LiveLocationPreview = Omit & { +export type LiveLocationPreview = Omit & { durationMs?: number; message_id?: string; }; diff --git a/src/messageComposer/middleware/textComposer/mentions.ts b/src/messageComposer/middleware/textComposer/mentions.ts index c367d48720..ee3139090b 100644 --- a/src/messageComposer/middleware/textComposer/mentions.ts +++ b/src/messageComposer/middleware/textComposer/mentions.ts @@ -28,13 +28,12 @@ import type { import type { StreamChat } from '../../../client'; import type { MemberFilters, - MemberSort, SearchUserGroupsOptions, + SortParamRequest, UserFilters, UserGroupResponse, UserOptions, UserResponse, - UserSort, } from '../../../types'; import type { Channel } from '../../../channel'; import { MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY } from '../../../constants'; @@ -98,10 +97,10 @@ export const calculateLevenshtein = (query: string, name: string) => { export type MentionsSearchSourceOptions = SearchSourceOptions & { /** Static base filters for the app-wide user query (mentionAllAppUsers). */ userFilters?: UserFilters; - userSort?: UserSort; + userSort?: SortParamRequest[]; /** Static base filters for the channel member query. */ memberFilters?: MemberFilters; - memberSort?: MemberSort; + memberSort?: SortParamRequest[]; searchOptions?: Omit; mentionAllAppUsers?: boolean; suggestionFactoryMappers?: MentionSuggestionFactoryMapperOverrides; @@ -307,8 +306,8 @@ export class MentionsSearchSource extends BaseSearchSource { protected userGroupCursor?: string; userFilters: UserFilters | undefined; memberFilters: MemberFilters | undefined; - userSort: UserSort | undefined; - memberSort: MemberSort | undefined; // todo: document there are filters and sort options for users and members + userSort: SortParamRequest[] | undefined; + memberSort: SortParamRequest[] | undefined; // todo: document there are filters and sort options for users and members searchOptions: Omit | undefined; config: Pick< MentionsSearchSourceOptions, @@ -562,13 +561,13 @@ export class MentionsSearchSource extends BaseSearchSource { ([ { field: 'name', direction: 1 }, { field: 'id', direction: 1 }, - ] satisfies UserSort), // todo: document the change - the sort is overridden, not merged + ] satisfies SortParamRequest[]), // todo: document the change - the sort is overridden, not merged options: { ...this.searchOptions, limit: this.pageSize, offset }, }); prepareQueryMembersParams = (searchQuery: string, offset = 0) => { // QueryMembers failed with error: \"sort must contain at maximum 1 item\" - let sort: MemberSort = [{ field: 'user_id', direction: 1 }]; + let sort: SortParamRequest[] = [{ field: 'user_id', direction: 1 }]; if (!this.memberSort || !this.memberSort.length) { sort = [{ field: 'user_id', direction: 1 }]; } else { diff --git a/src/offline-support/types.ts b/src/offline-support/types.ts index 5504c88903..7e7f103e7e 100644 --- a/src/offline-support/types.ts +++ b/src/offline-support/types.ts @@ -3,18 +3,17 @@ import type { ChannelMemberResponse, ChannelOptions, ChannelResponse, - ChannelSort, ChannelStateResponseFields, DraftResponse, GetApplicationResponse, LocalMessage, MessageResponse, - PollResponse_old, + PollResponseData, QueryChannelsRequest, ReactionFilters, ReactionResponse, - ReactionSort, ReadStateResponse, + SortParamRequest, } from '../types'; import type { Channel } from '../channel'; import type { StreamChat } from '../client'; @@ -48,7 +47,7 @@ export type DBUpsertCidsForQueryType = { /** Whether to immediately execute the operation. */ execute?: boolean; /** Optional sorting applied to the channels. */ - sort?: ChannelSort; + sort?: SortParamRequest[]; }; /** @@ -92,7 +91,7 @@ export type DBUpsertUserSyncStatusType = { */ export type DBUpsertPollType = { /** Poll data to be stored. */ - poll: PollResponse_old; + poll: PollResponseData; /** Whether to immediately execute the operation. */ execute?: boolean; }; @@ -216,7 +215,7 @@ export type DBGetReactionsType = { /** Optional filter to apply to reactions. */ filters?: ReactionFilters; /** Optional sorting for reactions. */ - sort?: ReactionSort; + sort?: SortParamRequest[]; /** Optional maximum number of reactions to return. */ limit?: number; }; diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index 121f2518ae..1fbe2d501a 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -22,16 +22,16 @@ import type { Channel } from '../../channel'; import type { ChannelFilters, ChannelOptions, - ChannelSort, ChannelStateOptions, ParsedPredefinedFilterResponse, QueryChannelsRequest, + SortParamRequest, } from '../../types'; import type { FieldToDataResolver, PathResolver } from '../types.normalization'; import { resolveDotPathValue } from '../utility.normalization'; import { isEqual } from '../../utils/mergeWith/mergeWithCore'; -const DEFAULT_BACKEND_SORT: ChannelSort = [ +const DEFAULT_BACKEND_SORT: SortParamRequest[] = [ { direction: -1, field: 'last_message_at' }, { direction: -1, field: 'updated_at' }, ]; @@ -57,7 +57,7 @@ export type ChannelPaginatorRequestOptions = Partial< export type ChannelSortComparatorFactoryParams = { /** Sort the comparator is being built for — the effective sort, so a backend-resolved sort template. */ - sort: ChannelSort; + sort: SortParamRequest[]; /** * The comparator `ChannelPaginator` would use for this sort. Delegate to it for the fields you do not * want to handle yourself instead of reimplementing channel field resolution and the cid tiebreaker. @@ -82,7 +82,7 @@ export type ChannelPaginatorOptions = { id?: string; paginatorOptions?: PaginatorOptions; requestOptions?: ChannelPaginatorRequestOptions; - sort?: ChannelSort; + sort?: SortParamRequest[]; sortComparatorFactory?: ChannelSortComparatorFactory; }; @@ -253,7 +253,7 @@ export class ChannelPaginator extends BasePaginator private readonly _id: string; private client: StreamChat; protected _staticFilters: ChannelFilters | undefined; - protected _sort: ChannelSort | undefined; + protected _sort: SortParamRequest[] | undefined; protected _options: ChannelPaginatorRequestOptions | undefined; protected _channelStateOptions: ChannelStateOptions | undefined; protected _nextQueryShape: ChannelQueryShape | undefined; @@ -322,7 +322,7 @@ export class ChannelPaginator extends BasePaginator * supply `sortComparatorFactory` (it is consulted on every rebuild and may delegate to * `defaultComparator`), or override this method in a subclass. */ - protected buildSortComparator(sort: ChannelSort) { + protected buildSortComparator(sort: SortParamRequest[]) { const defaultComparator = makeComparator({ sort, resolvePathValue: channelSortPathResolver, @@ -350,7 +350,7 @@ export class ChannelPaginator extends BasePaginator return this._staticFilters; } - get sort(): ChannelSort { + get sort(): SortParamRequest[] { return this._sort ?? DEFAULT_BACKEND_SORT; } @@ -380,8 +380,8 @@ export class ChannelPaginator extends BasePaginator * backend-resolved predefined filter takes precedence over the requested sort, matching backend * precedence rules. */ - get effectiveSort(): ChannelSort { - return (this._predefinedFilter?.sort as ChannelSort | undefined) ?? this.sort; + get effectiveSort(): SortParamRequest[] { + return (this._predefinedFilter?.sort as SortParamRequest[] | undefined) ?? this.sort; } get options(): ChannelOptions | undefined { @@ -396,7 +396,7 @@ export class ChannelPaginator extends BasePaginator this._staticFilters = filters; } - set sort(sort: ChannelSort | undefined) { + set sort(sort: SortParamRequest[] | undefined) { this._sort = sort; this.sortComparator = this.buildSortComparator(this.effectiveSort); } diff --git a/src/pagination/paginators/ReminderPaginator.ts b/src/pagination/paginators/ReminderPaginator.ts index 73ec8ba5e2..018c5981ef 100644 --- a/src/pagination/paginators/ReminderPaginator.ts +++ b/src/pagination/paginators/ReminderPaginator.ts @@ -8,7 +8,7 @@ import type { QueryRemindersRequest, ReminderFilters, ReminderResponseData, - ReminderSort, + SortParamRequest, } from '../../types'; import type { StreamChat } from '../../client'; import { StoreBackedItemIndex } from '../../entityStore/StoreBackedItemIndex'; @@ -21,7 +21,7 @@ const getReminderId = (reminder: ReminderResponseData) => reminder.message_id; // Fallback order for interval placement when no explicit sort is set. Order is not a pinned contract // (ReminderManager stores reminders in a message_id-keyed Map), but interval storage needs a total // order, so default to a deterministic one. -const DEFAULT_SORT: ReminderSort = [{ direction: 1, field: 'created_at' }]; +const DEFAULT_SORT: SortParamRequest[] = [{ direction: 1, field: 'created_at' }]; export class ReminderPaginator extends BasePaginator< ReminderResponseData, @@ -29,13 +29,13 @@ export class ReminderPaginator extends BasePaginator< > { private client: StreamChat; protected _filters: ReminderFilters | undefined; - protected _sort: ReminderSort | undefined; + protected _sort: SortParamRequest[] | undefined; get filters(): ReminderFilters | undefined { return this._filters; } - get sort(): ReminderSort | undefined { + get sort(): SortParamRequest[] | undefined { return this._sort; } @@ -44,7 +44,7 @@ export class ReminderPaginator extends BasePaginator< this.resetState(); } - set sort(sort: ReminderSort | undefined) { + set sort(sort: SortParamRequest[] | undefined) { this._sort = sort; this.sortComparator = this.buildSortComparator(); this.resetState(); diff --git a/src/pagination/utility.queryChannel.ts b/src/pagination/utility.queryChannel.ts index ea33d5a1c2..1c555f200b 100644 --- a/src/pagination/utility.queryChannel.ts +++ b/src/pagination/utility.queryChannel.ts @@ -49,7 +49,7 @@ export const getChannel = async ({ const theChannel = channel || - // `members` are member IDs; the OpenAPI `ChannelData.members` expects member objects. + // `members` are member IDs; the OpenAPI `ChannelInput.members` expects member objects. client.channel(type as string, id, { members: members?.map((user_id) => ({ user_id })), }); diff --git a/src/poll.ts b/src/poll.ts index 34cf113b03..1f8b6adb10 100644 --- a/src/poll.ts +++ b/src/poll.ts @@ -3,15 +3,14 @@ import type { StreamChat } from './client'; import type { EventPayload, PartialPollUpdate, - PollEnrichData, PollOptionData, - PollResponse_old, + PollResponseData, PollVoteResponseData, QueryVotesFilters, QueryVotesOptions, RequireLiteral, + SortParamRequest, UpdatePollRequest, - VoteSort, VotingVisibility, } from './types'; import type { PollResponseData as Gen_PollResponseData, WSEvent } from './gen/models'; @@ -35,18 +34,18 @@ export const isVoteAnswer = ( export type PollAnswersQueryParams = { filter?: QueryVotesFilters; options?: QueryVotesOptions; - sort?: VoteSort; + sort?: SortParamRequest[]; }; export type PollOptionVotesQueryParams = { filter: { option_id: string } & QueryVotesFilters; options?: QueryVotesOptions; - sort?: VoteSort; + sort?: SortParamRequest[]; }; type OptionId = string; -export type PollState = Omit & { +export type PollState = Omit & { lastActivityAt: Date; // todo: would be ideal to get this from the BE maxVotedOptionIds: OptionId[]; ownVotesByOptionId: Record; @@ -358,7 +357,7 @@ export class Poll { } function getMaxVotedOptionIds( - voteCountsByOption: PollResponse_old['vote_counts_by_option'], + voteCountsByOption: PollResponseData['vote_counts_by_option'], ) { let maxVotes = 0; let winningOptions: string[] = []; @@ -398,7 +397,7 @@ export function extractPollData(pollResponse: Gen_PollResponseData): UpdatePollR }; } -export function mapPollStateToResponse(poll: Poll): PollResponse_old { +export function mapPollStateToResponse(poll: Poll): PollResponseData { const { lastActivityAt: _lastActivityAt, @@ -421,7 +420,10 @@ export function mapPollStateToResponse(poll: Poll): PollResponse_old { export function extractPollEnrichedData( pollResponse: Gen_PollResponseData, -): Omit { +): Pick< + Gen_PollResponseData, + 'answers_count' | 'latest_votes_by_option' | 'vote_count' | 'vote_counts_by_option' +> { return { answers_count: pollResponse.answers_count, latest_votes_by_option: pollResponse.latest_votes_by_option, diff --git a/src/poll_manager.ts b/src/poll_manager.ts index 97c0f313b0..3320f56ab9 100644 --- a/src/poll_manager.ts +++ b/src/poll_manager.ts @@ -3,10 +3,10 @@ import type { CreatePollRequest, LocalMessage, MessageResponse, - PollResponse_old, - PollSort, + PollResponseData, QueryPollsFilters, QueryPollsOptions, + SortParamRequest, } from './types'; import { Poll } from './poll'; import { formatMessage } from './utils'; @@ -78,7 +78,7 @@ export class PollManager extends WithSubscriptions { public queryPolls = async ( filter: QueryPollsFilters, - sort: PollSort = [], + sort: SortParamRequest[] = [], options: QueryPollsOptions = {}, ) => { const { polls, next } = await this.client.queryPolls({ @@ -107,13 +107,13 @@ export class PollManager extends WithSubscriptions { if (!message.poll) { continue; } - const pollResponse = message.poll as PollResponse_old; + const pollResponse = message.poll as PollResponseData; this.setOrOverwriteInCache(pollResponse, overwriteState); } }; private setOrOverwriteInCache = ( - pollResponse: PollResponse_old, + pollResponse: PollResponseData, overwriteState?: boolean, ) => { if (!this.client._cacheEnabled()) { diff --git a/src/search/ChannelMemberSearchSource.ts b/src/search/ChannelMemberSearchSource.ts index e68baba40a..040025918b 100644 --- a/src/search/ChannelMemberSearchSource.ts +++ b/src/search/ChannelMemberSearchSource.ts @@ -4,8 +4,8 @@ import type { Channel } from '../channel'; import type { ChannelMemberResponse, MemberFilters, - MemberSort, QueryMembersOptions, + SortParamRequest, } from '../types'; import type { SearchSourceOptions } from './types'; @@ -18,7 +18,7 @@ export type ChannelMemberSearchSourceFilterBuilderContext< export type ChannelMemberSearchSourceOptions = SearchSourceOptions & { /** Static base filters merged under the dynamically generated ones. */ filters?: MemberFilters; - sort?: MemberSort; + sort?: SortParamRequest[]; searchOptions?: Omit; }; @@ -28,7 +28,7 @@ export class ChannelMemberSearchSource< readonly type = 'members'; channel: Channel; filters: MemberFilters | undefined; - sort: MemberSort | undefined; + sort: SortParamRequest[] | undefined; searchOptions: Omit | undefined; filterBuilder: FilterBuilder< MemberFilters, diff --git a/src/search/ChannelSearchSource.ts b/src/search/ChannelSearchSource.ts index d7af267661..07216f9baa 100644 --- a/src/search/ChannelSearchSource.ts +++ b/src/search/ChannelSearchSource.ts @@ -3,7 +3,7 @@ import type { FilterBuilderOptions } from '../pagination'; import { FilterBuilder } from '../pagination'; import type { Channel } from '../channel'; import type { StreamChat } from '../client'; -import type { ChannelFilters, ChannelOptions, ChannelSort } from '../types'; +import type { ChannelFilters, ChannelOptions, SortParamRequest } from '../types'; import type { SearchSourceOptions } from './types'; type CustomContext = Record; @@ -15,7 +15,7 @@ export type ChannelSearchSourceFilterBuilderContext< export type ChannelSearchSourceOptions = SearchSourceOptions & { /** Static base filters merged under the dynamically generated ones. */ filters?: ChannelFilters; - sort?: ChannelSort; + sort?: SortParamRequest[]; searchOptions?: Omit; }; @@ -25,7 +25,7 @@ export class ChannelSearchSource< readonly type = 'channels'; client: StreamChat; filters: ChannelFilters | undefined; - sort: ChannelSort | undefined; + sort: SortParamRequest[] | undefined; searchOptions: Omit | undefined; filterBuilder: FilterBuilder< ChannelFilters, diff --git a/src/search/MessageSearchSource.ts b/src/search/MessageSearchSource.ts index bfe9c46226..412f2dc8b0 100644 --- a/src/search/MessageSearchSource.ts +++ b/src/search/MessageSearchSource.ts @@ -2,11 +2,10 @@ import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource'; import type { ChannelFilters, ChannelOptions, - ChannelSort, MessageFilters, MessageResponse, - SearchMessageSort, SearchPayload, + SortParamRequest, } from '../types'; import type { StreamChat } from '../client'; import type { SearchSourceOptions } from './types'; @@ -59,10 +58,10 @@ export type MessageSearchSourceOptions = SearchSourceOptions & { messageSearchChannelFilters?: ChannelFilters; /** Static base filters for the message search itself. */ messageSearchFilters?: MessageFilters; - messageSearchSort?: SearchMessageSort; + messageSearchSort?: SortParamRequest[]; /** Static base filters for the follow-up query that hydrates unknown channels. */ channelQueryFilters?: ChannelFilters; - channelQuerySort?: ChannelSort; + channelQuerySort?: SortParamRequest[]; channelQueryOptions?: Omit; }; @@ -74,10 +73,10 @@ export class MessageSearchSource< messageSearchChannelFilters: SearchPayload['filter_conditions'] | undefined; messageSearchFilters: MessageFilters | undefined; - messageSearchSort: SearchMessageSort | undefined; + messageSearchSort: SortParamRequest[] | undefined; channelQueryFilters: ChannelFilters | undefined; - channelQuerySort: ChannelSort | undefined; + channelQuerySort: SortParamRequest[] | undefined; channelQueryOptions: Omit | undefined; messageSearchChannelFilterBuilder: FilterBuilder< diff --git a/src/search/UserSearchSource.ts b/src/search/UserSearchSource.ts index 271978763f..ea476e5557 100644 --- a/src/search/UserSearchSource.ts +++ b/src/search/UserSearchSource.ts @@ -1,7 +1,7 @@ import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource'; import { FilterBuilder, type FilterBuilderOptions } from '../pagination'; import type { StreamChat } from '../client'; -import type { UserFilters, UserOptions, UserResponse, UserSort } from '../types'; +import type { SortParamRequest, UserFilters, UserOptions, UserResponse } from '../types'; import type { SearchSourceOptions } from './types'; type CustomContext = Record; @@ -13,7 +13,7 @@ export type UserSearchSourceFilterBuilderContext< export type UserSearchSourceOptions = SearchSourceOptions & { /** Static base filters merged under the dynamically generated ones. */ filters?: UserFilters; - sort?: UserSort; + sort?: SortParamRequest[]; searchOptions?: Omit; }; @@ -23,7 +23,7 @@ export class UserSearchSource< readonly type = 'users'; client: StreamChat; filters: UserFilters | undefined; - sort: UserSort | undefined; + sort: SortParamRequest[] | undefined; searchOptions: Omit | undefined; filterBuilder: FilterBuilder< UserFilters, @@ -73,7 +73,7 @@ export class UserSearchSource< }); const baseSort = this.sort ?? []; const hasIdSort = baseSort.some((entry) => entry.field === 'id'); - const sort: UserSort = hasIdSort + const sort: SortParamRequest[] = hasIdSort ? baseSort : [...baseSort, { field: 'id', direction: 1 }]; const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; diff --git a/src/types.ts b/src/types.ts index d22899115f..b7c55cda9a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,6 @@ import type { AxiosRequestConfig, AxiosResponse } from 'axios'; import type { StableWSConnection } from './connection'; -import type { CustomChannelData, CustomEventTypes } from './custom_types'; +import type { CustomEventTypes } from './custom_types'; import type { NotificationManager } from './notifications'; import type { RESERVED_UPDATED_MESSAGE_FIELDS } from './constants'; import type { @@ -8,7 +8,6 @@ import type { Attachment, AutomodDetailsResponse, ChannelConfigWithInfo, - ChannelInput, ChannelMemberResponse, ChannelOwnCapability, ChannelResponse, @@ -18,8 +17,6 @@ import type { MessageResponse, ModerationPayload, OwnUserResponse, - PollResponseData, - PollVoteResponseData, QueryChannelsRequest, QueryMembersPayload, QueryPollsRequest, @@ -32,9 +29,7 @@ import type { SearchWarning, SendMessageRequest, SendMessageResponse, - SharedLocation, SharedLocationResponseData, - SortParamRequest, TranslateMessageRequest, UpdateChannelRequest, UpdateMessageRequest, @@ -206,47 +201,18 @@ export type BanUserOptions = UnBanUserOptions & { delete_reactions?: boolean; }; -export type ChannelOptions = { - limit?: number; - member_limit?: number; - message_limit?: number; - offset?: number; - presence?: boolean; - state?: boolean; - user_id?: string; - watch?: boolean; - /** - * Name of a predefined filter to use instead of sending raw - * `filter_conditions`. - * - * The backend resolves the filter template by name and interpolates it using - * `filter_values`. - * - * A regular `sort` can still be passed to `queryChannels()`, but backend - * precedence rules apply: - * - * - if the predefined filter has its own stored sort template, that stored - * sort takes precedence and the request `sort` is ignored - * - if the predefined filter does not define a sort template, the request - * `sort` can still be used - */ - predefined_filter?: string; - /** - * Values used to interpolate placeholders inside the predefined filter's - * `filter` template. - * - * Example: a template value like `{{user_id}}` can be resolved with - * `{ user_id: 'alice' }`. - * - * Only used when `predefined_filter` is provided. - */ - filter_values?: Record; - /** - * Values to interpolate into the predefined filter sort template placeholders. - * Only used when predefined_filter is provided. - */ - sort_values?: Record; -}; +/** + * Everything `queryChannels()` accepts apart from the filter and the sort. + * + * Derived from the generated request so new query options appear here automatically. + * + * On `predefined_filter`: the backend resolves the named filter template and interpolates it + * with `filter_values`. A regular `sort` can still be passed, but backend precedence applies — + * if the predefined filter has its own stored sort template that template wins and the request + * `sort` is ignored; if it does not, the request `sort` is used. `sort_values` interpolates the + * stored sort template's placeholders. All three are only read when `predefined_filter` is set. + */ +export type ChannelOptions = Omit; export type ChannelStateOptions = { offlineMode?: boolean; @@ -385,12 +351,8 @@ export type UnBanUserOptions = { type?: string; }; -export type UserOptions = { - include_deactivated_users?: boolean; - limit?: number; - offset?: number; - presence?: boolean; -}; +/** Everything `queryUsers()` accepts apart from the filter and the sort. */ +export type UserOptions = Omit; type LocalEvent = ( | ({ type: 'live_location_sharing.started' } & { message: MessageResponse }) @@ -454,9 +416,9 @@ export type QueryReactionsRequestWithId = Parameters[ export type ChannelFilters = NonNullable; -export type QueryPollsOptions = Pager; +export type QueryPollsOptions = Omit; -export type QueryVotesOptions = Pager; +export type QueryVotesOptions = Omit; export type QueryPollsFilters = NonNullable; @@ -491,42 +453,12 @@ export type MemberFilters = QueryMembersPayload['filter_conditions']; * Sort Types */ -export type BannedUsersSort = SortParamRequest[]; - -export type ReactionSort = SortParamRequest[]; - -export type ChannelSort = SortParamRequest[]; - -export type PinnedMessagesSort = SortParamRequest[]; - -export type UserSort = SortParamRequest[]; - -export type MemberSort = SortParamRequest[]; - -export type SearchMessageSort = SortParamRequest[]; - -export type DraftSort = SortParamRequest[]; - -export type PollSort = SortParamRequest[]; - -export type VoteSort = SortParamRequest[]; - /** * Base Types */ -// export type Attachment = ReplacePropertyTypes< -// Attachment, -// { custom: CustomAttachmentData & { file_size?: number; mime_type?: string } } -// >; - export type OGAttachment = RequireLiteral; -export type ChannelData = ReplacePropertyTypes< - ChannelInput, - { custom: CustomChannelData } ->; - export type PushProvider = CreateDeviceRequest['push_provider']; export type Configs = Record; @@ -574,12 +506,6 @@ export type TaskResponse = { task_id: string; }; -export type Pager = { - limit?: number; - next?: string; - prev?: string; -}; - export type MessageSetType = 'latest' | 'current' | 'new'; export class StreamAPIError extends Error { @@ -648,22 +574,11 @@ export class StreamAPIError extends Error { } } -export type PollResponse_old = PollResponseData & PollEnrichData; - export enum VotingVisibility { anonymous = 'anonymous', public = 'public', } -export type PollEnrichData = { - answers_count: number; - latest_answers: PollVoteResponseData[]; // not updated with WS events, ordered DESC by created_at, seems like updated_at cannot be different from created_at - latest_votes_by_option: Record; // not updated with WS events; always null in anonymous polls - vote_count: number; - vote_counts_by_option: Record; - own_votes?: PollVoteResponseData[]; // not updated with WS events -}; - export type PartialPollUpdate = { set?: Partial; unset?: Array; @@ -730,18 +645,12 @@ export type SharedLiveLocationResponse = RequireLiteral< 'end_at' >; -export type LiveLocationPayload = RequireLiteral; - -export type ThreadSort = SortParamRequest[]; - export type ThreadFilters = NonNullable; export type CreateReminderOptions = Parameters[0]; export type ReminderFilters = NonNullable; -export type ReminderSort = SortParamRequest[]; - export type ListUserGroupsOptions = NonNullable[0]>; export type SearchUserGroupsOptions = Parameters[0]; @@ -770,17 +679,6 @@ export type EventPayload = Extract< export type RequireLiteral = Omit & Required>; -export type ReplacePropertyTypes< - Base, - Replacement extends RequireAtLeastOne>, -> = keyof Replacement extends keyof Base - ? Omit & { - [K in keyof Replacement as undefined extends Base[K] ? never : K]: Replacement[K]; - } & { - [K in keyof Replacement as undefined extends Base[K] ? K : never]?: Replacement[K]; - } - : never; - export type PartializeAllBut = { [P in K]-?: T[P]; } & { [P in Exclude]?: T[P] }; diff --git a/test/unit/pagination/filterCompiler.test.ts b/test/unit/pagination/filterCompiler.test.ts index 3cb39e12f5..7175f03a4c 100644 --- a/test/unit/pagination/filterCompiler.test.ts +++ b/test/unit/pagination/filterCompiler.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { - ChannelData, + ChannelInput, ChannelMemberResponse, ChannelResponse, ContainsOperator, @@ -55,7 +55,7 @@ type CustomChannelFilters = QueryFilters< } >; -type TestChannel = ChannelData & CustomChannelData; +type TestChannel = ChannelInput & CustomChannelData; const filter: CustomChannelFilters = { $or: [ diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index 7178fcf7fc..c60e745bcc 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -4,7 +4,7 @@ import { type ChannelFilters, ChannelOptions, ChannelPaginator, - ChannelSort, + SortParamRequest, DEFAULT_PAGINATION_OPTIONS, type FilterBuilderGenerators, formatMessage, @@ -847,7 +847,7 @@ describe('ChannelPaginator', () => { ).mockImplementation(() => Promise.resolve(true)); const filters = { id: 'abc' }; - const sort: ChannelSort = [{ field: 'id', direction: 1 }]; + const sort: SortParamRequest[] = [{ field: 'id', direction: 1 }]; const items1 = [channel1]; const paginator = new ChannelPaginator({ client }); @@ -1261,7 +1261,7 @@ describe('ChannelPaginator', () => { it('is called with correct parameters', async () => { const queryChannelsSpy = vi.spyOn(client, 'queryChannels').mockResolvedValue([]); const filters: ChannelFilters = { name: 'A' }; - const sort: ChannelSort = [{ field: 'has_unread', direction: -1 }]; + const sort: SortParamRequest[] = [{ field: 'has_unread', direction: -1 }]; const requestOptions: ChannelOptions = { message_limit: 3 }; const paginator = new ChannelPaginator({ client, @@ -1353,7 +1353,7 @@ describe('ChannelPaginator', () => { const mockQueryResponse = ( channels: Channel[], - predefinedFilter?: { name: string; filter: object; sort?: ChannelSort }, + predefinedFilter?: { name: string; filter: object; sort?: SortParamRequest[] }, ) => vi.spyOn(client, 'queryChannelsAndHydrate').mockResolvedValue({ channels, diff --git a/test/unit/search/ChannelMemberSearchSource.test.ts b/test/unit/search/ChannelMemberSearchSource.test.ts index 01e6f808d6..9c8ddc3e9d 100644 --- a/test/unit/search/ChannelMemberSearchSource.test.ts +++ b/test/unit/search/ChannelMemberSearchSource.test.ts @@ -5,7 +5,7 @@ import { ChannelMemberSearchSource } from '../../../src/search/ChannelMemberSear import type { ChannelMemberResponse, MemberFilters, - MemberSort, + SortParamRequest, } from '../../../src/types'; const createChannelMember = ( @@ -171,7 +171,7 @@ describe('ChannelMemberSearchSource', () => { it('passes filters, sort, and options to channel.queryMembers', async () => { const filters: MemberFilters = { user_id: 'user-2' }; - const sort: MemberSort = [{ field: 'name', direction: 1 }]; + const sort: SortParamRequest[] = [{ field: 'name', direction: 1 }]; searchSource.filters = filters; searchSource.sort = sort; searchSource.searchOptions = { user_id_gt: 'user-0' }; diff --git a/test/unit/search/UserSearchSource.test.ts b/test/unit/search/UserSearchSource.test.ts index 0c78871ca1..4338aa0f6d 100644 --- a/test/unit/search/UserSearchSource.test.ts +++ b/test/unit/search/UserSearchSource.test.ts @@ -3,7 +3,7 @@ import { UserSearchSource } from '../../../src/search/UserSearchSource'; import type { StreamChat } from '../../../src/client'; import type { UserFilters, - UserSort, + SortParamRequest, UserResponse, UsersAPIResponse, } from '../../../src/types'; @@ -210,7 +210,7 @@ describe('UserSearchSource', () => { }); it('leaves the sort array unchanged when it already contains an id key', async () => { - const sort: UserSort = [ + const sort: SortParamRequest[] = [ { field: 'id', direction: -1 }, { field: 'created_at', direction: -1 }, ]; diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 12d5e10c19..4113f778b4 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -15,7 +15,7 @@ import { ThreadStateResponse, THREAD_MANAGER_INITIAL_STATE, ThreadFilters, - ThreadSort, + SortParamRequest, } from '../../src'; import { describe, it, beforeEach, expect, afterEach } from 'vitest'; @@ -2337,7 +2337,7 @@ describe('Threads 2.0', () => { }); it('applies sort parameters correctly', async () => { - const sort: ThreadSort = [ + const sort: SortParamRequest[] = [ { field: 'created_at', direction: -1 }, { field: 'last_message_at', direction: 1 }, ]; @@ -2360,7 +2360,7 @@ describe('Threads 2.0', () => { created_by_user_id: { $eq: 'user1' }, updated_at: { $gte: '2024-01-01T00:00:00Z' }, }; - const sort: ThreadSort = [{ field: 'last_message_at', direction: -1 }]; + const sort: SortParamRequest[] = [{ field: 'last_message_at', direction: -1 }]; await threadManager.queryThreads({ filter, sort }); diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index 0e4cd480f7..d4e58a9ce1 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -22,7 +22,7 @@ import { import type { ChannelFilters, ChannelOwnCapability, - ChannelSort, + SortParamRequest, ReactionResponse, } from '../../src'; import { StreamChat, Channel } from '../../src'; diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index 3245a69d2b..9c98482c4f 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -293,7 +293,7 @@ Field-name typos in a typed filter are now compile errors. If you were relying o ### Filter aliases now derive from the request types -`ChannelFilters`, `MessageFilters`, `ReactionFilters`, `ThreadFilters`, `UserFilters` still exist as convenience aliases but derive from the constrained request types. The three remaining hand-written poll/reminder filter types are now migrated the same way: +`ChannelFilters`, `MessageFilters`, `ReactionFilters`, `ThreadFilters`, `UserFilters` still exist as convenience aliases but derive from the constrained request types. (The _sort_ aliases did not survive — unlike a filter alias, which resolves to a per-endpoint `Filters<{...}>` carrying that endpoint's declared operators, every sort alias was the same `SortParamRequest[]`. See the [sort guide](./v9-to-v10-migration-guide-sort.md#the-sort-aliases-are-gone).) The three remaining hand-written poll/reminder filter types are now migrated the same way: | Alias | Now derives from | | ------------------- | ---------------------------------------------- | diff --git a/v9-to-v10-migration-guide-sort.md b/v9-to-v10-migration-guide-sort.md index d334dfc033..a9f4c1913d 100644 --- a/v9-to-v10-migration-guide-sort.md +++ b/v9-to-v10-migration-guide-sort.md @@ -9,7 +9,7 @@ - The `normalizeQuerySort` helper is gone (no longer exported). Callers were the only normalizers in v9 — in v10 the SDK passes the array straight through to the API. - All `*SortBase` types (`ChannelSortBase`, `BannedUsersSortBase`, `SearchMessageSortBase`, `PollSortBase`, `VoteSortBase`, `DraftSortBase`, `PinnedMessagesSortBase`, `ReactionSortBase`, `ThreadSortBase`, `QueryMessageHistorySortBase`) are removed. - The `Sort` generic mapping type is removed. The `QuerySort` union type is removed. -- Sort type aliases that still exist (`ChannelSort`, `UserSort`, `MemberSort`, `BannedUsersSort`, `ReactionSort`, `PinnedMessagesSort`, `SearchMessageSort`, `DraftSort`, `PollSort`, `VoteSort`, `ThreadSort`, `ReminderSort`) all collapse to the same `Gen_SortParamRequest[]` type — they're convenience aliases, not field-typed shapes. +- **All twelve sort aliases are removed after `10.0.0-rc.4`** (`ChannelSort`, `UserSort`, `MemberSort`, `BannedUsersSort`, `ReactionSort`, `PinnedMessagesSort`, `SearchMessageSort`, `DraftSort`, `PollSort`, `VoteSort`, `ThreadSort`, `ReminderSort`). Every one of them was exactly `SortParamRequest[]` — a name, carrying no per-endpoint field typing. Use `SortParamRequest[]` directly. See [The sort aliases are gone](#the-sort-aliases-are-gone). - Removed sort types with no replacement: `SortParam`, `CampaignSort`, `QueryMessageHistorySort`, `ReviewQueueSort`, `QueryModerationConfigsSort`, `QueryModerationRulesSort`, `PredefinedFilterSort`, `PredefinedFilterSortParam`. Their carrier methods either moved to the generated API (and now accept `SortParamRequest[]` inline via the request type) or were removed with the rest of server-side functionality. If you were typing a local variable as one of these, retype it as `SortParamRequest[]`. ## The shape change @@ -66,6 +66,28 @@ export interface SortParamRequest { Multi-key sort objects are no longer expressible — each `{ field, direction }` entry has exactly one field, and field order is the array order. +## The sort aliases are gone + +> Applies to integrations pinned to the `rc` dist-tag as well as to v9 upgrades. These twelve names still shipped in `10.0.0-rc.4`. + +`ChannelSort`, `UserSort`, `MemberSort`, `BannedUsersSort`, `ReactionSort`, `PinnedMessagesSort`, `SearchMessageSort`, `DraftSort`, `PollSort`, `VoteSort`, `ThreadSort` and `ReminderSort` were **all defined as exactly `SortParamRequest[]`** — twelve names for one type. Unlike the `*Filters` aliases (which resolve to per-endpoint `Filters<{...}>` shapes with their own declared operators, and are kept), a sort alias narrowed nothing: it could not tell you which fields a given endpoint sorts by, and it did not stop you passing a field the endpoint rejects. + +The migration is a find/replace, and it is safe to do blindly because every one resolved to the same type: + +```ts +// before +import type { ChannelSort, UserSort } from 'stream-chat'; +const sort: ChannelSort = [{ field: 'last_message_at', direction: -1 }]; + +// after +import type { SortParamRequest } from 'stream-chat'; +const sort: SortParamRequest[] = [{ field: 'last_message_at', direction: -1 }]; +``` + +Watch the array brackets — the alias _was_ the array, so `ChannelSort` becomes `SortParamRequest[]`, not `SortParamRequest`. In an import specifier list you still import the bare `SortParamRequest`. + +No runtime behaviour changes: these were type-only aliases and the values you pass are unchanged. + ## Where `sort` lives now All v9 methods that took `(filters, sort, options, ...)` as positional arguments have been collapsed to a single `request` object (or `{ payload: request }` for endpoints whose payload is sent as a JSON-encoded query param). `sort` is now a property on that request/payload. The signature consolidation itself is covered in the per-method migration guide; the relevant point for the sort migration is that **the value you previously passed as the `sort` positional argument is now the value of `request.sort` (or `request.payload.sort`)** — only its shape changed (see above). diff --git a/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index 139b577575..c1c20fa91e 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -142,8 +142,8 @@ v9 also had `client.createAbortControllerForNextRequest()`, which armed a contro These v9 names look like they'd be caught by the same rewrite pass but are **not** simple aliases — they either have hand-authored shape on top of the generated type (via `RequireLiteral`, compound intersections, etc.) or point at a locally-defined type. Some names now re-export the generated shape directly through `export * from './gen/models'`; the name is the same but the shape may have narrowed since v9. Do not rewrite these: - `MessageResponse`, `UserResponse`, `OwnUserResponse`, `ReactionResponse`, `ChannelResponse`, `ChannelMemberResponse`, `DraftResponse`, `Attachment`, `PollResponseData`, `PollOptionResponseData`, `MessageRequest` — in v9 these were wrapped with `ReplacePropertyTypes<…, { custom: Custom*Data }>`; in v10 the custom-overlay pattern is dropped and the raw generated shape is re-exported. The v9 name is retained. -- `PollOptionData`, `DraftMessage`, `SharedLiveLocationResponse`, `LiveLocationPayload`, `ChannelData` — compound types (locally-defined or `RequireLiteral<…, 'end_at'>`). Keep as-is. -- `PollResponse_old` — the only remaining `_old`-suffixed compat holder. Still used internally by `poll.ts` / `poll_manager.ts` / `offline-support/types.ts` and not a simple alias (it intersects the generated `PollResponseData` with a local `PollEnrichData` overlay). Its own story; do not rename here. +- `PollOptionData`, `DraftMessage`, `SharedLiveLocationResponse` — compound types (locally-defined or `RequireLiteral<…, 'end_at'>`). Keep as-is. +- `LiveLocationPayload`, `ChannelData` and `PollResponse_old` **were** in this list and are now removed — see [below](#aliases-that-restated-a-generated-type). - `AppSettings` is the only "settings"-ish name that IS renamed here (to `AppResponseFields`). Do not confuse it with `AppSettingsAPIResponse` (also removed; renamed to `GetApplicationResponse`) — they were two different aliases that shared a prefix. ## Removed after `10.0.0-rc.4` — convergence on the generated types @@ -224,6 +224,35 @@ type AutomodBehavior = ChannelConfigWithInfo['automod_behavior']; // 'flag' | 'b The `| (string & {})` tail meant the unions accepted _any_ string — the documented values were a hint, not a constraint. Assigning an arbitrary string to one of these now fails to compile. `channel.getConfig().automod` reads are unaffected; the generated config has always had the narrow type. +### Aliases that restated a generated type + +Each of these was structurally identical to a type the generator already emits — verified by compiling a mutual-assignability assertion, not by inspection. They are removed; the replacement is a pure find/replace with no behaviour change. + +| Removed after `rc.4` | Replacement | Detail | +| ---------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ChannelData` | `ChannelInput` | Was `ReplacePropertyTypes`, but `ChannelInput.custom` is _already_ `CustomChannelData` — the mapped type was a no-op. | +| `PollResponse_old` | `PollResponseData` | Was `PollResponseData & PollEnrichData`; all six `PollEnrichData` fields are already on `PollResponseData`. | +| `PollEnrichData` | `PollResponseData` | Fully subsumed. Its fields are `answers_count`, `latest_answers`, `latest_votes_by_option`, `vote_count`, `vote_counts_by_option`, `own_votes`. | +| `LiveLocationPayload` | `SharedLocation` | Was `RequireLiteral`; its only consumer immediately did `Omit<…, 'end_at'>`, undoing the requirement. | +| `Pager` | the request type's own `limit` / `next` / `prev` | Also removes its two aliases — see the row below. | +| `ReplacePropertyTypes` | none | Type utility whose only remaining consumer was `ChannelData`. Inline the `Omit<…> & {…}` if you were using it. | + +`ChannelOptions`, `UserOptions`, `QueryPollsOptions` and `QueryVotesOptions` keep their names but are now **derived** from the request types instead of restating them: + +```ts +type ChannelOptions = Omit; +type UserOptions = Omit; +type QueryPollsOptions = Omit; +type QueryVotesOptions = Omit; +``` + +Two of those change shape: + +- **`ChannelOptions` gains `member_custom_include?: Array`** (the endpoint has always accepted it) and **loses `user_id?: string`** (`QueryChannelsRequest` has no such field — it was never sent). Additive for almost everyone; if you were setting `user_id` here it was being dropped silently. +- **`UserOptions` is unchanged field-for-field** — it happened to be an exact copy. It is derived now so it cannot drift. + +`ChannelUpdateOptions` and the `*Filters` family are deliberately **kept**: they were already derived (`Omit`, `NonNullable`), so they update themselves when the spec moves and restate nothing. + ## Verification After applying the renames, `yarn types` should pass. If a call site errors with `Cannot find name 'X'` where X is one of the v9 names in the left column, the rewrite is incomplete. From 89e000a1a0b8981662f88e80708dfeff73324719 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 19 Aug 2026 16:30:37 -0500 Subject: [PATCH 05/17] refactor: retire the APIResponse envelope and UpdatedMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `APIResponse` was `{ duration: string }` — the response envelope from before the generated layer existed. Every generated response already carries `duration`, and the transport wraps results in `StreamResponse`, which also carries `metadata`. So the aliases built on it were not merely redundant, they were weaker than the real return type. `UpdatedMessage` built a request type by subtracting a hand-maintained constant from a response type. The generated `MessageRequest` already is that shape, and is correct where `UpdatedMessage` was not. BREAKING CHANGES: * Removed from the `APIResponse` family — replacements are reached through `StreamResponse<…>` when they are method return values, so each gains a required `metadata` field: - `SearchAPIResponse` -> `SearchResponse`. `results` entries are `SearchResult` rather than an inline `{ message }`. - `SendFileAPIResponse` -> `FileUploadResponse` / `ImageUploadResponse`. - `UpdateChannelAPIResponse` -> `UpdateChannelResponse`. - `UsersAPIResponse` -> `UpdateUsersResponse` / `QueryUsersResponse`. - `TaskResponse` -> the endpoint's own response type. - `ReactionAPIResponse` -> `SendReactionResponse` / `DeleteReactionResponse`. - `Flag` and `FlagDetails` -> `FlagDetailsResponse`. Code that only destructures the payload is unaffected; code that annotates a variable with a removed alias needs the new name. * `UpdatedMessage` -> `MessageRequest`. This TIGHTENS what compiles, deliberately: - `MessageRequest['type']` is `'regular' | 'system'`, where `UpdatedMessage['type']` was the six-member `MessageLabel` including `'deleted'`, `'error'`, `'ephemeral'` and `'reply'` — none of which a client may send. - Server-owned `MessageResponse` fields absent from the reserved list (`cid`, `shadowed`, `reaction_groups`, …) were assignable to an update payload. They are not on `MessageRequest`. * `MessageLabel` and `ReservedUpdatedMessageFields` are removed with it. The runtime constant `RESERVED_UPDATED_MESSAGE_FIELDS` stays — `toUpdatedMessagePayload()` still uses it to strip server-owned keys off a `LocalMessage`; it just no longer drives a type. * `MessageComposerMiddlewareState.message` is now `MessageRequest`, not `MessageRequest | UpdatedMessage`. Custom composer middleware that annotated the union should drop the `UpdatedMessage` arm. NOT removed, and why: * `APIResponse`, `FlagMessageResponse`, `FlagUserResponse`, `MuteUserResponse` and `UnmuteUserResponse` survive. Every remaining reference to them sits inside the hand-written `/moderation/*` methods on `StreamChat` that bypass the generated client. Those methods are migrating in a separate PR and these types go with them. The one `APIResponse` use that was NOT pinned — the `deleteDraft` offline-queue generic in `channel.ts` — is switched to `Awaited>`, matching the neighbouring `createDraft` queue call. Co-Authored-By: Claude Opus 5 (1M context) --- src/channel.ts | 24 +++--- .../middleware/messageComposer/types.ts | 9 +-- src/types.ts | 79 ++----------------- src/utils.ts | 5 +- test/unit/reactions.optimistic.test.ts | 4 +- v9-to-v10-migration-guide-other.md | 2 +- v9-to-v10-migration-guide-type-renames.md | 39 +++++++++ 7 files changed, 65 insertions(+), 97 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 338bdcaa71..f55b463ee4 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -19,7 +19,6 @@ import type { StreamChat } from './client'; import { chatLoggerSystem } from './logger'; import type { AIState, - APIResponse, BanUserOptions, ChannelGetOrCreateRequest, ChannelInput, @@ -42,7 +41,6 @@ import type { MessageResponse, MessageSetType, QueryMembersPayload, - ReactionAPIResponse, ReactionRequest, SendMessageOptions, SendReactionRequest, @@ -629,7 +627,7 @@ export class Channel extends ChannelApi { if (offlineDb) { // The optimistic reaction row is written by the local-update layer // (`applyReactionLocally`); here we only queue the request for replay. - return await offlineDb.queueTask({ + return await offlineDb.queueTask>>({ task: { channelId: this.id as string, channelType: this.type, @@ -661,7 +659,7 @@ export class Channel extends ChannelApi { if (offlineDb) { // The optimistic reaction-row removal is handled by the local-update layer // (`applyReactionLocally`); here we only queue the request for replay. - return await offlineDb.queueTask({ + return await offlineDb.queueTask>>({ task: { channelId: this.id as string, channelType: this.type, @@ -1970,15 +1968,17 @@ export class Channel extends ChannelApi { try { const offlineDb = this.getClient().offlineDb; if (offlineDb) { - return (await offlineDb.queueTask({ - task: { - channelId: this.id as string, - channelType: this.type, - threadId: request?.parent_id, - payload: args, - type: 'delete-draft', + return (await offlineDb.queueTask>>( + { + task: { + channelId: this.id as string, + channelType: this.type, + threadId: request?.parent_id, + payload: args, + type: 'delete-draft', + }, }, - })) as Awaited>; + )) as Awaited>; } } catch (error) { offlineDbLogger diff --git a/src/messageComposer/middleware/messageComposer/types.ts b/src/messageComposer/middleware/messageComposer/types.ts index b2c58f698c..5cc94beb2c 100644 --- a/src/messageComposer/middleware/messageComposer/types.ts +++ b/src/messageComposer/middleware/messageComposer/types.ts @@ -1,14 +1,9 @@ import type { Middleware, MiddlewareExecutionResult } from '../../../middleware'; -import type { - LocalMessage, - MessageRequest, - SendMessageOptions, - UpdatedMessage, -} from '../../../types'; +import type { LocalMessage, MessageRequest, SendMessageOptions } from '../../../types'; import type { MessageComposer } from '../../messageComposer'; export type MessageComposerMiddlewareState = { - message: MessageRequest | UpdatedMessage; + message: MessageRequest; localMessage: LocalMessage; sendOptions: SendMessageOptions; }; diff --git a/src/types.ts b/src/types.ts index b7c55cda9a..8e9a4b3417 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,15 +2,11 @@ import type { AxiosRequestConfig, AxiosResponse } from 'axios'; import type { StableWSConnection } from './connection'; import type { CustomEventTypes } from './custom_types'; import type { NotificationManager } from './notifications'; -import type { RESERVED_UPDATED_MESSAGE_FIELDS } from './constants'; import type { APIError, Attachment, - AutomodDetailsResponse, ChannelConfigWithInfo, - ChannelMemberResponse, ChannelOwnCapability, - ChannelResponse, ChannelStateResponseFields, CreateDeviceRequest, DraftPayloadResponse, @@ -26,7 +22,6 @@ import type { QueryUsersPayload, ReactionResponse, SearchPayload, - SearchWarning, SendMessageRequest, SendMessageResponse, SharedLocationResponseData, @@ -67,24 +62,17 @@ export type Unpacked = T extends (infer U)[] * Response Types */ +/** + * Legacy response envelope. Every generated response already carries `duration`, and the + * transport wraps results in `StreamResponse`, which also carries `metadata`. + * + * Only still referenced by the hand-written `/moderation/*` methods on `StreamChat` that + * bypass the generated client. Delete this together with those. + */ export type APIResponse = { duration: string; }; -export type FlagDetails = { - automod?: AutomodDetailsResponse; -}; - -export type Flag = { - created_at: string; - created_by_automod: boolean; - updated_at: string; - details?: FlagDetails; - target_message?: MessageResponse; - target_user?: UserResponse; - user?: UserResponse; -}; - export type ChannelUpdateOptions = Omit; export type ConnectAPIResponse = Promise; @@ -162,34 +150,7 @@ export type OwnUserBase = Pick< Exclude >; -export type ReactionAPIResponse = APIResponse & { - message: MessageResponse; - reaction: ReactionResponse; -}; - -export type SearchAPIResponse = APIResponse & { - results: { - message: MessageResponse; - }[]; - next?: string; - previous?: string; - results_warning?: SearchWarning | null; -}; - // Thumb URL(thumb_url) is added considering video attachments as the backend will return the thumbnail in the response. -export type SendFileAPIResponse = APIResponse & { file: string; thumb_url?: string }; - -export type UpdateChannelAPIResponse = APIResponse & { - channel: ChannelResponse; - members: ChannelMemberResponse[]; - message?: MessageResponse; -}; - -export type UsersAPIResponse = APIResponse & { - users: Array; - membership_deletion_task_id?: string; -}; - export type BanUserOptions = UnBanUserOptions & { ban_from_future_channels?: boolean; banned_by?: UserResponse; @@ -465,14 +426,6 @@ export type Configs = Record; export type ConnectionOpen = EventPayload<'health.check'> | EventPayload<'connection.ok'>; -export type MessageLabel = - | 'deleted' - | 'ephemeral' - | 'error' - | 'regular' - | 'reply' - | 'system'; - export type SendMessageOptions = Omit; export type PermissionObject = { @@ -488,24 +441,6 @@ export type TokenOrProvider = null | string | TokenProvider | undefined; export type TokenProvider = () => Promise; -export type ReservedUpdatedMessageFields = keyof typeof RESERVED_UPDATED_MESSAGE_FIELDS; - -export type UpdatedMessage = Omit< - MessageResponse, - ReservedUpdatedMessageFields | 'mentioned_groups' -> & { - mentioned_users?: string[]; - mentioned_channel?: boolean; - mentioned_here?: boolean; - mentioned_group_ids?: string[]; - mentioned_roles?: string[]; - type?: MessageLabel; -}; - -export type TaskResponse = { - task_id: string; -}; - export type MessageSetType = 'latest' | 'current' | 'new'; export class StreamAPIError extends Error { diff --git a/src/utils.ts b/src/utils.ts index 468621640d..f8f191454d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,7 +6,6 @@ import type { OwnUserResponse, ReactionGroupResponse, ReactionResponse, - UpdatedMessage, UserResponse, } from './types'; import type { StreamChat } from './client'; @@ -445,7 +444,7 @@ export const localMessageToNewMessagePayload = ( export const toUpdatedMessagePayload = ( message: LocalMessage | Partial, -): UpdatedMessage => { +): MessageRequest => { const reservedKeys = { ...RESERVED_UPDATED_MESSAGE_FIELDS, ...LOCAL_MESSAGE_FIELDS, @@ -455,7 +454,7 @@ export const toUpdatedMessagePayload = ( Object.entries(message).filter( ([key]) => !reservedKeys[key as keyof typeof reservedKeys], ), - ) as UpdatedMessage; + ) as MessageRequest; return { ...messageFields, diff --git a/test/unit/reactions.optimistic.test.ts b/test/unit/reactions.optimistic.test.ts index af673991cf..02aa58039a 100644 --- a/test/unit/reactions.optimistic.test.ts +++ b/test/unit/reactions.optimistic.test.ts @@ -5,9 +5,9 @@ import type { Channel, Event, MessageResponse, - ReactionAPIResponse, ReactionGroupResponse, ReactionResponse, + SendReactionResponse, } from '../../src'; import { generateUUIDv4 as uuidv4 } from '../../src/utils'; import { MockOfflineDB } from './offline-support/MockOfflineDB'; @@ -84,7 +84,7 @@ const ownReactionTypes = (paginator: Channel['messagePaginator'], id: string) => (paginator.getItem(id)?.own_reactions ?? []).map((reaction) => reaction.type); const apiReactionResponse = (message: MessageResponse) => - ({ duration: '0.0ms', message, reaction: {} }) as unknown as ReactionAPIResponse; + ({ duration: '0.0ms', message, reaction: {} }) as unknown as SendReactionResponse; const networkError = () => new Error('network down'); diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index 9c98482c4f..74697a77eb 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -559,7 +559,7 @@ The following v9 helper types are removed from the public surface. They mostly s `Readable`, `KnownKeys`, `PartializeKeys`, `UnknownType`, `MessageResponseBase`, `LocalMessageBase`, `FormatMessageResponse`, `ChannelAPIResponse` variants, `QueryChannelsAPIResponse`, `QueryReactionsOptions`/`QueryReactionsAPIResponse`, `TranslateResponse`, `ModerationResult`, `AutomodDetails`, `FlagsResponse`, `MessageFlagsResponse`, `FlagReport(s)Response`, `ReviewFlagReportResponse`, `BannedUsersResponse`, `FutureChannelBan(s)Response`, `HookEvent(s)Response`, `CheckPush/SQS/SNSResponse`, `CommandResponse` family, `ExportChannel*`/`ExportUsers*` types, push-preference types (`ChatLevelPushPreference`, `CallLevelPushPreference`, `PushPreferenceLevel`, `ChatPreferences`, `PushPreference`). -For any of these that survive as a generated shape, the replacement is the generator's `Gen_*` re-export (re-exported through `./types` or `./gen/models`). For the type utilities (`Readable`, `KnownKeys`, `PartializeKeys`, `UnknownType`) there is no replacement — inline the built-in equivalent or drop the constraint. +For any of these that survive as a generated shape, the replacement is the generator's `Gen_*` re-export (re-exported through `./types` or `./gen/models`). The `APIResponse`-based aliases that outlived v9 (`SearchAPIResponse`, `SendFileAPIResponse`, `UpdateChannelAPIResponse`, `UsersAPIResponse`, `ReactionAPIResponse`, `TaskResponse`, `GetRepliesAPIResponse`, `Flag`, `FlagDetails`) are removed after `10.0.0-rc.4` — see [the `APIResponse` envelope](./v9-to-v10-migration-guide-type-renames.md#the-apiresponse-envelope). For the type utilities (`Readable`, `KnownKeys`, `PartializeKeys`, `UnknownType`) there is no replacement — inline the built-in equivalent or drop the constraint. > **Larger topic** — the `types.ts` cleanup (~4.6k lines removed, most hand-rolled response/request types replaced by generated shapes) is large enough that a per-type cheat sheet ("`FormatMessageResponse` → …", "`FlagReportsResponse` → …", …) may deserve its own guide. Flag me if you want one written. diff --git a/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index c1c20fa91e..3189eec193 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -253,6 +253,45 @@ Two of those change shape: `ChannelUpdateOptions` and the `*Filters` family are deliberately **kept**: they were already derived (`Omit`, `NonNullable`), so they update themselves when the spec moves and restate nothing. +### The `APIResponse` envelope + +`APIResponse` was `{ duration: string }` — the response envelope from before the generated layer existed. Every generated response already carries `duration`, and the transport wraps results in `StreamResponse`, which carries `metadata` (rate-limit headers, response code, client request id) as well. Anything typed with `APIResponse` was therefore not just redundant but **weaker** than the real return type. + +| Removed after `rc.4` | Replacement | Detail | +| -------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------- | +| `SearchAPIResponse` | `SearchResponse` | `results` entries are `SearchResult`, not an inline `{ message }`. | +| `SendFileAPIResponse` | `FileUploadResponse` / `ImageUploadResponse` | Pick the one matching the endpoint you called. | +| `UpdateChannelAPIResponse` | `UpdateChannelResponse` | | +| `UsersAPIResponse` | `UpdateUsersResponse` / `QueryUsersResponse` | Two endpoints shared this alias; pick by endpoint. | +| `TaskResponse` | the endpoint's own response type | Was `{ task_id: string }`; the generated responses name the field the same way. | +| `ReactionAPIResponse` | `SendReactionResponse` / `DeleteReactionResponse` | Was one alias for two endpoints. | +| `Flag`, `FlagDetails` | `FlagDetailsResponse` | Neither had a reference anywhere in `src` — they only referred to each other. | + +Every replacement is reached through `StreamResponse<…>` when it is a method return value, so it gains a required `metadata` field. Code that only destructures the payload (`const { message } = await …`) is unaffected; code that annotates a variable with the old alias needs the new name. + +**Still present, deliberately:** `APIResponse` itself, plus `FlagMessageResponse`, `FlagUserResponse`, `MuteUserResponse` and `UnmuteUserResponse`. Every remaining reference to these sits inside the hand-written `/moderation/*` methods on `StreamChat` that bypass the generated client; they are removed together with those methods, which is tracked separately. + +### `UpdatedMessage` → `MessageRequest` + +`UpdatedMessage` built a **request** type by subtracting a hand-maintained constant (`RESERVED_UPDATED_MESSAGE_FIELDS`) from a **response** type (`MessageResponse`), then re-adding the `mentioned_*` fields. The generated `MessageRequest` already is that shape, and gets two things right that `UpdatedMessage` did not: + +- **`type` is narrower.** `MessageRequest['type']` is `'regular' | 'system'`. `UpdatedMessage['type']` was `MessageLabel` — six members including `'deleted'`, `'error'`, `'ephemeral'` and `'reply'`, none of which a client may send. +- **Server-owned fields no longer typecheck.** Anything on `MessageResponse` that was not in the reserved list — `cid`, `shadowed`, `reaction_groups`, and so on — was assignable to an update payload. It is not on `MessageRequest`. + +```ts +// before +import type { UpdatedMessage } from 'stream-chat'; +const payload: UpdatedMessage = { id, text, type: 'reply' }; // compiled, and was wrong + +// after +import type { MessageRequest } from 'stream-chat'; +const payload: MessageRequest = { id, text }; // `type: 'reply'` is now a compile error +``` + +`MessageLabel` and `ReservedUpdatedMessageFields` are removed with it. The **runtime** constant `RESERVED_UPDATED_MESSAGE_FIELDS` stays — `toUpdatedMessagePayload()` still uses it to strip server-owned keys off a `LocalMessage`; it just no longer drives a type. + +`MessageComposerMiddlewareState.message` is now `MessageRequest` rather than `MessageRequest | UpdatedMessage`. Custom composer middleware that annotated the union should drop the `UpdatedMessage` arm. + ## Verification After applying the renames, `yarn types` should pass. If a call site errors with `Cannot find name 'X'` where X is one of the v9 names in the left column, the rewrite is incomplete. From 8c344ad7d0f8ef0b88e871c89032291912229a88 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 19 Aug 2026 16:36:44 -0500 Subject: [PATCH 06/17] refactor: remove redundant guards and pass-through methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six methods either forwarded their whole body to another one or ran a runtime check that restated something the type system already enforces. Each was a signature that had to be re-checked by hand after a regeneration in exchange for nothing. BREAKING CHANGES: * `client.queryBannedUsers(...)` is removed. Its entire body was `return await super.queryBannedUsers(...args)` and it was not marked `override`, so the inherited `ChatApi.queryBannedUsers` you were already reaching is unchanged. No call-site change needed. * `client.partialUpdateThread(messageId, partialThreadObject, requestOptions?)` is removed. Use `client.updateThreadPartial({ message_id, set, unset }, options?)`. The `PartialThreadUpdate` type goes with it — `UpdateThreadPartialRequest` is the replacement. - The reserved-field guard is gone, and it was wrong in both directions. It rejected `id`, `type`, `user` and `participants` — none of which are fields on `ThreadResponse`, so legitimate custom fields with those names were blocked — while letting through `parent_message_id`, `channel_cid`, `created_by_user_id`, `thread_participants`, `reply_count`, `participant_count`, `active_participant_count` and `deleted_at`, all of which ARE server-owned. A rejected write now surfaces as a rejected promise instead of a synchronous `throw`; adjust any try/catch that expected the latter. - The empty-`messageId` check is gone. `message_id` is required on `UpdateThreadPartialRequest`, so it is a compile error now. * `channel.search(...)` is removed. Use `client.search(...)`. The removed method forwarded to `client.search()` WITHOUT scoping the query to the channel — despite the name it searched every channel the user could see. `client.search()` is the identical call. If you assumed it was channel-scoped, add the scope to your filter; that is a bug fix in the integration, not a regression here. * `channel.getReplies(...)` is removed. Use `client.getReplies(...)`. Pure forward — the removed method's own comment noted it did nothing with the result. * `channel.getReactions(...)` is removed. Use `client.getReactions(...)`. Pure forward. * `channel.sendAction(...)` is kept, but its `Message ID is missing` guard is gone. `runMessageAction` requires `id: string`, so an empty id is a compile error; an empty string at runtime reaches the server and is rejected there. Internal: `MessageIntervalPaginator` now calls `channel.getClient().getReplies(...)` directly. Unit tests that stubbed `channel.getReplies` or `channel.search` were retargeted at the client, which is the real seam. Co-Authored-By: Claude Opus 5 (1M context) --- src/channel.ts | 45 ------------- src/client.ts | 66 ------------------- .../paginators/MessageIntervalPaginator.ts | 6 +- src/types.ts | 6 -- test/unit/channel.test.js | 19 +++--- .../paginators/MessagePaginator.test.ts | 19 ++++-- test/unit/threads.test.ts | 8 +-- v9-to-v10-migration-guide-methods.md | 34 ++++++++++ v9-to-v10-migration-guide-type-renames.md | 4 ++ 9 files changed, 71 insertions(+), 136 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index f55b463ee4..14fc17c077 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -554,18 +554,6 @@ export class Channel extends ChannelApi { return await super.sendEvent(request, requestOptions); } - /** - * Queries messages. - * - * @param ...args - `[request, requestOptions]`. The optional `request.payload` accepts - * MongoDB-style filters and additional options such as `user_id`. `requestOptions` carries - * per-request options such as an abort `signal` and is never serialized into the request. - * @returns The search messages response. - */ - async search(...args: Parameters) { - return await this.getClient().search(...args); - } - /** * Queries members. * @@ -1147,9 +1135,6 @@ export class Channel extends ChannelApi { requestOptions?: StreamRequestOptions, ) { this._checkInitialized(); - if (!messageId) { - throw Error(`Message ID is missing`); - } return this.getClient().runMessageAction( { id: messageId, @@ -1501,36 +1486,6 @@ export class Channel extends ChannelApi { return response; } - /** - * List the message replies for a parent message. - * - * The recommended way of working with threads is to use the `Thread` class. - * - * @param ...args - `[request, requestOptions]`. `request` holds the parent message ID, pagination - * params, and optional sort directions for `created_at`; `requestOptions` carries per-request - * options such as an abort `signal` and is never serialized into the request. - * @returns A response with a list of messages. - */ - async getReplies(...args: Parameters) { - const data = await this.getClient().getReplies(...args); - - // Thread reply state is owned by the Thread object (Thread.messagePaginator); the returned - // replies are consumed there. The channel message list is owned by channel.messagePaginator. - return data; - } - - /** - * List the reactions; supports pagination. - * - * @param ...args - `[request, requestOptions]`. `request` holds the target message ID and - * pagination options (`limit`, `offset`); `requestOptions` carries per-request options such as - * an abort `signal` and is never serialized into the request. - * @returns The server response. - */ - getReactions(...args: Parameters) { - return this.getClient().getReactions(...args); - } - /** * Retrieves a list of messages by ID. * diff --git a/src/client.ts b/src/client.ts index 8ea52c468a..49a881f89f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -45,7 +45,6 @@ import type { MuteUserResponse, OwnUserResponse, PartializeAllBut, - PartialThreadUpdate, QueryChannelsRequest, QueryChannelsResponse, QueryReactionsRequestWithId, @@ -1218,21 +1217,6 @@ export class StreamChat extends ChatApi { return data; } - /** - * Queries user bans. - * - * @param ...args - `[request, requestOptions]`. The optional `request.payload` accepts - * MongoDB-style filter conditions, sort directions - * (e.g. `[{ field: 'created_at', direction: 1 }]`), and options such as `limit`, `offset`, - * and `exclude_expired_bans`. `requestOptions` carries per-request options such as an abort - * `signal` and is never serialized into the request. - * @returns The ban query response. - */ - async queryBannedUsers(...args: Parameters) { - // Return a list of user bans - return await super.queryBannedUsers(...args); - } - /** * Queries channels and returns the full API response including top-level metadata such as * `predefined_filter`. @@ -2174,56 +2158,6 @@ export class StreamChat extends ChatApi { return new Thread({ client: this, threadData: response.thread }); } - /** - * Updates the given thread. - * - * @param messageId - The ID of the thread message which needs to be updated. - * @param partialThreadObject - Should contain `set` or `unset` params for any of the thread's non-reserved fields. - * @param requestOptions - Per-request options such as an abort `signal`. Never serialized - * into the request (optional). - * @returns The updated thread. - */ - async partialUpdateThread( - messageId: string, - partialThreadObject: PartialThreadUpdate, - requestOptions?: StreamRequestOptions, - ) { - if (!messageId) { - throw Error('Please specify the message id when calling partialUpdateThread'); - } - - // check for reserved fields from ThreadResponse type within partialThreadObject's set and unset. - // Throw error if any of the reserved field is found. - const reservedThreadFields = [ - 'created_at', - 'id', - 'last_message_at', - 'type', - 'updated_at', - 'user', - 'reply_count', - 'participants', - 'channel', - 'custom', - ]; - - for (const key in { ...partialThreadObject.set, ...partialThreadObject.unset }) { - if (reservedThreadFields.includes(key)) { - throw Error( - `You cannot set ${key} field on Thread object. ${key} is reserved for server-side use. Please omit ${key} from your set object.`, - ); - } - } - - return await this.updateThreadPartial( - { - message_id: messageId, - ...partialThreadObject, - }, - requestOptions, - ); - } - getUserAgent = (): string => { // An explicit override (deprecated `setUserAgent`) always wins and is never cached. if (this.userAgent) { diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 1121d868d0..08e0569f64 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -421,7 +421,7 @@ export class MessageIntervalPaginator extends BasePaginator< : undefined; } else { const { messages } = this.parentMessageId - ? await this.channel.getReplies({ + ? await this.channel.getClient().getReplies({ parent_id: this.parentMessageId, ...options, sort: this.requestSort, @@ -812,7 +812,9 @@ export class MessageIntervalPaginator extends BasePaginator< private async hasMessagesOlderThan(id: string): Promise { const pagination = { limit: 1, id_lt: id } as MessagePaginationParams; const { messages } = this.parentMessageId - ? await this.channel.getReplies({ parent_id: this.parentMessageId, ...pagination }) + ? await this.channel + .getClient() + .getReplies({ parent_id: this.parentMessageId, ...pagination }) : await this.channel.query({ messages: pagination }); return Array.isArray(messages) && messages.length > 0; } diff --git a/src/types.ts b/src/types.ts index 8e9a4b3417..95a9cf5d12 100644 --- a/src/types.ts +++ b/src/types.ts @@ -117,12 +117,6 @@ export type LocalMessage = MessageResponse & { user_id?: string; }; -// TODO: Figure out a way to strongly type set and unset. -export type PartialThreadUpdate = { - set?: Partial>; - unset?: Array; -}; - export type GetThreadOptions = Omit[0], 'message_id'>; export type MuteUserResponse = APIResponse & { diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 583a461aaf..132cd20d85 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -2608,19 +2608,20 @@ describe('event subscription and unsubscription', () => { expect(channel.listeners.get('all')?.size ?? 0).to.be.equal(0); }); }); -describe('Channel search', async () => { +describe('Message search', async () => { const client = await getClientWithUser(); - const channel = client.channel('messaging', uuidv4()); - // search now takes a single request object `{ payload }` and forwards the payload straight to - // the generated ChatApi.search (GET /search) via client.api.sendRequest. Sort normalization is - // no longer done inside search, so the caller passes the already-shaped `{ field, direction }`. + // `client.search()` was removed - it forwarded to `client.search()` without scoping the query + // to the channel, so it was a delegate with a misleading name. Search takes a single request + // object `{ payload }` and forwards the payload straight to the generated ChatApi.search + // (GET /search) via client.api.sendRequest. Sort normalization is not done inside search, so + // the caller passes the already-shaped `{ field, direction }`. it('search with sorting by defined field', async () => { const sendRequest = vi .spyOn(client.api, 'sendRequest') .mockResolvedValue({ body: {}, metadata: {} }); const payload = { query: 'query', sort: [{ field: 'updated_at', direction: -1 }] }; - await channel.search({ payload }); + await client.search({ payload }); expect(sendRequest).toHaveBeenCalledWith( 'GET', '/api/v2/chat/search', @@ -2636,7 +2637,7 @@ describe('Channel search', async () => { .spyOn(client.api, 'sendRequest') .mockResolvedValue({ body: {}, metadata: {} }); const payload = { query: 'query', sort: [{ field: 'custom_field', direction: -1 }] }; - await channel.search({ payload }); + await client.search({ payload }); expect(sendRequest).toHaveBeenCalledWith( 'GET', '/api/v2/chat/search', @@ -2650,7 +2651,7 @@ describe('Channel search', async () => { it('sorting and offset works', async () => { vi.spyOn(client.api, 'sendRequest').mockResolvedValue({ body: {}, metadata: {} }); await expect( - channel.search({ + client.search({ payload: { query: 'query', offset: 1, @@ -2661,7 +2662,7 @@ describe('Channel search', async () => { }); it('next and offset fails', async () => { await expect( - channel.search({ payload: { query: 'query', offset: 1, next: 'next' } }), + client.search({ payload: { query: 'query', offset: 1, next: 'next' } }), ).rejects.toThrow(); }); }); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index c2f08fbc0c..b59ea2f46b 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -32,6 +32,9 @@ describe('MessagePaginator', () => { getReplies: vi.fn(), query: vi.fn(), } as unknown as Channel; + // The paginator calls `channel.getClient().getReplies(...)`; point that at the same spy so + // the assertions below keep reading `channel.getReplies`. + (channel as unknown as { getClient: () => unknown }).getClient = () => channel; itemIndex = new StoreBackedItemIndex({ getEntityId: (message) => message.id, }); @@ -435,6 +438,7 @@ describe('MessagePaginator', () => { (channel as unknown as { getClient: () => unknown }).getClient = () => ({ user: undefined, notifications: { addError: () => {} }, + getReplies: channel.getReplies, }); (channel.query as unknown as ReturnType).mockResolvedValue({ messages: [ @@ -468,6 +472,7 @@ describe('MessagePaginator', () => { (channel as unknown as { getClient: () => unknown }).getClient = () => ({ user: undefined, notifications: { addError: () => {} }, + getReplies: channel.getReplies, }); (channel.query as unknown as ReturnType).mockResolvedValue({ messages: [ @@ -540,6 +545,7 @@ describe('MessagePaginator', () => { // postQueryReconcile reads the client to take an unread snapshot; no user => snapshot skipped. (channel as unknown as { getClient: () => unknown }).getClient = () => ({ user: undefined, + getReplies: channel.getReplies, }); // No newer messages exist on the server → the headward query returns an empty page. (channel.getReplies as unknown as ReturnType).mockResolvedValue({ @@ -1126,6 +1132,7 @@ describe('MessagePaginator', () => { beforeEach(() => { (channel as unknown as { getClient: () => unknown }).getClient = () => ({ userID: currentUserId, + getReplies: channel.getReplies, }); }); @@ -1387,6 +1394,7 @@ describe('MessagePaginator', () => { // First-page reconcile reads the client for the unread snapshot; no user => snapshot skipped. (channel as unknown as { getClient: () => unknown }).getClient = () => ({ user: undefined, + getReplies: channel.getReplies, }); const paginator = new MessagePaginator({ channel, itemIndex }); // Fewer messages than the requested page size => dataset edges reached both ways. @@ -1400,6 +1408,7 @@ describe('MessagePaginator', () => { it('seeds an around/jump open as a middle window, not the head', () => { (channel as unknown as { getClient: () => unknown }).getClient = () => ({ user: undefined, + getReplies: channel.getReplies, }); const paginator = new MessagePaginator({ channel, itemIndex }); // A full page centered on m6: messages exist on both sides beyond this window, so the @@ -2750,11 +2759,12 @@ describe('MessagePaginator', () => { it('mirrors reconciled ghosts into the offline DB via the DB batch API (LLC-owned)', () => { const hardDeleteMessages = vi.fn().mockResolvedValue([]); + const getReplies = vi.fn(); const channelWithOfflineDb = { cid: 'channel-id', - getReplies: vi.fn(), + getReplies, query: vi.fn(), - getClient: () => ({ offlineDb: { hardDeleteMessages } }), + getClient: () => ({ offlineDb: { hardDeleteMessages }, getReplies }), } as unknown as Channel; const paginator = new MessagePaginator({ channel: channelWithOfflineDb, @@ -2800,11 +2810,12 @@ describe('MessagePaginator', () => { // The plain-seed branch runs seedUnreadSnapshot (reads getClient().user); give the channel a // benign client with no current user so it no-ops instead of throwing on the bare mock. + const reconcileGetReplies = vi.fn(); const reconcileChannel = { cid: 'channel-id', - getReplies: vi.fn(), + getReplies: reconcileGetReplies, query: vi.fn(), - getClient: () => ({ user: undefined }), + getClient: () => ({ user: undefined, getReplies: reconcileGetReplies }), } as unknown as Channel; const makePaginator = () => diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 4113f778b4..70996a2fc3 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -734,9 +734,9 @@ describe('Threads 2.0', () => { const older = makeReply({ created_at: '2020-01-02T00:00:00.000Z' }); const getRepliesStub = sinon - .stub(thread.channel, 'getReplies') + .stub(thread.channel.getClient(), 'getReplies') .resolves({ messages: [older], duration: '' } as unknown as ReturnType< - Channel['getReplies'] + StreamChat['getReplies'] >); await thread.messagePaginator.toTail(); @@ -755,9 +755,9 @@ describe('Threads 2.0', () => { const older = makeReply({ created_at: '2020-01-02T00:00:00.000Z' }); sinon - .stub(thread.channel, 'getReplies') + .stub(thread.channel.getClient(), 'getReplies') .resolves({ messages: [older], duration: '' } as unknown as ReturnType< - Channel['getReplies'] + StreamChat['getReplies'] >); await thread.messagePaginator.toTail(); diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index e6c71f6e88..15dc39b7f8 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -1358,6 +1358,40 @@ type CustomMarkReadRequestFn = ( The `Partial<>` is deliberate: it lets a handler return just `{ event }` without fabricating a `duration`, and it means a handler can delegate straight to the SDK — `markReadRequest: ({ channel, options }) => channel.markRead(options)` — which the `rc` signature rejected because `MarkReadResponse.event` is optional. `CustomThreadMarkReadRequestFn` takes `{ thread, options? }` instead of `{ channel, options? }` and additionally permits a `void` return. +## Removed after `10.0.0-rc.4` — redundant guards and pass-throughs + +Skip this section if you are upgrading from v9 only in the sense that these methods still existed in `10.0.0-rc.4`; if you are on v9 they are simply gone in v10 final. Each was either a method whose entire body forwarded to another one, or a runtime check that restated something the type system already enforces. + +### `StreamChat` + +- **`client.queryBannedUsers(...)` — REMOVED.** Its whole body was `return await super.queryBannedUsers(...args)`; it was not even marked `override`. The inherited `ChatApi.queryBannedUsers` is unchanged, so **no call-site change is needed** — you were already reaching this implementation. + +- **`client.partialUpdateThread(messageId, partialThreadObject, requestOptions?)` — REMOVED.** Use the inherited `client.updateThreadPartial({ message_id, set, unset }, requestOptions?)`. + + ```ts + // before + await client.partialUpdateThread(messageId, { set: { custom_field: 1 } }); + + // after + await client.updateThreadPartial({ message_id: messageId, set: { custom_field: 1 } }); + ``` + + Two behaviour changes come with it: + 1. **The reserved-field guard is gone.** It threw synchronously for keys in a hardcoded list, and that list had drifted from `ThreadResponse` in both directions: it rejected `id`, `type`, `user` and `participants` (none of which are fields on `ThreadResponse`, so legitimate custom fields with those names were blocked) while letting through `parent_message_id`, `channel_cid`, `created_by_user_id`, `thread_participants`, `reply_count`, `participant_count`, `active_participant_count` and `deleted_at`, all of which _are_ server-owned. The server rejects what it owns; the client no longer guesses. **A rejected write now surfaces as a rejected promise rather than a synchronous `throw`** — adjust any `try`/`catch` that wrapped the call expecting the latter. + 2. **The empty-`messageId` check is gone.** `message_id` is a required field on `UpdateThreadPartialRequest`, so this is a compile error instead. + + The `PartialThreadUpdate` type is removed with the method — `UpdateThreadPartialRequest` is its replacement. + +### `Channel` + +- **`channel.search(...)` — REMOVED.** Use `client.search(...)`. The removed method forwarded straight to `client.search()` **without scoping the query to the channel**, so despite the name it searched every channel the user could see. If you were relying on that behaviour, `client.search()` is the same call. If you assumed it was channel-scoped, add the scope to your filter explicitly — that is a real bug fix in your integration, not a regression. + +- **`channel.getReplies(...)` — REMOVED.** Use `client.getReplies(...)`. Pure forward; the removed method's own comment noted it did nothing with the result. + +- **`channel.getReactions(...)` — REMOVED.** Use `client.getReactions(...)`. Pure forward. + +- **`channel.sendAction(messageId, formData, requestOptions?)` — KEPT**, but its `if (!messageId) throw Error('Message ID is missing')` guard is gone. `runMessageAction` requires `id: string`, so an empty id is a compile error; a runtime empty string reaches the server and is rejected there. + ## Logging (applies to every class) `options.logger` (function) and `client.logger(level, msg, extra?)` are gone. To capture logs in v10, configure the shared `chatLoggerSystem` before constructing the client: diff --git a/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index 3189eec193..298ebcae27 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -292,6 +292,10 @@ const payload: MessageRequest = { id, text }; // `type: 'reply'` is now a compil `MessageComposerMiddlewareState.message` is now `MessageRequest` rather than `MessageRequest | UpdatedMessage`. Custom composer middleware that annotated the union should drop the `UpdatedMessage` arm. +### `PartialThreadUpdate` removed + +`PartialThreadUpdate` (`{ set?: Partial>; unset?: Array }`) went with `client.partialUpdateThread`. Use the generated `UpdateThreadPartialRequest`, which is the same `set` / `unset` pair plus the required `message_id`. See [redundant guards and pass-throughs](./v9-to-v10-migration-guide-methods.md#removed-after-1000-rc4--redundant-guards-and-pass-throughs). + ## Verification After applying the renames, `yarn types` should pass. If a call site errors with `Cannot find name 'X'` where X is one of the v9 names in the left column, the rewrite is incomplete. From ac4d54821e2e218ddacc45929290651c99ffc54e Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 20 Aug 2026 08:26:13 -0500 Subject: [PATCH 07/17] refactor: reinstate GiphyVersions and MessageLabel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sweep of `stream-chat-react` and `stream-chat-react-native` for every type removed earlier on this branch found that two of them have real downstream consumers and no generated equivalent. Removing them does not delete a hand-written type, it relocates one into two repos — the opposite of the goal. * `GiphyVersions` is `keyof Images`, derived from the generated attachment-images shape, so it cannot drift. It is the same pattern as `PushProvider` (`CreateDeviceRequest['push_provider']`), which was deliberately kept for exactly this reason, so removing this one was inconsistent. `stream-chat-react` exposes it on two public types — `AttachmentProps.giphyVersion` and `AttachmentContextValue.giphyVersion` — across 6 sites in 3 files. * `MessageLabel` has no generated substitute: `MessageResponse['type']` is a bare `string`, so every replacement widens rather than narrows. It was removed only as collateral of the `UpdatedMessage` retirement, and both SDKs use it as a discriminant — `stream-chat-react-native` types its SQLite message and draft-message rows with it (4 files), `stream-chat-react` types the `DateSeparatorMessage` arm of its exported `RenderedMessage` union with it. `CommandVariants` stays removed. Unlike these two it is genuinely hand-written — eight literals plus `keyof CustomCommandData`, with no generated backing — so it is what this effort targets. Its two React Native call sites are a cast on a `string` and an icon-name prop, both better served locally. This reverses part of two earlier commits on this branch; nothing was released in between. BREAKING CHANGES: * None. This restores two previously-removed exports; it removes nothing and narrows nothing. Notes on what did NOT come back: * `UpdatedMessage` stays removed, and `MessageLabel` is still not valid as a write payload type — `MessageRequest['type']` is `'regular' | 'system'`. `MessageLabel` is for typing `type` values on the read side only. * `ReservedUpdatedMessageFields` stays removed. The runtime constant `RESERVED_UPDATED_MESSAGE_FIELDS` is unaffected. Co-Authored-By: Claude Opus 5 --- src/types.ts | 21 +++++++++++++++++++++ v9-to-v10-migration-guide-type-renames.md | 8 ++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/types.ts b/src/types.ts index 95a9cf5d12..5185f5c2b0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,7 @@ import type { ChannelStateResponseFields, CreateDeviceRequest, DraftPayloadResponse, + Images, MessageResponse, ModerationPayload, OwnUserResponse, @@ -420,6 +421,21 @@ export type Configs = Record; export type ConnectionOpen = EventPayload<'health.check'> | EventPayload<'connection.ok'>; +/** + * The message `type` values the server can return. + * + * Hand-written because there is no generated equivalent: `MessageResponse['type']` is a bare + * `string`, so deriving it would widen rather than narrow. Both the React and React Native + * SDKs use this as a discriminant (RN types its SQLite message rows with it). + */ +export type MessageLabel = + | 'deleted' + | 'ephemeral' + | 'error' + | 'regular' + | 'reply' + | 'system'; + export type SendMessageOptions = Omit; export type PermissionObject = { @@ -616,6 +632,11 @@ export type DeleteMessageOptions = Omit[0], export type SendMessageAPIResponse = StreamResponse; export type UpdateMessageOptions = Omit; export type UpdateMessageAPIResponse = StreamResponse; +/** + * The Giphy rendition names, derived from the generated `Images` shape so the two cannot + * drift. Consumed by the React and React Native SDKs to pick a rendition size. + */ +export type GiphyVersions = keyof Images; export type TranslationLanguage = TranslateMessageRequest['language']; export type FileReferenceBase = { diff --git a/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index 298ebcae27..ce900be8da 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -199,7 +199,7 @@ Neither is a rename; both **gain** surface, so no call site breaks. These described admin/server-side surface (push-provider credentials, permission policies, blocklists, channel-type config) that moved to `@stream-io/node-sdk` when the server-side API left this package. Nothing in the SDK referenced them and no endpoint here returns them. -`APNConfig`, `AsyncModerationOptions`, `BlockList`, `CommandVariants`, `FirebaseConfig`, `GetRepliesRequest`, `GiphyVersions`, `HuaweiConfig`, `Policy`, `PolicyRequest`, `Product`, `PushProviderAPN`, `PushProviderCommon`, `PushProviderConfig`, `PushProviderFirebase`, `PushProviderHuawei`, `PushProviderID`, `PushProviderXiaomi`, `UR`, `VotesFiltersOptions`, `XiaomiConfig`. +`APNConfig`, `AsyncModerationOptions`, `BlockList`, `CommandVariants`, `FirebaseConfig`, `GetRepliesRequest`, `HuaweiConfig`, `Policy`, `PolicyRequest`, `Product`, `PushProviderAPN`, `PushProviderCommon`, `PushProviderConfig`, `PushProviderFirebase`, `PushProviderHuawei`, `PushProviderID`, `PushProviderXiaomi`, `UR`, `VotesFiltersOptions`, `XiaomiConfig`. Two notes: @@ -208,6 +208,8 @@ Two notes: `PushProvider` is **kept** — it is `CreateDeviceRequest['push_provider']`, the union `client.createDevice()` accepts, and it derives from the generated request rather than restating it. +`GiphyVersions` is **kept** for the same reason — it is `keyof Images`, derived from the generated attachment-images shape, so it cannot drift. The React SDK exposes it on `AttachmentProps.giphyVersion` and `AttachmentContextValue.giphyVersion`. + ### `Automod` / `AutomodBehavior` narrowed Both now read their union off the generated channel config instead of restating it: @@ -288,7 +290,9 @@ import type { MessageRequest } from 'stream-chat'; const payload: MessageRequest = { id, text }; // `type: 'reply'` is now a compile error ``` -`MessageLabel` and `ReservedUpdatedMessageFields` are removed with it. The **runtime** constant `RESERVED_UPDATED_MESSAGE_FIELDS` stays — `toUpdatedMessagePayload()` still uses it to strip server-owned keys off a `LocalMessage`; it just no longer drives a type. +`ReservedUpdatedMessageFields` is removed with it. The **runtime** constant `RESERVED_UPDATED_MESSAGE_FIELDS` stays — `toUpdatedMessagePayload()` still uses it to strip server-owned keys off a `LocalMessage`; it just no longer drives a type. + +`MessageLabel` itself is **kept**. It is what `UpdatedMessage['type']` used to be, and there is no generated equivalent — `MessageResponse['type']` is a bare `string`, so deriving it would widen rather than narrow. It stays available for typing message `type` values on the read side; it is just no longer valid as a _write_ payload type. `MessageComposerMiddlewareState.message` is now `MessageRequest` rather than `MessageRequest | UpdatedMessage`. Custom composer middleware that annotated the union should drop the `UpdatedMessage` arm. From cf53a0798f9415c548bf2187c8f7cf33460dbb21 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 20 Aug 2026 12:49:24 -0500 Subject: [PATCH 08/17] refactor: move moderation onto the generated V2 API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/moderation/*` methods on `StreamChat` were the last endpoints in the SDK that built their own request instead of calling the generated client — nine hand-rolled `this.api.post(this.baseURL + '/moderation/...')` calls that had to be re-checked by hand after every regeneration. Four of them have a generated V2 equivalent, so the wrapper only reshaped arguments and is removed rather than rewritten. Four more had no caller in `stream-chat-react` or `stream-chat-react-native` and are dropped. One has no generated equivalent and stays. After this, the only code outside `src/gen` that builds its own request is `unbanUser` and the `/hi` telemetry ping. BREAKING CHANGES: * Removed from `StreamChat`; use the generated V2 method via `client.moderation`: - `banUser(id, options?)` -> `moderation.ban({ target_user_id: id, ...options })` - `muteUser(id, options?)` -> `moderation.mute({ target_ids: [id], ...options })` - `unmuteUser(id)` -> `moderation.unmute({ target_ids: [id] })` - `flagMessage(id, options?)` -> `moderation.flagMessage(id, reason?, options?)` Note mute/unmute take `target_ids` as an ARRAY; a single id becomes `[id]`. * Return types change. These resolve to `StreamResponse<…>` of the generated response, so they gain `metadata`. Two lose fields: the mute response no longer carries `mute` (singular) — use `mutes` — and the flag response is `FlagItemResponse { duration, item_id }` rather than a nested `flag` object. Code that only awaits these calls is unaffected; neither downstream SDK read them. * Two ban options are gone with no replacement: V2 `BanRequest` has no `delete_reactions` and no `ban_from_future_channels`. Verified unused by both downstream SDKs, neither of which passes any ban option at all. * `BanUserOptions` is now `Omit` — derived, so a spec change updates it instead of drifting past it. `MuteUserOptions` is removed (V2 mute accepts only `timeout`), as are `MessageDeletionStrategy`, `MuteUserResponse`, `FlagMessageResponse`, `FlagUserResponse` and `UnmuteUserResponse`. * Removed with no replacement, none of them used by either downstream SDK: `client.flagUser` (use `client.moderation.flagUser`), `client.unflagMessage`, `client.unflagUser` and `client.unblockMessage` (V2 has no unflag or unblock-message endpoint). * `shadowBan` / `removeShadowBan` are removed from BOTH `StreamChat` and `Channel`. They were sugar for a flag that is still public, so the capability is intact: `channel.banUser(id, { shadow: true })`, `channel.unbanUser(id, { shadow: true })`. * `channel.banUser` keeps its signature but its options no longer accept `channel_cid` — the channel sets it, so passing one was a silent no-op. * `Moderation.flagUser` / `Moderation.flagMessage` take `reason` as OPTIONAL now. `FlagRequest.reason` is optional, so requiring it positionally was stricter than the endpoint. Widening only; existing calls still compile. NOT removed, and why: * `client.unbanUser` and `channel.unbanUser` keep their v1 implementation. The generated layer has NO unban endpoint — `ChatApi` exposes only the reads (`queryBannedUsers`, `queryFutureChannelBans`) and V2 moderation has `ban` with no matching `unban`, verified by sweeping every generated endpoint URL. Ban and unban must target the same system, so both stay reachable until the spec publishes one. `APIResponse` and `UnBanUserOptions` survive for the same reason. This leaves a temporary asymmetry: `channel.banUser` scopes through V2's `channel_cid` while `channel.unbanUser` still scopes through v1's `type` + `id`. * `Moderation.unmuteUser` now calls the inherited `ModerationApi.unmute` instead of hand-posting to `/api/v2/moderation/unmute` — same URL, same body, same response shape. Co-Authored-By: Claude Opus 5 --- src/channel.ts | 38 +------- src/client.ts | 141 --------------------------- src/moderation.ts | 21 ++-- src/types.ts | 73 +------------- v9-to-v10-migration-guide-methods.md | 63 ++++++++++++ 5 files changed, 76 insertions(+), 260 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 14fc17c077..82e02294eb 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1780,12 +1780,12 @@ export class Channel extends ChannelApi { * @param options - Ban options. * @returns The server response. */ - async banUser(targetUserId: string, options: BanUserOptions) { + async banUser(targetUserId: string, options: Omit) { this._checkInitialized(); - return await this.getClient().banUser(targetUserId, { + return await this.getClient().moderation.ban({ ...options, - type: this.type, - id: this.id, + target_user_id: targetUserId, + channel_cid: this.cid, }); } @@ -1832,36 +1832,6 @@ export class Channel extends ChannelApi { }); } - /** - * Shadow bans a user from a channel. - * - * @param targetUserId - The user to shadow ban. - * @param options - Ban options. - * @returns The server response. - */ - async shadowBan(targetUserId: string, options: BanUserOptions) { - this._checkInitialized(); - return await this.getClient().shadowBan(targetUserId, { - ...options, - type: this.type, - id: this.id, - }); - } - - /** - * Removes the shadow ban for a user on a channel. - * - * @param targetUserId - The user to remove the shadow ban for. - * @returns The server response. - */ - async removeShadowBan(targetUserId: string) { - this._checkInitialized(); - return await this.getClient().removeShadowBan(targetUserId, { - type: this.type, - id: this.id, - }); - } - /** * Casts or cancels one or more votes on a poll. * diff --git a/src/client.ts b/src/client.ts index 49a881f89f..b4a7a2afeb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -23,7 +23,6 @@ import { normalizeUploadFile } from './upload-utils'; import type { APIResponse, AppIdentifier, - BanUserOptions, ChannelInput, ChannelMute, ChannelOptions, @@ -37,12 +36,8 @@ import type { EventHandler, EventType, FileUploadInput, - FlagMessageResponse, - FlagUserResponse, GetThreadOptions, LocalMessage, - MuteUserOptions, - MuteUserResponse, OwnUserResponse, PartializeAllBut, QueryChannelsRequest, @@ -1664,20 +1659,6 @@ export class StreamChat extends ChatApi { return channel; }; - /** - * Bans a user from all channels. - * - * @param targetUserId - The user to ban. - * @param options - Ban options (optional). - * @returns The server response. - */ - async banUser(targetUserId: string, options?: BanUserOptions) { - return await this.api.post(this.baseURL + '/moderation/ban', { - target_user_id: targetUserId, - ...options, - }); - } - /** * Revoke a global ban for a user. * @@ -1692,33 +1673,6 @@ export class StreamChat extends ChatApi { }); } - /** - * Shadow bans a user from all channels. - * - * @param targetUserId - The user to shadow ban. - * @param options - Ban options (optional). - * @returns The server response. - */ - async shadowBan(targetUserId: string, options?: BanUserOptions) { - return await this.banUser(targetUserId, { - shadow: true, - ...options, - }); - } - - /** - * Revoke a global shadow ban for a user. - * - * @param targetUserId - The user to remove the shadow ban for. - * @param options - Unban options (optional). - * @returns The server response. - */ - async removeShadowBan(targetUserId: string, options?: UnBanUserOptions) { - return await this.unbanUser(targetUserId, { - shadow: true, - ...options, - }); - } async blockUser(blockedUserId: string, requestOptions?: StreamRequestOptions) { const result = await this.blockUsers( { @@ -1759,32 +1713,6 @@ export class StreamChat extends ChatApi { return result; } - /** - * Mutes a user. - * - * @param targetId - The user to mute. - * @param options - UserMuteResponse options (optional, defaults to `{}`). - * @returns The server response. - */ - async muteUser(targetId: string, options: MuteUserOptions = {}) { - return await this.api.post(this.baseURL + '/moderation/mute', { - target_id: targetId, - ...options, - }); - } - - /** - * Unmutes a user. - * - * @param targetId - The user to unmute. - * @returns The server response. - */ - async unmuteUser(targetId: string) { - return await this.api.post(this.baseURL + '/moderation/unmute', { - target_id: targetId, - }); - } - /** * Checks whether a user is muted. Can be used after `connectUser()` is called. * @@ -1802,75 +1730,6 @@ export class StreamChat extends ChatApi { return false; } - /** - * Flag a message. - * - * @param targetMessageId - The message to flag. - * @param options - Flag options (optional, defaults to `{}`). - * @param options.reason - Reason for flagging (optional). - * @returns The server response. - */ - async flagMessage(targetMessageId: string, options: { reason?: string } = {}) { - return await this.api.post(this.baseURL + '/moderation/flag', { - target_message_id: targetMessageId, - ...options, - }); - } - - /** - * Flag a user. - * - * @param targetId - The user to flag. - * @param options - Flag options (optional, defaults to `{}`). - * @param options.reason - Reason for flagging (optional). - * @returns The server response. - */ - async flagUser(targetId: string, options: { reason?: string } = {}) { - return await this.api.post(this.baseURL + '/moderation/flag', { - target_user_id: targetId, - ...options, - }); - } - - /** - * Unflag a message. - * - * @param targetMessageId - The message to unflag. - * @returns The server response. - */ - async unflagMessage(targetMessageId: string) { - return await this.api.post(this.baseURL + '/moderation/unflag', { - target_message_id: targetMessageId, - }); - } - - /** - * Unflag a user. - * - * @param targetId - The user to unflag. - * @returns The server response. - */ - async unflagUser(targetId: string) { - return await this.api.post(this.baseURL + '/moderation/unflag', { - target_user_id: targetId, - }); - } - - /** - * Unblocks a message blocked by automod. - * - * @param targetMessageId - The message to unblock. - * @returns The server response. - */ - async unblockMessage(targetMessageId: string) { - return await this.api.post( - this.baseURL + '/moderation/unblock_message', - { - target_message_id: targetMessageId, - }, - ); - } - /** * Transforms an expiration value into an ISO string. * diff --git a/src/moderation.ts b/src/moderation.ts index 0145699e23..ba8388ac98 100644 --- a/src/moderation.ts +++ b/src/moderation.ts @@ -1,8 +1,4 @@ -import type { - ModerationFlagOptions, - StreamRequestOptions, - UnmuteUserResponse, -} from './types'; +import type { ModerationFlagOptions, StreamRequestOptions } from './types'; import type { StreamChat } from './client'; import { ModerationApi } from './gen/moderation/ModerationApi'; @@ -33,7 +29,7 @@ export class Moderation extends ModerationApi { */ flagUser( flaggedUserId: string, - reason: string, + reason?: string, options: ModerationFlagOptions = {}, requestOptions?: StreamRequestOptions, ) { @@ -62,7 +58,7 @@ export class Moderation extends ModerationApi { */ flagMessage( messageId: string, - reason: string, + reason?: string, options: ModerationFlagOptions = {}, requestOptions?: StreamRequestOptions, ) { @@ -82,14 +78,11 @@ export class Moderation extends ModerationApi { * Unmutes a user. * * @param targetId - User ID to be unmuted. + * @param requestOptions - Per-request options such as an abort `signal`. Never serialized + * into the request (optional). * @returns The unmute response. */ - async unmuteUser(targetId: string) { - return await this.client.api.post( - this.client.baseURL + '/api/v2/moderation/unmute', - { - target_ids: [targetId], - }, - ); + async unmuteUser(targetId: string, requestOptions?: StreamRequestOptions) { + return await this.unmute({ target_ids: [targetId] }, requestOptions); } } diff --git a/src/types.ts b/src/types.ts index 5185f5c2b0..6d6c36b7a9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ import type { NotificationManager } from './notifications'; import type { APIError, Attachment, + BanRequest, ChannelConfigWithInfo, ChannelOwnCapability, ChannelStateResponseFields, @@ -32,7 +33,6 @@ import type { UpdateMessageResponse, UpdatePollOptionRequest, UpdatePollRequest, - UserMuteResponse, UserResponse, WSEvent, } from './gen/models'; @@ -78,40 +78,6 @@ export type ChannelUpdateOptions = Omit; export type ConnectAPIResponse = Promise; -export type FlagMessageResponse = APIResponse & { - flag: { - created_at: string; - created_by_automod: boolean; - target_message_id: string; - updated_at: string; - user: UserResponse; - approved_at?: string; - channel_cid?: string; - details?: object; // Any JSON - message_user_id?: string; - rejected_at?: string; - reviewed_at?: string; - reviewed_by?: string; - }; - review_queue_item_id?: string; -}; - -export type FlagUserResponse = APIResponse & { - flag: { - created_at: string; - created_by_automod: boolean; - target_user: UserResponse; - updated_at: string; - user: UserResponse; - approved_at?: string; - details?: object; // Any JSON - rejected_at?: string; - reviewed_at?: string; - reviewed_by?: string; - }; - review_queue_item_id?: string; -}; - export type LocalMessage = MessageResponse & { status: string; error?: StreamAPIError; @@ -120,17 +86,6 @@ export type LocalMessage = MessageResponse & { export type GetThreadOptions = Omit[0], 'message_id'>; -export type MuteUserResponse = APIResponse & { - mute?: UserMuteResponse; - mutes?: Array; - own_user?: OwnUserResponse; - non_existing_users?: string[]; -}; - -export type UnmuteUserResponse = APIResponse & { - non_existing_users?: string[]; -}; - /** * The fields that exist on the connected user (`OwnUserResponse`) but not on a plain * `UserResponse` — i.e. the own-user-only slice of the user object. @@ -146,16 +101,7 @@ export type OwnUserBase = Pick< >; // Thumb URL(thumb_url) is added considering video attachments as the backend will return the thumbnail in the response. -export type BanUserOptions = UnBanUserOptions & { - ban_from_future_channels?: boolean; - banned_by?: UserResponse; - banned_by_id?: string; - ip_ban?: boolean; - reason?: string; - timeout?: number; - delete_messages?: MessageDeletionStrategy; - delete_reactions?: boolean; -}; +export type BanUserOptions = Omit; /** * Everything `queryChannels()` accepts apart from the filter and the sort. @@ -190,18 +136,6 @@ export type Automod = ChannelConfigWithInfo['automod']; /** What automod does when it trips, as reported by `channel.getConfig()`. */ export type AutomodBehavior = ChannelConfigWithInfo['automod_behavior']; -export type MuteUserOptions = { - client_id?: string; - connection_id?: string; - id?: string; - reason?: string; - target_user_id?: string; - timeout?: number; - type?: string; - user?: UserResponse; - user_id?: string; -}; - export type PaginationOptions = { created_at_after?: string | Date; created_at_after_or_equal?: string | Date; @@ -533,9 +467,6 @@ export type PollOptionData = UpdatePollOptionRequest & { position?: number; }; -export type MessageDeletionStrategy = 'soft' | 'hard' | 'pruning'; -// @deprecated use type MessageDeletionStrategy instead - export type ModerationFlagOptions = { custom?: Record; moderation_payload?: ModerationPayload; diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index 15dc39b7f8..33ed11271b 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -1392,6 +1392,69 @@ Skip this section if you are upgrading from v9 only in the sense that these meth - **`channel.sendAction(messageId, formData, requestOptions?)` — KEPT**, but its `if (!messageId) throw Error('Message ID is missing')` guard is gone. `runMessageAction` requires `id: string`, so an empty id is a compile error; a runtime empty string reaches the server and is rejected there. +## Removed after `10.0.0-rc.4` — moderation moves to the generated V2 API + +The `/moderation/*` methods on `StreamChat` were the last endpoints in the SDK that built +their own request instead of calling the generated client. Every one that has a generated +equivalent now delegates to it, and the ones no SDK used are gone. + +### Migrated to `client.moderation.*` + +These four are **removed** from `StreamChat`. The replacement is the generated V2 method, +reachable through `client.moderation`: + +| Removed | Replacement | +| ----------------------------------------------- | ------------------------------------------------------------------- | +| `client.banUser(targetUserId, options?)` | `client.moderation.ban({ target_user_id, ...options })` | +| `client.muteUser(targetId, options?)` | `client.moderation.mute({ target_ids: [targetId], ...options })` | +| `client.unmuteUser(targetId)` | `client.moderation.unmute({ target_ids: [targetId] })` | +| `client.flagMessage(targetMessageId, options?)` | `client.moderation.flagMessage(targetMessageId, reason?, options?)` | + +Note the shape change on mute/unmute: V2 takes `target_ids` as an **array**, so a single +id becomes `[targetId]`. + +Three consequences worth checking in your integration: + +- **Return types change.** These now resolve to `StreamResponse<…>` of the generated + response, so they also carry `metadata`. Two lose fields: `mute` (singular) is no longer + on the mute response — use `mutes` — and the flag response is now + `FlagItemResponse { duration, item_id }` rather than a nested `flag` object. If you only + `await` these calls you are unaffected. +- **Two ban options are gone.** V2 `BanRequest` has no `delete_reactions` and no + `ban_from_future_channels`. There is no replacement for either. +- **`BanUserOptions` and `MuteUserOptions`.** `BanUserOptions` is now + `Omit` — derived, so it tracks the spec. `MuteUserOptions` + is removed; V2 mute accepts only `timeout`. + +`MuteUserResponse`, `FlagMessageResponse`, `FlagUserResponse` and `UnmuteUserResponse` are +removed with them. + +### Removed with no replacement — unused by the React and React Native SDKs + +- **`client.flagUser(...)`** — use `client.moderation.flagUser(targetUserId, reason?)`. +- **`client.unflagMessage(...)`, `client.unflagUser(...)`** — V2 has no unflag endpoint. +- **`client.unblockMessage(...)`** — no V2 equivalent. +- **`client.shadowBan(targetUserId, options?)`** → `client.moderation.ban({ target_user_id, shadow: true, ...options })`. +- **`client.removeShadowBan(targetUserId, options?)`** → `client.unbanUser(targetUserId, { shadow: true, ...options })`. +- **`channel.shadowBan(...)`** → `channel.banUser(targetUserId, { shadow: true })`. +- **`channel.removeShadowBan(...)`** → `channel.unbanUser(targetUserId, { shadow: true })`. + +Shadow banning itself is unaffected — `shadow` is still a documented option on both ban +paths. Only the convenience wrappers are gone. + +### Still hand-written: `unbanUser` + +`client.unbanUser(targetUserId, options?)` and `channel.unbanUser(...)` keep their v1 +implementation, because **the generated layer has no unban endpoint**: `ChatApi` exposes +only the reads (`queryBannedUsers`, `queryFutureChannelBans`) and V2 moderation has `ban` +without a matching `unban`. Ban and unban have to target the same system, so both stay +reachable until the spec publishes one. `APIResponse` and `UnBanUserOptions` survive for +the same reason. + +Note the asymmetry this creates while it lasts: `channel.banUser` scopes through V2's +`channel_cid`, while `channel.unbanUser` still scopes through v1's `type` + `id`. Both +still take a channel-scoped ban and clear it; only the wire format differs. + ## Logging (applies to every class) `options.logger` (function) and `client.logger(level, msg, extra?)` are gone. To capture logs in v10, configure the shared `chatLoggerSystem` before constructing the client: From 911ab88f711d0299ae88054d645b662ddbc67328 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 20 Aug 2026 14:07:26 -0500 Subject: [PATCH 09/17] fix: supply blocked_user_ids instead of asserting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sites cast the connected user with `RequireLiteral` to satisfy a target typed `UserResponse`, each carrying "TODO: drop RequireLiteral once the oapi spec is adjusted". The spec needs no adjusting. Verified against the live API on both API versions: * `OwnUserResponse` on the connect hello OMITS `blocked_user_ids` when nothing is blocked, and includes it once something is. Identical on v1 `/connect` (`health.check`) and v2 `/api/v2/connect` (`connection.ok`). * A plain `UserResponse` — another user embedded in a message or a member — ALWAYS carries it, as `[]` when empty. So optional-on-own-user and required-on-`UserResponse` is exactly right, and following the TODO would have made `OwnUserResponse` lie about the empty case. The cast was also doing a second, unstated job: `client.user` is `ClientUser` (`PartializeAllBut`), so every field but `id` is optional there and something has to lift it to the populated shape. That part is unavoidable and stays — but it is now a plain `as UserResponse` that says what it means, rather than an indirection through `RequireLiteral` plus a misleading TODO. `blocked_user_ids` is supplied rather than asserted: user: { ...ownUser, blocked_user_ids: ownUser.blocked_user_ids ?? [] } as UserResponse `?? []` is correct rather than defensive — absent genuinely means "nothing blocked", which is how `client._handleClientEvent` already treats it when seeding `client.blockedUsers`. BEHAVIOUR CHANGE: * `blocked_user_ids: []` now appears on the `user` of offline-DB read rows and on `localMessage.user` when the connected user has nothing blocked. Previously the key was absent while the type claimed it was required. This aligns the runtime value with both the declared type and what the server sends for other users. Eight unit tests asserted the old shape and are updated. BREAKING CHANGES: * None. All three sites are internal; no exported type or signature changes. `stream-chat-react` and `stream-chat-react-native` both read the `client.blockedUsers` store rather than `blocked_user_ids` off a user object, so neither is affected. `RequireLiteral` itself stays — its remaining users are the defensible ones, where the narrowing is proven by a runtime check rather than asserted: `isOwnAnswer` in `poll.ts`, `SharedLiveLocationResponse` (via `isValidLiveLocationMessage`), and `OGAttachment`. Co-Authored-By: Claude Opus 5 --- .../messageComposer/userDataInjection.ts | 17 +++++++++----- src/offline-support/offline_support_api.ts | 22 +++++++++++++------ .../messageComposer/userDataInjection.test.ts | 4 ++-- .../offline_support_api.test.ts | 21 ++++++++++++------ 4 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/messageComposer/middleware/messageComposer/userDataInjection.ts b/src/messageComposer/middleware/messageComposer/userDataInjection.ts index c6f22a16cd..9003c6f9c8 100644 --- a/src/messageComposer/middleware/messageComposer/userDataInjection.ts +++ b/src/messageComposer/middleware/messageComposer/userDataInjection.ts @@ -4,7 +4,7 @@ import type { MessageCompositionMiddleware, } from './types'; import type { MiddlewareHandlerParams } from '../../../middleware'; -import type { OwnUserResponse, RequireLiteral } from '../../../types'; +import type { UserResponse } from '../../../types'; export const createUserDataInjectionMiddleware = ( composer: MessageComposer, @@ -23,10 +23,9 @@ export const createUserDataInjectionMiddleware = ( // that provide no value for localMessage (and will never exist within message.user). // This way we make sure that our localMessage is enriched with data as close as // possible to the actual user. - // The reason why we need to explicitly cast is because OwnUserResponse only takes - // precedence after we connectUser the first time and we get the connection health - // check event. Due to how liberal the type of client.user is, we have to do it this - // way to maintain type safety. + // The cast below is needed because `client.user` is `ClientUser`, which makes every + // field but `id` optional — it is only fully populated once `connectUser` has run and + // the connection hello event has arrived. const { channel_mutes: _channel_mutes, @@ -38,7 +37,13 @@ export const createUserDataInjectionMiddleware = ( ...state, localMessage: { ...state.localMessage, - user: messageUser as RequireLiteral, // TODO: drop RequireLiteral once the oapi spec is adjusted, + // `blocked_user_ids` is optional on `OwnUserResponse` — the connect payload + // omits it when nothing is blocked — but required on `UserResponse`. Supply the + // empty case rather than assert it; `[]` is what the server sends for other users. + user: { + ...messageUser, + blocked_user_ids: messageUser.blocked_user_ids ?? [], + } as UserResponse, user_id: messageUser.id, }, }); diff --git a/src/offline-support/offline_support_api.ts b/src/offline-support/offline_support_api.ts index 845b051525..7a5e9434c5 100644 --- a/src/offline-support/offline_support_api.ts +++ b/src/offline-support/offline_support_api.ts @@ -7,8 +7,7 @@ import type { LocalMessage, MessageRequest, MessageResponse, - OwnUserResponse, - RequireLiteral, + UserResponse, } from '../types'; import type { @@ -676,10 +675,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { last_read: ownReads?.last_read ?? new Date(0), last_read_message_id: ownReads?.last_read_message_id, unread_messages: unreadCount, - user: client.user as RequireLiteral< - OwnUserResponse, - 'blocked_user_ids' - >, // TODO: drop RequireLiteral once the oapi spec is adjusted + // `client.user` is `ClientUser` (everything optional but `id`), so a + // cast to the populated shape is unavoidable — it holds after connect. + // `blocked_user_ids` is supplied rather than asserted: the connect payload + // omits it when nothing is blocked, while `UserResponse` always carries it + // (the server sends `[]` for other users). + user: { + ...client.user, + blocked_user_ids: client.user.blocked_user_ids ?? [], + } as UserResponse, }, ], }); @@ -992,7 +996,11 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { last_read: ownReads?.last_read ?? new Date(0), last_read_message_id: ownReads?.last_read_message_id, unread_messages: unreadCount, - user: ownUser as RequireLiteral, // TODO: drop RequireLiteral once the oapi spec is adjusted + // See the note above: supply `blocked_user_ids`, do not assert it. + user: { + ...ownUser, + blocked_user_ids: ownUser.blocked_user_ids ?? [], + } as UserResponse, }, ], }); diff --git a/test/unit/MessageComposer/middleware/messageComposer/userDataInjection.test.ts b/test/unit/MessageComposer/middleware/messageComposer/userDataInjection.test.ts index 256966b646..d9103d90e9 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/userDataInjection.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/userDataInjection.test.ts @@ -55,7 +55,7 @@ describe('stream-io/message-composer-middleware/user-data-injection', () => { ...stateSeed, localMessage: { ...stateSeed.localMessage, - user: mockComposer.client.user, + user: { ...mockComposer.client.user, blocked_user_ids: [] }, user_id: mockComposer.client.user.id, }, }); @@ -100,7 +100,7 @@ describe('stream-io/message-composer-middleware/user-data-injection', () => { ...stateSeed, localMessage: { ...stateSeed.localMessage, - user: mockComposer.client.user, + user: { ...mockComposer.client.user, blocked_user_ids: [] }, user_id: mockComposer.client.user.id, }, }); diff --git a/test/unit/offline-support/offline_support_api.test.ts b/test/unit/offline-support/offline_support_api.test.ts index fe10b14280..f42649a9d5 100644 --- a/test/unit/offline-support/offline_support_api.test.ts +++ b/test/unit/offline-support/offline_support_api.test.ts @@ -548,7 +548,9 @@ describe('OfflineSupportApi', () => { beforeEach(() => { queriesWithChannelGuardSpy = vi.spyOn(offlineDb, 'queriesWithChannelGuard'); vi.spyOn(offlineDb, 'channelExists').mockResolvedValue(true); - readResponse = generateReadResponse({ user: client.user }); + readResponse = generateReadResponse({ + user: { ...client.user, blocked_user_ids: [] }, + }); channelResponse = generateChannel({ channel: { id: 'channel123', type: 'messaging' }, read: [readResponse], @@ -659,7 +661,7 @@ describe('OfflineSupportApi', () => { reads: expect.arrayContaining([ expect.objectContaining({ unread_messages: expect.any(Number), - user: client.user, + user: { ...client.user, blocked_user_ids: [] }, }), ]), }), @@ -1406,7 +1408,7 @@ describe('OfflineSupportApi', () => { offlineDb.upsertReads.mockResolvedValue(['UPDATE * IN reads']); readResponse = generateReadResponse({ - user: client.user, + user: { ...client.user, blocked_user_ids: [] }, last_read: lastReadDate, last_read_message_id: lastReadMessageId, unread_messages: unreadMessagesCount, @@ -1473,7 +1475,7 @@ describe('OfflineSupportApi', () => { last_read: lastReadDate, last_read_message_id: lastReadMessageId, unread_messages: 2, - user: client.user, + user: { ...client.user, blocked_user_ids: [] }, }, ], }); @@ -1504,7 +1506,7 @@ describe('OfflineSupportApi', () => { last_read: lastReadDate, last_read_message_id: lastReadMessageId, unread_messages: 0, - user: client.user, + user: { ...client.user, blocked_user_ids: [] }, }, ], }); @@ -1546,7 +1548,7 @@ describe('OfflineSupportApi', () => { last_read: lastReadDate, last_read_message_id: lastReadMessageId, unread_messages: 0, - user: client.user, + user: { ...client.user, blocked_user_ids: [] }, }, ], }); @@ -1584,7 +1586,12 @@ describe('OfflineSupportApi', () => { expect(offlineDb.upsertReads).toHaveBeenCalledWith( expect.objectContaining({ cid: localChannelResponse.channel.cid, - reads: [expect.objectContaining({ unread_messages: 3, user: client.user })], + reads: [ + expect.objectContaining({ + unread_messages: 3, + user: { ...client.user, blocked_user_ids: [] }, + }), + ], }), ); }); From a15f0ee49be931a8ccd9d6ff9952f8fdd08f0870 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 20 Aug 2026 14:11:57 -0500 Subject: [PATCH 10/17] refactor: drop the created_by_device_id TODO from live location updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LiveLocationManager.updateLiveLocation` carried a commented-out `created_by_device_id: location.created_by_device_id` line under "TODO: this is missing from the OAPI spec". It is not missing — it is deliberately absent. `created_by_device_id` identifies the device that opened the share and is fixed at creation; `UpdateLiveLocationRequest` has no device field, so there is nothing to restore. Removing the dead line and the TODO that invited someone to "fix" the spec. The RULES header above it reached the right conclusion from the wrong premise — it said the field "has currently no checks", implying the per-device intent was unenforced. The actual reason any of a user's devices can push updates to one share is that the update payload carries no device field at all. Reworded to say that. `LocationComposer` still sets `created_by_device_id` when composing a NEW share, which is correct — `SharedLocation` accepts it on create. No behaviour change: the line was already commented out. BREAKING CHANGES: * None. Comment and dead-code only; no signature, type or runtime change. Downstream: nothing to update in `stream-chat-react` or `stream-chat-react-native`. Both call `channel.stopLiveLocationSharing(location)` with a full `SharedLocationResponseData` rather than a trimmed request. That is type-legal (TypeScript does not excess-property-check a variable) and runtime-safe, because the generated `ChatApi.updateLiveLocation` builds its body from an explicit whitelist of `message_id` / `end_at` / `latitude` / `longitude` — so `created_by_device_id` and the other response fields are dropped before the request is sent and never reach the wire. Noted while here, not changed: `channel.stopLiveLocationSharing` accepts a full `UpdateLiveLocationRequest` but always overrides `end_at` with `new Date()`, so that field is silently ignored. `Omit` would be the honest signature. Co-Authored-By: Claude Opus 5 --- src/LiveLocationManager.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index ec56cdb5c6..0a69f40ab4 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -2,10 +2,11 @@ * RULES: * * 1. one loc-sharing message per channel per user - * 2. live location is intended to be per device - * but created_by_device_id has currently no checks, - * and user can update the location from another device - * thus making location sharing based on user and channel + * 2. live location is intended to be per device, but `created_by_device_id` is set once + * when the share is created and cannot be reassigned — `UpdateLiveLocationRequest` has no + * device field at all, by design. Any of the user's devices can therefore push coordinate + * updates to the same share, which makes location sharing effectively per user and + * channel rather than per device. */ import { withCancellation } from './utils/concurrency'; @@ -190,8 +191,6 @@ export class LiveLocationManager extends WithSubscriptions { if (location.latitude === latitude && location.longitude === longitude) continue; const promise = this.client.updateLiveLocation({ - // TODO: this is missing from the OAPI spec - // created_by_device_id: location.created_by_device_id, message_id: messageId, latitude, longitude, From 6bfc5039512489e8b05083fa12c820dc8a0bba69 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 20 Aug 2026 14:30:08 -0500 Subject: [PATCH 11/17] refactor: replace the hand-written pagination option types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MessagePaginationOptions` restated the generated `MessagePaginationParams`, and the two lived side by side in `MessageIntervalPaginator` — which imported both and cast between them (`messages: options as MessagePaginationParams`) to reach the request shape. The generated `ChannelGetOrCreateRequest` already types `messages?: MessagePaginationParams`, so the hand-written pair was a parallel vocabulary for something the spec defines. Two divergences made them non-assignable, and both were wrong: * `created_at_*` were widened to `string | Date`. The only place that exercised the string arm was `MessagePaginator.jumpToTheFirstUnreadMessage`, which called `lastReadAt.toISOString()` — converting a `Date` into a string purely to satisfy the SDK's own type, when the generated one wants the `Date`. The transport serializes dates, so passing it through is equivalent on the wire and one step shorter. * `offset` was declared for message pagination, which the endpoint does not accept — the comment beside it even said "should be avoided with channel.query()". It was a silent no-op there, the same shape of bug as the `user_id` that `ChannelOptions` used to carry. `PaginationOptions` is deleted rather than derived. It was never equivalent to the generated `PaginationParams` (which is only `limit` / `offset`), and after the swap its sole consumer was `linearPaginationFlags`, where it bounded one helper and named the query keys that imply a cursor direction. That is cursor-derivation domain knowledge, not a request shape, so it now lives beside the helper as a non-exported `LinearPaginationQueryShape`. `offset` stays in that local shape because `PinnedMessagePaginationOptions` has one — `getPinnedMessages` genuinely accepts it — and `TAILWARD_QUERY_PROPERTIES` lists it. BREAKING CHANGES: * `MessagePaginationOptions` is removed. Use the generated `MessagePaginationParams`. It is the same field set with two differences: `created_at_after`, `created_at_after_or_equal`, `created_at_around`, `created_at_before` and `created_at_before_or_equal` are `Date` rather than `string | Date`, and there is no `offset`. Pass a `Date` where you passed an ISO string; drop `offset`, which was never sent. * `PaginationOptions` is removed with no direct replacement. For message pagination use `MessagePaginationParams`; for the `members` / `watchers` sub-objects of `ChannelGetOrCreateRequest` use the generated `PaginationParams` (`limit` / `offset`). Neither type is referenced by `stream-chat-react` or `stream-chat-react-native`, so no downstream change is required. BEHAVIOUR CHANGE: * `jumpToTheFirstUnreadMessage` now sends `created_at_around` as a `Date` rather than a pre-stringified ISO value. Identical on the wire; one unit test asserted the string form and is updated. Co-Authored-By: Claude Opus 5 --- src/channel.ts | 4 +-- .../cursorDerivation/linearPaginationFlags.ts | 30 +++++++++++++++---- .../paginators/MessageIntervalPaginator.ts | 11 ++++--- src/pagination/paginators/MessagePaginator.ts | 2 +- src/types.ts | 18 ----------- .../paginators/MessagePaginator.test.ts | 2 +- 6 files changed, 33 insertions(+), 34 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 82e02294eb..56a026cdeb 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -36,7 +36,7 @@ import type { LocalMessage, MarkReadRequest, MarkReadResponse, - MessagePaginationOptions, + MessagePaginationParams, MessageRequest, MessageResponse, MessageSetType, @@ -1723,7 +1723,7 @@ export class Channel extends ChannelApi { const isLatestMessageSet = messageSetToAddToIfDoesNotExist === 'latest' && !options?.messages?.id_around && - !(options?.messages as MessagePaginationOptions | undefined)?.created_at_around; + !(options?.messages as MessagePaginationParams | undefined)?.created_at_around; this.getClient().polls.hydratePollCache(state.messages, true); this.getClient().reminders.hydrateState(state.messages); diff --git a/src/pagination/cursorDerivation/linearPaginationFlags.ts b/src/pagination/cursorDerivation/linearPaginationFlags.ts index 0e7ed202e2..1a38ed66b0 100644 --- a/src/pagination/cursorDerivation/linearPaginationFlags.ts +++ b/src/pagination/cursorDerivation/linearPaginationFlags.ts @@ -1,7 +1,25 @@ import type { CursorDeriveContext, PaginationFlags } from '../paginators'; -import type { MessagePaginationOptions, PaginationOptions } from '../../types'; +import type { MessagePaginationParams } from '../../types'; -const TAILWARD_QUERY_PROPERTIES: Array = [ +/** + * The query keys this helper understands when deriving a cursor direction. Not a request + * shape — `MessagePaginationParams` and `PinnedMessagePaginationOptions` are both assignable + * to it, and `offset` is present because the pinned-messages endpoint accepts one. + */ +type LinearPaginationQueryShape = { + created_at_after?: Date; + created_at_after_or_equal?: Date; + created_at_before?: Date; + created_at_before_or_equal?: Date; + id_gt?: string; + id_gte?: string; + id_lt?: string; + id_lte?: string; + limit?: number; + offset?: number; +}; + +const TAILWARD_QUERY_PROPERTIES: Array = [ 'created_at_before_or_equal', 'created_at_before', 'id_lt', @@ -9,7 +27,7 @@ const TAILWARD_QUERY_PROPERTIES: Array = [ 'offset', ]; -const HEADWARD_QUERY_PROPERTIES: Array = [ +const HEADWARD_QUERY_PROPERTIES: Array = [ 'created_at_after_or_equal', 'created_at_after', 'id_gt', @@ -17,7 +35,7 @@ const HEADWARD_QUERY_PROPERTIES: Array = [ ]; export const deriveLinearPaginationFlags = < T extends { id: string; created_at: Date }, - Q extends PaginationOptions, + Q extends LinearPaginationQueryShape, >({ direction, hasMoreHead, @@ -51,8 +69,8 @@ export const deriveLinearPaginationFlags = < TAILWARD_QUERY_PROPERTIES.some((p) => typeof queryShape[p] !== 'undefined'); const containsNonLinearPaginationProperties = - !!(queryShape as MessagePaginationOptions)?.id_around || - !!(queryShape as MessagePaginationOptions)?.created_at_around; + !!(queryShape as MessagePaginationParams)?.id_around || + !!(queryShape as MessagePaginationParams)?.created_at_around; const containsUnrecognizedOptionsOnly = !queriedMessagesTowardsHead && diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 08e0569f64..85613eda59 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -18,7 +18,6 @@ import { } from './BasePaginator'; import type { LocalMessage, - MessagePaginationOptions, MessagePaginationParams, MessageResponse, PinnedMessagePaginationOptions, @@ -98,7 +97,7 @@ const DEFAULT_BACKEND_SORT: MessagePaginatorSort = [ const DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE = 100; export type MessagePaginatorState = PaginatorState; -export type MessageQueryShape = MessagePaginationOptions | PinnedMessagePaginationOptions; +export type MessageQueryShape = MessagePaginationParams | PinnedMessagePaginationOptions; /** * At the moment all the pagination parameters are just different types of cursors, e.g. @@ -460,7 +459,7 @@ export class MessageIntervalPaginator extends BasePaginator< seedFirstPageSync( messages: LocalMessage[], requestedPageSize: number, - messagePaginationOptions?: MessagePaginationOptions, + messagePaginationOptions?: MessagePaginationParams, options?: SeedFirstPageOptions, ) { const queryShape: MessageQueryShape = { @@ -494,7 +493,7 @@ export class MessageIntervalPaginator extends BasePaginator< isJumpQueryShape(queryShape: MessageQueryShape): boolean { return ( !!queryShape?.id_around || - !!(queryShape as MessagePaginationOptions)?.created_at_around + !!(queryShape as MessagePaginationParams)?.created_at_around ); } @@ -1445,11 +1444,11 @@ const makeDeriveCursor = return { cursor, hasMoreHead, hasMoreTail }; }; - if ((ctx.queryShape as MessagePaginationOptions)?.created_at_around) { + if ((ctx.queryShape as MessagePaginationParams)?.created_at_around) { return injectCursor( deriveCreatedAtAroundPaginationFlags< LocalMessage, - MessagePaginationOptions, + MessagePaginationParams, MessageIntervalPaginator >({ ...ctx, diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index b1b99a8d94..5db0ace7ae 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -434,7 +434,7 @@ export class MessagePaginator extends MessageIntervalPaginator { if (!inferredLastReadMessageId) { const result = await this.executeQuery({ queryShape: { - created_at_around: lastReadAt.toISOString(), + created_at_around: lastReadAt, limit: options?.pageSize, }, updateState: false, diff --git a/src/types.ts b/src/types.ts index 6d6c36b7a9..d7cf64908a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -136,24 +136,6 @@ export type Automod = ChannelConfigWithInfo['automod']; /** What automod does when it trips, as reported by `channel.getConfig()`. */ export type AutomodBehavior = ChannelConfigWithInfo['automod_behavior']; -export type PaginationOptions = { - created_at_after?: string | Date; - created_at_after_or_equal?: string | Date; - created_at_before?: string | Date; - created_at_before_or_equal?: string | Date; - id_gt?: string; - id_gte?: string; - id_lt?: string; - id_lte?: string; - limit?: number; - offset?: number; // should be avoided with channel.query() -}; - -export type MessagePaginationOptions = PaginationOptions & { - created_at_around?: string | Date; - id_around?: string; -}; - export type PinnedMessagePaginationOptions = Omit< Parameters[0], 'id' | 'sort' | 'type' diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index b59ea2f46b..1f8955457c 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -675,7 +675,7 @@ describe('MessagePaginator', () => { expect(ok).toBe(true); expect(executeQuerySpy).toHaveBeenCalledWith({ - queryShape: { created_at_around: lastReadAt.toISOString(), limit: 25 }, + queryShape: { created_at_around: lastReadAt, limit: 25 }, updateState: false, }); expect(jumpSpy).toHaveBeenCalledWith( From 83d62d816565042f42bab0af14d9dd7dc2f3f074 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 20 Aug 2026 14:44:35 -0500 Subject: [PATCH 12/17] refactor: remove the v1 permission system and assignRoles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/permissions.ts` held the deprecated v1 permission surface. Its own header said to stop using it — "deprecated permission object class, you should use the new permission system v2 and use permissions defined in BuiltinPermissions to configure your channel types" — and permissions are channel-type CONFIGURATION, which is server-side and left this package with the rest of that surface (`Policy`, `PolicyRequest`, `BlockList`, the push-provider configs). Nothing in `src` used any of it except `RoleName`, whose only consumer was `channel.assignRoles`. Assigning channel roles is server-side too, so that goes as well and takes `RoleName` with it. Neither `stream-chat-react` nor `stream-chat-react-native` references a single symbol from the module. BREAKING CHANGES: * `src/permissions.ts` is removed entirely, so `export * from './permissions'` is gone from the public surface. Removed with no replacement in this SDK: `PermissionObject`, the `Permission` class, `AllowAll`, `DenyAll`, `Allow`, `Deny`, `AnyResource`, `AnyRole`, `MaxPriority`, `MinPriority`, `BuiltinRoles`, `BuiltinPermissions`, `RoleName`. Configure permissions with `@stream-io/node-sdk` or the dashboard. * `Permission`, `AllowAll` and `DenyAll` were runtime values, not only types, so `import { Permission } from 'stream-chat'` now fails at runtime and not just at compile time — the same caveat as the `Product` enum. * `channel.assignRoles(roles, message?, options?, requestOptions?)` is removed. Role assignment is server-side; use `@stream-io/node-sdk`. Latent bug removed along with it, worth recording in case anyone copied the values: `BuiltinPermissions` had six corrupted entries. An over-broad `Message` -> `MessageRequest` rename in 62f05078 — a paginator commit, unrelated to permissions — rewrote the string VALUES as well as type names, so `CreateMessage` read 'Create MessageRequest', `RunMessageAction` read 'Run MessageRequest Action', and likewise for `DeleteAnyMessage`, `DeleteOwnMessage`, `UpdateAnyMessage` and `UpdateOwnMessage`. Those are server-side permission names the API matches on, so the constants emitted strings the backend does not recognise. `62f05078` is not on `origin/master`, so this never shipped. The corruption was confined to this file — `channel.ts`'s `'sendMessageRequestFn'` and friends are genuine property names introduced by that same commit. Note: `test/typescript/unit-test.ts` still imports `Permission`, `PermissionObject`, `Allow`, `Deny`, `AnyResource`, `AnyRole` and `MaxPriority`. That harness is already broken independently and is neither typechecked (`tsconfig.json` includes only `./src/**/*`) nor run by `yarn test` (vitest covers `test/unit/**`), so it stays out of scope here. Co-Authored-By: Claude Opus 5 --- src/channel.ts | 23 ----- src/index.ts | 1 - src/permissions.ts | 102 ---------------------- src/types.ts | 9 -- v9-to-v10-migration-guide-methods.md | 8 ++ v9-to-v10-migration-guide-type-renames.md | 26 ++++++ 6 files changed, 34 insertions(+), 135 deletions(-) delete mode 100644 src/permissions.ts diff --git a/src/channel.ts b/src/channel.ts index 56a026cdeb..0845a4bf69 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -53,7 +53,6 @@ import type { UpdateMessageOptions, UserResponse, } from './types'; -import type { RoleName } from './permissions'; import { StateStore } from './store'; import type { ChannelMemberRequest as Gen_ChannelMemberRequest, @@ -916,28 +915,6 @@ export class Channel extends ChannelApi { ); } - /** - * Sets member roles in a channel. - * - * @param roles - List of role assignments. - * @param message - Message object for channel members notification (optional). - * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). - * @param requestOptions - Per-request options such as an abort `signal`. Never serialized - * into the request (optional). - * @returns The server response. - */ - async assignRoles( - roles: { channel_role: RoleName; user_id: string }[], - message?: MessageRequest, - options: ChannelUpdateOptions = {}, - requestOptions?: StreamRequestOptions, - ) { - return await this.update( - { assign_roles: roles, message, ...options }, - requestOptions, - ); - } - /** * Invite members to the channel. * diff --git a/src/index.ts b/src/index.ts index c4fc236194..e91c2c754e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,6 @@ export * from './middleware'; export * from './moderation'; export * from './notifications'; export * from './pagination'; -export * from './permissions'; export * from './poll'; export * from './poll_manager'; export * from './reminders'; diff --git a/src/permissions.ts b/src/permissions.ts deleted file mode 100644 index 776bfb7ed8..0000000000 --- a/src/permissions.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { PermissionObject } from './types'; - -type RequiredPermissionObject = Required; - -export const Allow = 'Allow'; -export const Deny = 'Deny'; -export const AnyResource = ['*']; -export const AnyRole = ['*']; -export const MaxPriority = 999; -export const MinPriority = 1; - -// deprecated permission object class, you should use the new permission system v2 and use permissions -// defined in BuiltinPermissions to configure your channel types - -export class Permission { - name: RequiredPermissionObject['name']; - action: RequiredPermissionObject['action']; - owner: RequiredPermissionObject['owner']; - priority: RequiredPermissionObject['priority']; - resources: RequiredPermissionObject['resources']; - roles: RequiredPermissionObject['roles']; - constructor( - name: string, - priority: number, - resources = AnyResource, - roles = AnyRole, - owner = false, - action: RequiredPermissionObject['action'] = Allow, - ) { - this.name = name; - this.action = action; - this.owner = owner; - this.priority = priority; - this.resources = resources; - this.roles = roles; - } -} - -// deprecated -export const AllowAll = new Permission( - 'Allow all', - MaxPriority, - AnyResource, - AnyRole, - false, - Allow, -); - -// deprecated -export const DenyAll = new Permission( - 'Deny all', - MinPriority, - AnyResource, - AnyRole, - false, - Deny, -); - -export type RoleName = - | 'admin' - | 'user' - | 'guest' - | 'anonymous' - | 'channel_member' - | 'channel_moderator' - | (string & {}); - -export const BuiltinRoles = { - Admin: 'admin', - Anonymous: 'anonymous', - ChannelMember: 'channel_member', - ChannelModerator: 'channel_moderator', - Guest: 'guest', - User: 'user', -}; - -export const BuiltinPermissions = { - AddLinks: 'Add Links', - BanUser: 'Ban User', - CreateChannel: 'Create Channel', - CreateMessage: 'Create MessageRequest', - CreateReaction: 'Create Reaction', - DeleteAnyAttachment: 'Delete Any Attachment', - DeleteAnyChannel: 'Delete Any Channel', - DeleteAnyMessage: 'Delete Any MessageRequest', - DeleteAnyReaction: 'Delete Any Reaction', - DeleteOwnAttachment: 'Delete Own Attachment', - DeleteOwnChannel: 'Delete Own Channel', - DeleteOwnMessage: 'Delete Own MessageRequest', - DeleteOwnReaction: 'Delete Own Reaction', - ReadAnyChannel: 'Read Any Channel', - ReadOwnChannel: 'Read Own Channel', - RunMessageAction: 'Run MessageRequest Action', - UpdateAnyChannel: 'Update Any Channel', - UpdateAnyMessage: 'Update Any MessageRequest', - UpdateMembersAnyChannel: 'Update Members Any Channel', - UpdateMembersOwnChannel: 'Update Members Own Channel', - UpdateOwnChannel: 'Update Own Channel', - UpdateOwnMessage: 'Update Own MessageRequest', - UploadAttachment: 'Upload Attachment', - UseFrozenChannel: 'Send messages and reactions to frozen channels', -}; diff --git a/src/types.ts b/src/types.ts index d7cf64908a..a426503a0a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -354,15 +354,6 @@ export type MessageLabel = export type SendMessageOptions = Omit; -export type PermissionObject = { - action?: 'Deny' | 'Allow'; - name?: string; - owner?: boolean; - priority?: number; - resources?: string[]; - roles?: string[]; -}; - export type TokenOrProvider = null | string | TokenProvider | undefined; export type TokenProvider = () => Promise; diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index 33ed11271b..ae56ae1f59 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -1392,6 +1392,14 @@ Skip this section if you are upgrading from v9 only in the sense that these meth - **`channel.sendAction(messageId, formData, requestOptions?)` — KEPT**, but its `if (!messageId) throw Error('Message ID is missing')` guard is gone. `runMessageAction` requires `id: string`, so an empty id is a compile error; a runtime empty string reaches the server and is rejected there. +## Removed after `10.0.0-rc.4` — server-side role assignment + +**`channel.assignRoles(roles, message?, options?, requestOptions?)` — REMOVED.** Assigning +channel roles is a server-side operation; use +[`@stream-io/node-sdk`](https://github.com/GetStream/stream-node). The `RoleName` type went +with it, along with the rest of the v1 permission surface — see +[the v1 permission system](./v9-to-v10-migration-guide-type-renames.md#the-v1-permission-system--removed). + ## Removed after `10.0.0-rc.4` — moderation moves to the generated V2 API The `/moderation/*` methods on `StreamChat` were the last endpoints in the SDK that built diff --git a/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index ce900be8da..0d1f42d37f 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -195,6 +195,32 @@ Neither is a rename; both **gain** surface, so no call site breaks. `UpdateChannelRequest` has no `members` key (it has `add_members` / `remove_members`), so that omit was a no-op left over from an older payload shape. `member_custom_include` **is** accepted by `getPinnedMessages`, so omitting it was narrowing the API — it can now be passed through. +### The v1 permission system — removed + +`src/permissions.ts` is gone, and with it the whole deprecated v1 permission surface. The +module's own header already said to stop using it: _"deprecated permission object class, you +should use the new permission system v2 and use permissions defined in BuiltinPermissions to +configure your channel types."_ Permissions are channel-type **configuration**, which is +server-side and left this package with the rest of that surface. + +Removed: `PermissionObject`, the `Permission` class, `AllowAll`, `DenyAll`, `Allow`, `Deny`, +`AnyResource`, `AnyRole`, `MaxPriority`, `MinPriority`, `BuiltinRoles`, +`BuiltinPermissions` and `RoleName`. Configure permissions with +[`@stream-io/node-sdk`](https://github.com/GetStream/stream-node) or the dashboard. + +Note that `Permission`, `AllowAll` and `DenyAll` were runtime values, not just types, so +`import { Permission } from 'stream-chat'` now fails at runtime as well as at compile time — +the same caveat as [`Product`](#orphans-of-the-server-side-split--removed-no-replacement). + +`BuiltinPermissions` carried a latent bug worth knowing about if you copied its values: +six entries had been corrupted by an over-broad `Message` -> `MessageRequest` rename, so +`CreateMessage` read `'Create MessageRequest'`, `RunMessageAction` read +`'Run MessageRequest Action'`, and similarly for `DeleteAnyMessage`, `DeleteOwnMessage`, +`UpdateAnyMessage` and `UpdateOwnMessage`. Those are server-side permission names the API +matches on, so the constants were emitting strings the backend does not recognise. If you +hard-coded any of them, use the un-suffixed forms (`'Create Message'`, `'Run Message +Action'`, ...). + ### Orphans of the server-side split — removed, no replacement These described admin/server-side surface (push-provider credentials, permission policies, blocklists, channel-type config) that moved to `@stream-io/node-sdk` when the server-side API left this package. Nothing in the SDK referenced them and no endpoint here returns them. From 3d58bed609eadc97d8fd0fda790ec2afefe8954b Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 20 Aug 2026 15:05:20 -0500 Subject: [PATCH 13/17] refactor: derive VotingVisibility and drop the phantom PollOptionData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `VotingVisibility` was a hand-written enum whose two members duplicated `CreatePollRequest['voting_visibility']` (`'anonymous' | 'public'`). It is now derived from that request field, so a spec change flows through it. BREAKING CHANGE: `VotingVisibility` is a type, not an enum, so it is no longer a runtime value — `VotingVisibility.anonymous` must become `'anonymous'`. Member names and string values were identical, so the rewrite is mechanical. Type-position uses are unaffected, including `as VotingVisibility` casts when reading a poll: `PollResponseData.voting_visibility` is typed `string` in the spec, so the narrowing is still required. `PollOptionData` (`UpdatePollOptionRequest & { position?: number }`) typed the parameter of both `poll.createOption()` and `poll.updateOption()`, and described neither: - `position` appears nowhere in the OpenAPI spec, and the generated methods whitelist their body fields (`createPollOption` sends `text`/`custom`; `updatePollOption` sends `id`/`text`/`custom`), so it was dropped before the request left the client. Removing it changes no behaviour. - `UpdatePollOptionRequest.id` is required, so `createOption()` demanded an id the create endpoint does not send. Callers cast around it — stream-chat-react-native did exactly that: `poll.createOption({ text } as PollOptionData)`. BREAKING CHANGE: `PollOptionData` is removed. `poll.createOption()` now takes `CreatePollOptionRequest` and `poll.updateOption()` takes `UpdatePollOptionRequest`. Drop any `position` you were passing; it was never sent. `PartialPollUpdate` is kept — it invents nothing and is already derived from `UpdatePollRequest`, so it needs no hand edit on regeneration. Co-Authored-By: Claude Opus 5 --- src/messageComposer/pollComposer.ts | 3 +- src/poll.ts | 7 +- src/types.ts | 14 ++-- .../pollComposer/composition.test.ts | 5 +- .../middleware/pollComposer/state.test.ts | 3 +- .../unit/MessageComposer/pollComposer.test.ts | 27 ++++--- v9-to-v10-migration-guide-type-renames.md | 74 +++++++++++++++++++ 7 files changed, 100 insertions(+), 33 deletions(-) diff --git a/src/messageComposer/pollComposer.ts b/src/messageComposer/pollComposer.ts index 762e09a6c6..1bfac12b93 100644 --- a/src/messageComposer/pollComposer.ts +++ b/src/messageComposer/pollComposer.ts @@ -4,7 +4,6 @@ import { VALID_MAX_VOTES_VALUE_REGEX, } from './middleware/pollComposer'; import { StateStore } from '../store'; -import { VotingVisibility } from '../types'; import { generateUUIDv4 } from '../utils'; import type { MessageComposer } from './messageComposer'; import type { @@ -45,7 +44,7 @@ export class PollComposer { max_votes_allowed: '', name: '', options: [{ id: generateUUIDv4(), text: '' }], - voting_visibility: VotingVisibility.public, + voting_visibility: 'public', }, errors: {}, }; diff --git a/src/poll.ts b/src/poll.ts index 1f8b6adb10..6c2e416adc 100644 --- a/src/poll.ts +++ b/src/poll.ts @@ -1,15 +1,16 @@ import { StateStore } from './store'; import type { StreamChat } from './client'; import type { + CreatePollOptionRequest, EventPayload, PartialPollUpdate, - PollOptionData, PollResponseData, PollVoteResponseData, QueryVotesFilters, QueryVotesOptions, RequireLiteral, SortParamRequest, + UpdatePollOptionRequest, UpdatePollRequest, VotingVisibility, } from './types'; @@ -283,10 +284,10 @@ export class Poll { delete = async () => await this.client.deletePoll({ poll_id: this.id as string }); - createOption = async (option: PollOptionData) => + createOption = async (option: CreatePollOptionRequest) => await this.client.createPollOption({ poll_id: this.id as string, ...option }); - updateOption = async (option: PollOptionData) => + updateOption = async (option: UpdatePollOptionRequest) => await this.client.updatePollOption({ poll_id: this.id as string, ...option }); deleteOption = async (option_id: string) => diff --git a/src/types.ts b/src/types.ts index a426503a0a..ab276337f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,7 @@ import type { ChannelOwnCapability, ChannelStateResponseFields, CreateDeviceRequest, + CreatePollRequest, DraftPayloadResponse, Images, MessageResponse, @@ -31,7 +32,6 @@ import type { UpdateChannelRequest, UpdateMessageRequest, UpdateMessageResponse, - UpdatePollOptionRequest, UpdatePollRequest, UserResponse, WSEvent, @@ -426,20 +426,16 @@ export class StreamAPIError extends Error { } } -export enum VotingVisibility { - anonymous = 'anonymous', - public = 'public', -} +/** + * Poll vote visibility. Derived from the generated request shape so it tracks the spec. + */ +export type VotingVisibility = NonNullable; export type PartialPollUpdate = { set?: Partial; unset?: Array; }; -export type PollOptionData = UpdatePollOptionRequest & { - position?: number; -}; - export type ModerationFlagOptions = { custom?: Record; moderation_payload?: ModerationPayload; diff --git a/test/unit/MessageComposer/middleware/pollComposer/composition.test.ts b/test/unit/MessageComposer/middleware/pollComposer/composition.test.ts index 1bc4d0d1b5..48c41c7db4 100644 --- a/test/unit/MessageComposer/middleware/pollComposer/composition.test.ts +++ b/test/unit/MessageComposer/middleware/pollComposer/composition.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createPollCompositionValidationMiddleware } from '../../../../../src/messageComposer/middleware/pollComposer/composition'; import { MessageComposer } from '../../../../../src/messageComposer/messageComposer'; import { PollComposer } from '../../../../../src/messageComposer/pollComposer'; -import { VotingVisibility } from '../../../../../src/types'; import type { Middleware, MiddlewareStatus } from '../../../../../src/middleware'; import type { PollComposerCompositionMiddlewareValueState } from '../../../../../src/messageComposer/middleware/pollComposer/types'; import type { MiddlewareHandler } from '../../../../../src/middleware'; @@ -51,7 +50,7 @@ describe('PollComposerCompositionMiddleware', () => { max_votes_allowed: '', id: 'test-id', user_id: 'user-id', - voting_visibility: VotingVisibility.public, + voting_visibility: 'public', allow_answers: false, allow_user_suggested_options: false, description: '', @@ -86,7 +85,7 @@ describe('PollComposerCompositionMiddleware', () => { max_votes_allowed: '', id: 'test-id', user_id: 'user-id', - voting_visibility: VotingVisibility.public, + voting_visibility: 'public', allow_answers: false, allow_user_suggested_options: false, description: '', diff --git a/test/unit/MessageComposer/middleware/pollComposer/state.test.ts b/test/unit/MessageComposer/middleware/pollComposer/state.test.ts index 781071fd7a..9d9e71807e 100644 --- a/test/unit/MessageComposer/middleware/pollComposer/state.test.ts +++ b/test/unit/MessageComposer/middleware/pollComposer/state.test.ts @@ -9,7 +9,6 @@ import { createPollComposerStateMiddleware, PollComposerStateMiddlewareFactoryOptions, } from '../../../../../src/messageComposer/middleware/pollComposer/state'; -import { VotingVisibility } from '../../../../../src/types'; const setupHandlerParams = (initialState: PollComposerStateChangeMiddlewareValue) => { return { @@ -40,7 +39,7 @@ const getInitialState = (): PollComposerState => ({ max_votes_allowed: '', name: '', options: [{ id: 'option-id', text: '' }], - voting_visibility: VotingVisibility.public, + voting_visibility: 'public', }, errors: {}, }); diff --git a/test/unit/MessageComposer/pollComposer.test.ts b/test/unit/MessageComposer/pollComposer.test.ts index f50a10fbde..982b1b24bc 100644 --- a/test/unit/MessageComposer/pollComposer.test.ts +++ b/test/unit/MessageComposer/pollComposer.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { PollComposer } from '../../../src/messageComposer/pollComposer'; import { StateStore } from '../../../src/store'; -import { VotingVisibility } from '../../../src/types'; // Mock dependencies vi.mock('../../../src/utils', () => ({ @@ -24,7 +23,7 @@ vi.mock('../../../src/messageComposer/middleware/pollComposer', () => ({ name: 'Test Poll', options: [{ text: 'Option 1' }, { text: 'Option 2' }], user_id: 'user-id', - voting_visibility: VotingVisibility.public, + voting_visibility: 'public', }, errors: {}, }, @@ -47,7 +46,7 @@ vi.mock('../../../src/messageComposer/middleware/pollComposer', () => ({ name: 'Test Poll', options: [{ id: 'option-id', text: 'Option 1' }], user_id: 'user-id', - voting_visibility: VotingVisibility.public, + voting_visibility: 'public', }, errors: {}, }, @@ -94,7 +93,7 @@ describe('PollComposer', () => { expect(initialState.data.max_votes_allowed).toBe(''); expect(initialState.data.name).toBe(''); expect(initialState.data.options).toEqual([{ id: 'test-uuid', text: '' }]); - expect(initialState.data.voting_visibility).toBe(VotingVisibility.public); + expect(initialState.data.voting_visibility).toBe('public'); expect(initialState.errors).toEqual({}); }); }); @@ -112,7 +111,7 @@ describe('PollComposer', () => { max_votes_allowed: '', name: '', options: [{ id: 'option-id', text: '' }], - voting_visibility: VotingVisibility.anonymous, + voting_visibility: 'anonymous', }, errors: {}, }); @@ -125,7 +124,7 @@ describe('PollComposer', () => { expect(pollComposer.max_votes_allowed).toBe(''); expect(pollComposer.name).toBe(''); expect(pollComposer.options).toEqual([{ id: 'option-id', text: '' }]); - expect(pollComposer.voting_visibility).toBe(VotingVisibility.anonymous); + expect(pollComposer.voting_visibility).toBe('anonymous'); }); }); @@ -137,7 +136,7 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '', id: 'test-id', - voting_visibility: VotingVisibility.public, + voting_visibility: 'public', }, errors: {}, }); @@ -152,7 +151,7 @@ describe('PollComposer', () => { name: '', max_votes_allowed: '', id: 'test-id', - voting_visibility: VotingVisibility.public, + voting_visibility: 'public', }, errors: {}, }); @@ -167,7 +166,7 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '1', // Less than 2 id: 'test-id', - voting_visibility: VotingVisibility.public, + voting_visibility: 'public', }, errors: {}, }); @@ -182,7 +181,7 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '', id: 'test-id', - voting_visibility: VotingVisibility.public, + voting_visibility: 'public', }, errors: { name: 'Name is required' }, }); @@ -197,7 +196,7 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '', id: 'test-id', - voting_visibility: VotingVisibility.public, + voting_visibility: 'public', }, errors: {}, }); @@ -211,7 +210,7 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '', id: 'test-id', - voting_visibility: VotingVisibility.public, + voting_visibility: 'public', }, errors: { name: undefined, options: undefined }, }); @@ -233,7 +232,7 @@ describe('PollComposer', () => { max_votes_allowed: '5', name: 'Different Name', options: [{ id: 'different-option-id', text: 'Different Option' }], - voting_visibility: VotingVisibility.anonymous, + voting_visibility: 'anonymous', }, errors: { name: 'Error' }, }); @@ -251,7 +250,7 @@ describe('PollComposer', () => { expect(currentState.data.max_votes_allowed).toBe(''); expect(currentState.data.name).toBe(''); expect(currentState.data.options).toEqual([{ id: 'test-uuid', text: '' }]); - expect(currentState.data.voting_visibility).toBe(VotingVisibility.public); + expect(currentState.data.voting_visibility).toBe('public'); expect(currentState.errors).toEqual({}); }); }); diff --git a/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index 0d1f42d37f..78fb0623ac 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -326,6 +326,80 @@ const payload: MessageRequest = { id, text }; // `type: 'reply'` is now a compil `PartialThreadUpdate` (`{ set?: Partial>; unset?: Array }`) went with `client.partialUpdateThread`. Use the generated `UpdateThreadPartialRequest`, which is the same `set` / `unset` pair plus the required `message_id`. See [redundant guards and pass-throughs](./v9-to-v10-migration-guide-methods.md#removed-after-1000-rc4--redundant-guards-and-pass-throughs). +### `VotingVisibility` is now a type, not an enum + +`VotingVisibility` was a hand-written `enum` whose two members duplicated a union the +generator already emits. It is now derived: + +```ts +// before rc.4 +export enum VotingVisibility { + anonymous = 'anonymous', + public = 'public', +} + +// after +export type VotingVisibility = NonNullable; +// 'anonymous' | 'public' +``` + +⚠️ **This is a runtime break, not just a type change** — an `enum` is a value in the bundle, +so `VotingVisibility.anonymous` no longer resolves. The member names and their string values +were identical, so the rewrite is mechanical: + +```ts +// before +pollComposer.updateFields({ voting_visibility: VotingVisibility.anonymous }); +if (votingVisibility === VotingVisibility.anonymous) { … } + +// after +pollComposer.updateFields({ voting_visibility: 'anonymous' }); +if (votingVisibility === 'anonymous') { … } +``` + +Uses in **type** position keep working unchanged, including the narrowing cast that reading +a poll requires: `PollResponseData.voting_visibility` is typed `string` in the spec, not the +narrow union, so `poll.data.voting_visibility as VotingVisibility` is still needed. That +asymmetry is a spec gap on the response side, not something this change introduces. + +### `PollOptionData` removed — it described neither endpoint + +`PollOptionData` was `UpdatePollOptionRequest & { position?: number }`, and it typed the +parameter of **both** `poll.createOption()` and `poll.updateOption()`. Both halves were wrong. + +- **`position` does not exist.** It appears nowhere in the OpenAPI spec — zero occurrences + across the whole generated model set — and the generated methods whitelist their body + fields explicitly (`createPollOption` sends `text` and `custom`; `updatePollOption` sends + `id`, `text` and `custom`). Anything passed as `position` was silently dropped before the + request left the client. It never reached the wire, so removing it changes no behaviour. +- **`id` was required on create.** `UpdatePollOptionRequest.id` is required, so + `createOption()` demanded an option id that the create endpoint does not even send. Callers + worked around it with a cast — the React Native SDK did exactly this: + `poll.createOption({ text } as PollOptionData)`. + +Each method now takes the request type for the endpoint it actually calls: + +| Method | Was | Now | +| --------------------- | ---------------- | ------------------------- | +| `poll.createOption()` | `PollOptionData` | `CreatePollOptionRequest` | +| `poll.updateOption()` | `PollOptionData` | `UpdatePollOptionRequest` | + +```ts +// before — the cast existed only to satisfy the required `id` +await poll.createOption({ text: optionText } as PollOptionData); + +// after — no cast needed +await poll.createOption({ text: optionText }); +``` + +If you were passing `position`, drop it; it was never sent. Option ordering is server-side. + +`PartialPollUpdate` is **kept**. Unlike `PollOptionData` it adds no fields and invents +nothing — it narrows the generated `UpdatePollPartialRequest` +(`{ set?: Record; unset?: Array }`) to `Partial` and +`Array`, both derived, so a spec change flows through it without a +hand edit. + ## Verification After applying the renames, `yarn types` should pass. If a call site errors with `Cannot find name 'X'` where X is one of the v9 names in the left column, the rewrite is incomplete. From 6722cee1c2bd3e10bfa6ce7ac37dde873551fa60 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 20 Aug 2026 15:14:13 -0500 Subject: [PATCH 14/17] refactor: derive ModerationFlagOptions from FlagRequest `ModerationFlagOptions` types the `options` parameter of `moderation.flagUser()` and `moderation.flagMessage()`. It was hand-written and disagreed with `FlagRequest` in both directions: - It carried `user_id`, which `FlagRequest` does not have. The generated `flag()` whitelists its body explicitly (`entity_id`, `entity_type`, `entity_creator_id`, `reason`, `custom`, `moderation_payload`), so a `user_id` passed here was discarded before the request was built. It never reached the server, and does not need to: the transport already sends `user_id` as a query parameter on every request, taken from `client.userId`. - It omitted `entity_creator_id`, which the endpoint accepts. Both wrappers pinned it to `''` with no way to override; `options` is spread last, so it can now be supplied by the caller. `reason` stays excluded: both wrappers take it positionally, and since `options` is spread last, including it would let `options.reason` silently override the positional argument. BREAKING CHANGE: `ModerationFlagOptions.user_id` is removed. It was never serialized into the flag request, so no call behaviour changes. Co-Authored-By: Claude Opus 5 --- src/moderation.ts | 8 ++++ src/types.ts | 15 +++++--- v9-to-v10-migration-guide-type-renames.md | 45 +++++++++++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/moderation.ts b/src/moderation.ts index ba8388ac98..0b4fe8aa0e 100644 --- a/src/moderation.ts +++ b/src/moderation.ts @@ -23,6 +23,10 @@ export class Moderation extends ModerationApi { * @param reason - Reason for flagging the user. * @param options - Additional options for flagging the user (optional, defaults to `{}`). * @param options.custom - Additional data to be stored with the flag (optional). + * @param options.entity_creator_id - ID of the user who created the flagged entity. + * Overrides the empty-string default (optional). + * @param options.moderation_payload - Content submitted for moderation alongside the + * flag (optional). * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The flag response. @@ -52,6 +56,10 @@ export class Moderation extends ModerationApi { * @param reason - Reason for flagging the message. * @param options - Additional options for flagging the message (optional, defaults to `{}`). * @param options.custom - Additional data to be stored with the flag (optional). + * @param options.entity_creator_id - ID of the user who created the flagged entity. + * Overrides the empty-string default (optional). + * @param options.moderation_payload - Content submitted for moderation alongside the + * flag (optional). * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The flag response. diff --git a/src/types.ts b/src/types.ts index ab276337f2..07ffa26d94 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,9 +12,9 @@ import type { CreateDeviceRequest, CreatePollRequest, DraftPayloadResponse, + FlagRequest, Images, MessageResponse, - ModerationPayload, OwnUserResponse, QueryChannelsRequest, QueryMembersPayload, @@ -436,11 +436,14 @@ export type PartialPollUpdate = { unset?: Array; }; -export type ModerationFlagOptions = { - custom?: Record; - moderation_payload?: ModerationPayload; - user_id?: string; -}; +/** + * Options accepted by `moderation.flagUser` / `moderation.flagMessage` on top of the + * entity they resolve and the `reason` they take positionally. + */ +export type ModerationFlagOptions = Omit< + FlagRequest, + 'entity_id' | 'entity_type' | 'reason' +>; export type AIState = | 'AI_STATE_ERROR' diff --git a/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index 78fb0623ac..dcf45f3ed4 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -400,6 +400,51 @@ nothing — it narrows the generated `UpdatePollPartialRequest` `Array`, both derived, so a spec change flows through it without a hand edit. +### `ModerationFlagOptions` derived — and its `user_id` was never sent + +`ModerationFlagOptions` types the `options` parameter of `moderation.flagUser()` and +`moderation.flagMessage()`. It was hand-written, and it disagreed with `FlagRequest` in both +directions: + +```ts +// before rc.4 +export type ModerationFlagOptions = { + custom?: Record; + moderation_payload?: ModerationPayload; + user_id?: string; +}; + +// after +export type ModerationFlagOptions = Omit< + FlagRequest, + 'entity_id' | 'entity_type' | 'reason' +>; +// { entity_creator_id?: string; custom?: Record; moderation_payload?: ModerationPayload } +``` + +- **`user_id` is gone, and it never reached the server.** `FlagRequest` has no such field, and + the generated `flag()` whitelists its body explicitly — `entity_id`, `entity_type`, + `entity_creator_id`, `reason`, `custom`, `moderation_payload` — so a `user_id` passed here + was discarded before the request was built. Nothing to migrate: the acting user is already + sent as a query parameter on every request, taken from `client.userId` by the transport. +- **`entity_creator_id` is now settable.** It is accepted by the endpoint but was absent from + the options type, so both wrappers pinned it to `''` with no way to override. `options` is + spread last, so passing it now wins over that default. + +`reason` is deliberately excluded from the options type even though `FlagRequest` carries it: +both wrappers already take it as a positional argument, and because `options` is spread last, +including it would have let `options.reason` silently override the positional one. + +Note that the wrappers still default `entity_creator_id` to `''` when you do not supply it, +and an empty string **is** serialized (`JSON.stringify` drops `undefined`, not `''`). That +predates this change and is left as-is; pass the real creator id if your moderation +dashboard depends on the attribution. + +`flagUser` / `flagMessage` themselves are **kept**. Unlike the ban and mute wrappers removed +in [the moderation migration](./v9-to-v10-migration-guide-methods.md), they earn their place: +they resolve `entity_type` from `MODERATION_ENTITY_TYPES` (`'stream:user'`, +`'stream:chat:v1:message'`), magic strings that the spec types only as `string`. + ## Verification After applying the renames, `yarn types` should pass. If a call site errors with `Cannot find name 'X'` where X is one of the v9 names in the left column, the rewrite is incomplete. From 90d1906d2ac96ccf27916eec9ba2e38a81295fdd Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 20 Aug 2026 15:22:39 -0500 Subject: [PATCH 15/17] docs: record the verified entity_creator_id behaviour The note left the empty-string default as an open question. Verified against the live API instead: flagging a message and a user each way (`''`, omitted, the real id) produced review-queue items whose `entity_creator_id` and resolved `entity_creator` were the correct author in every case. The server derives the creator from the entity and discards the empty string, so the default is harmless and stays. Co-Authored-By: Claude Opus 5 --- v9-to-v10-migration-guide-type-renames.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index dcf45f3ed4..ef1f18e068 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -436,9 +436,12 @@ both wrappers already take it as a positional argument, and because `options` is including it would have let `options.reason` silently override the positional one. Note that the wrappers still default `entity_creator_id` to `''` when you do not supply it, -and an empty string **is** serialized (`JSON.stringify` drops `undefined`, not `''`). That -predates this change and is left as-is; pass the real creator id if your moderation -dashboard depends on the attribution. +and an empty string **is** serialized (`JSON.stringify` drops `undefined`, not `''`) — so every +flag this SDK sends carries `"entity_creator_id": ""`. That is harmless, verified against the +live API rather than assumed: flagging a message and a user each three ways (`''`, omitted, +and the real id) produced review-queue items whose `entity_creator_id` and resolved +`entity_creator` were the correct author in **all** cases. The server derives the creator from +the entity and discards the empty string, so the default is left as-is. `flagUser` / `flagMessage` themselves are **kept**. Unlike the ban and mute wrappers removed in [the moderation migration](./v9-to-v10-migration-guide-methods.md), they earn their place: From 49b9fbbed545b28656721b27beec39605c5ebb2b Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 20 Aug 2026 15:26:46 -0500 Subject: [PATCH 16/17] refactor: stop sending an empty entity_creator_id on flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `flagUser` / `flagMessage` pinned `entity_creator_id: ''` unconditionally, and an empty string is serialized (`JSON.stringify` drops `undefined`, not `''`), so every flag carried `"entity_creator_id": ""`. The field is optional in the spec, so there was no reason to send it. Verified against the live API rather than reasoned about: flagging a message and a user each way — `''`, omitted, and the real creator id — produced review-queue items whose `entity_creator_id` and resolved `entity_creator` were the correct author in every case. The server derives the creator from the entity and discards the empty string, so removing the default is behaviour-preserving. Attribution was never broken; the field was dead weight on the wire. Callers who do want to set it still can — `entity_creator_id` is part of `ModerationFlagOptions` and `options` is spread last. Co-Authored-By: Claude Opus 5 --- src/moderation.ts | 6 ++---- v9-to-v10-migration-guide-type-renames.md | 18 +++++++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/moderation.ts b/src/moderation.ts index 0b4fe8aa0e..7ede932ccf 100644 --- a/src/moderation.ts +++ b/src/moderation.ts @@ -24,7 +24,7 @@ export class Moderation extends ModerationApi { * @param options - Additional options for flagging the user (optional, defaults to `{}`). * @param options.custom - Additional data to be stored with the flag (optional). * @param options.entity_creator_id - ID of the user who created the flagged entity. - * Overrides the empty-string default (optional). + * Omitted when not supplied; the server resolves it from the entity (optional). * @param options.moderation_payload - Content submitted for moderation alongside the * flag (optional). * @param requestOptions - Per-request options such as an abort `signal`. Never serialized @@ -41,7 +41,6 @@ export class Moderation extends ModerationApi { { entity_type: MODERATION_ENTITY_TYPES.user, entity_id: flaggedUserId, - entity_creator_id: '', reason, ...options, }, @@ -57,7 +56,7 @@ export class Moderation extends ModerationApi { * @param options - Additional options for flagging the message (optional, defaults to `{}`). * @param options.custom - Additional data to be stored with the flag (optional). * @param options.entity_creator_id - ID of the user who created the flagged entity. - * Overrides the empty-string default (optional). + * Omitted when not supplied; the server resolves it from the entity (optional). * @param options.moderation_payload - Content submitted for moderation alongside the * flag (optional). * @param requestOptions - Per-request options such as an abort `signal`. Never serialized @@ -74,7 +73,6 @@ export class Moderation extends ModerationApi { { entity_type: MODERATION_ENTITY_TYPES.message, entity_id: messageId, - entity_creator_id: '', reason, ...options, }, diff --git a/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index ef1f18e068..c3b1683bc9 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -435,13 +435,17 @@ export type ModerationFlagOptions = Omit< both wrappers already take it as a positional argument, and because `options` is spread last, including it would have let `options.reason` silently override the positional one. -Note that the wrappers still default `entity_creator_id` to `''` when you do not supply it, -and an empty string **is** serialized (`JSON.stringify` drops `undefined`, not `''`) — so every -flag this SDK sends carries `"entity_creator_id": ""`. That is harmless, verified against the -live API rather than assumed: flagging a message and a user each three ways (`''`, omitted, -and the real id) produced review-queue items whose `entity_creator_id` and resolved -`entity_creator` were the correct author in **all** cases. The server derives the creator from -the entity and discards the empty string, so the default is left as-is. +The wrappers also **no longer pin `entity_creator_id` to `''`**. They used to send it +unconditionally, and an empty string _is_ serialized (`JSON.stringify` drops `undefined`, not +`''`), so every flag carried `"entity_creator_id": ""`. Now the field is simply absent unless +you pass one. + +This was verified against the live API rather than reasoned about: flagging a message and a +user each way — `''`, omitted, and the real id — produced review-queue items whose +`entity_creator_id` and resolved `entity_creator` were the correct author in **every** case. +The server derives the creator from the entity and discards the empty string, so the two are +equivalent and dropping the default changes nothing observable. Attribution was never broken; +the field was just dead weight on the wire. `flagUser` / `flagMessage` themselves are **kept**. Unlike the ban and mute wrappers removed in [the moderation migration](./v9-to-v10-migration-guide-methods.md), they earn their place: From 339d5391a3ba1320b33eb3c537605a5c5af6205d Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 20 Aug 2026 21:14:48 -0500 Subject: [PATCH 17/17] fix: remove leftover comments --- src/types.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/types.ts b/src/types.ts index 07ffa26d94..7c8ce78092 100644 --- a/src/types.ts +++ b/src/types.ts @@ -321,14 +321,6 @@ export type UserFilters = QueryUsersPayload['filter_conditions']; export type MemberFilters = QueryMembersPayload['filter_conditions']; -/** - * Sort Types - */ - -/** - * Base Types - */ - export type OGAttachment = RequireLiteral; export type PushProvider = CreateDeviceRequest['push_provider'];