diff --git a/ai-docs/v10-breaking-changes.md b/ai-docs/v10-breaking-changes.md index 40ca8ea9cf..5aadaf7e79 100644 --- a/ai-docs/v10-breaking-changes.md +++ b/ai-docs/v10-breaking-changes.md @@ -333,7 +333,7 @@ const client = StreamChat.getInstance(key, { ## 3. `StreamChat` methods and properties -Common rules for every generated or rewritten `StreamChat` method: path parameters are a first object argument (a stray key in it throws `"" is not a path parameter of `), then a request object sent as given, then `requestOptions?: StreamRequestOptions` (`{ signal?, onUploadProgress?, timeout? }`, replaces v9 `RequestOptions`). No-body endpoints keep an unused `_request?: Record` slot (pass `undefined` to reach `requestOptions`). All `user_id` / `userId` "on behalf of" overrides are dropped. Responses are `StreamResponse` = generated body plus `metadata` (rate limit, headers, status, client request id). Sort, filter and date shapes: [sort](#sort-shape), [filters](#filter-types), [request dates](#request-dates). +Common rules for every generated or rewritten `StreamChat` method: path parameters are a first object argument (a stray key in it throws `"" is not a path parameter of `), then a request object sent as given, then `requestOptions?: StreamRequestOptions` (`{ signal?, onUploadProgress?, timeout? }`, replaces v9 `RequestOptions`). No-body endpoints take no request object: `requestOptions` follows the path params, or is the only argument (`getApp(requestOptions?)`, `getPoll({ poll_id }, requestOptions?)`). All `user_id` / `userId` "on behalf of" overrides are dropped. Responses are `StreamResponse` = generated body plus `metadata` (rate limit, headers, status, client request id). Sort, filter and date shapes: [sort](#sort-shape), [filters](#filter-types), [request dates](#request-dates). @@ -392,18 +392,18 @@ Common rules for every generated or rewritten `StreamChat` method: path paramete | `searchUserGroups` / `searchRoles` | `(options, requestOptions?)` | `(request, requestOptions?)` (same keys) | | `updateUsers` | `(users: UserResponse[])` | `({ users: Record })` | | `blockUser` | `(blockedUserID, user_id?)` | `(blockedUserId, requestOptions?)` (wrapper over `blockUsers`) | - | `getBlockedUsers` | `(user_id?)` | `(_request?, requestOptions?)` | + | `getBlockedUsers` | `(user_id?)` | `(requestOptions?)` | | `markChannelsRead` | `(data?: { read_by_channel?, user?, user_id? })`, resolved `void` | `({ read_by_channel? }?)`, resolves the response | | `markChannelsDelivered` | `(data: MarkDeliveredOptions)` | `(request?: MarkDeliveredRequest, requestOptions?)` (still a no-op when `latest_delivered_messages` is empty; generated `markDelivered` is the raw call) | | `translateMessage` | `(messageId, language)` | `({ id }, { language })`; `language` is a closed union of 57 codes (`TranslateMessageRequest['language']`), so a `string` variable fails (TS2322) | - | `getMessage` | `(messageID, { show_deleted_message? }?)` | `({ id }, _request?, requestOptions?)` (option gone) | + | `getMessage` | `(messageID, { show_deleted_message? }?)` | `({ id }, requestOptions?)` (option gone) | | `sync` | `(channel_cids, last_sync_at: string, { watch?, with_inaccessible_cids? }?)` | `({ channel_cids, last_sync_at: Date, watch?, with_inaccessible_cids? })` | | `createBlockList` | `(blockList)` | `(request)` | | `listBlockLists` | `({ team? }?)` | `({ team?, cursor?, limit? }?)` | | `updateBlockList` / `deleteBlockList` | `(name, data)` | `({ name }, request?)` | | `deleteChannels` | `(cids, { hard_delete? }?)` | `({ cids, hard_delete? })` | | `queryDrafts` | `({ filter?, sort?, user_id?, ...pager }?)` | `({ filter?, sort?, limit?, next?, prev? }?)` | - | `getAppSettings` | `()` -> `AppSettingsAPIResponse` | `(_request?, requestOptions?)` -> `StreamResponse` (wraps generated `getApp`); `appSettingsPromise` typed the same | + | `getAppSettings` | `()` -> `AppSettingsAPIResponse` | `(requestOptions?)` -> `StreamResponse` (wraps generated `getApp`); `appSettingsPromise` typed the same | | `setGuestUser` | `(user: UserResponse)` | `(user: UserRequest, requestOptions?)` (calls `createGuest({ user })`) | | poll methods (`createPoll`, `updatePoll`, `getPoll`, `deletePoll`, `createPollOption`, `updatePollOption`, `getPollOption`, `deletePollOption`, `castPollVote`, `queryPolls`, `queryPollVotes`, `queryPollAnswers`) | positional | see [poll client methods](#poll-client-methods) | | reminder methods (`createReminder`, `updateReminder`, `deleteReminder`, `queryReminders`) | object with `messageId` | see [reminder methods](#reminder-methods) | @@ -431,8 +431,8 @@ Common rules for every generated or rewritten `StreamChat` method: path paramete **Kind:** signature · **Impact:** medium - **v9:** `queryThreads(options?)` -> `{ threads: Thread[], next }` (defaults `limit 10, participant_limit 10, reply_limit 3, watch true`, poll cache hydrated); `getThread(messageId, options?)` -> `Thread` (defaults `participant_limit 100, reply_limit 3, watch true`); `queryReactions(messageID, filter, sort?, options?)` read the offline DB first and dispatched `offline_reactions.queried`. -- **v10:** `queryThreads(request?)`, `getThread({ message_id }, request?)`, `queryReactions({ id }, request?)` are raw generated calls with no defaults and no state. Hydrating versions: `queryThreadsAndHydrate(options?, requestOptions?)`, `getThreadAndHydrate(messageId, options?, requestOptions?)` (thread instances resolved through `client.threads`), `queryReactionsAndHydrate({ id }, request?)`. -- **Migrate:** switch calls that need `Thread` instances or offline reactions to the `*AndHydrate` variants; when calling the raw ones pass `watch: true` yourself if needed. +- **v10:** `queryThreads(request?)`, `getThread({ message_id }, request?)`, `queryReactions({ id }, request?)` are raw generated calls with no defaults and no state. Hydrating versions: `queryThreadsAndHydrate(options?, requestOptions?)`, `getThreadAndHydrate({ message_id }, options?, requestOptions?)` (path params like `getThread`; thread instances resolved through `client.threads`), `queryReactionsAndHydrate({ id }, request?)`. +- **Migrate:** switch calls that need `Thread` instances or offline reactions to the `*AndHydrate` variants (`getThread(id)` -> `getThreadAndHydrate({ message_id: id })`); when calling the raw ones pass `watch: true` yourself if needed. - **Source:** `src/client.ts:1643-1670,2059-2142` (v10); `src/client.ts:2307-2349,3725-3808` (v9) @@ -464,9 +464,9 @@ Common rules for every generated or rewritten `StreamChat` method: path paramete **Kind:** signature · **Impact:** low - **v9:** `pinMessage(msg, timeoutOrExpirationDate?, pinnedBy?, pinnedAt?)`, `unpinMessage(msg, userId?)`. -- **v10:** `pinMessage(msg, timeoutOrExpirationDate?, pinnedAt?, requestOptions?)`, `unpinMessage(msg, requestOptions?)`. `pinnedBy` (3rd) removed, so `pinnedAt` moves to 3rd. A numeric argument is a relative offset in seconds; a server `TimestampNS` is rejected at compile time; non-representable offsets now throw. -- **Migrate:** drop the 3rd `pinnedBy` argument (`pinMessage(m, null, 'user', date)` -> `pinMessage(m, null, date)`); drop `unpinMessage`'s user argument. -- **Source:** `src/client.ts:1908-2010` (v10); `src/client.ts:3405-3445` (v9) +- **v10:** `pinMessage({ id }, { pin_expires?, pinned_at? }?, requestOptions?)` (`PinMessageOptions`), `unpinMessage({ id }, requestOptions?)`. Both take the message (or `{ id }`), not a bare id string. `pin_expires` is a `Date`, an RFC3339 string or `null` (no expiry, the default); `pinned_at` is a `Date` or an RFC3339 string (defaults to now). There is no relative offset in seconds any more and no `pinnedBy`; a server `TimestampNS` is rejected at compile time. The internal `_normalizeExpiration` / `_validateAndGetMessageId` helpers are removed. +- **Migrate:** `pinMessage(m, 120)` -> `pinMessage(m, { pin_expires: new Date(Date.now() + 120 * 1000) })`; `pinMessage(m, date)` -> `pinMessage(m, { pin_expires: date })`; `pinMessage(m, null, 'user', date)` -> `pinMessage(m, { pinned_at: date })`; `pinMessage('id')` -> `pinMessage({ id: 'id' })`; drop `unpinMessage`'s user argument and pass `{ id }` for a bare id. +- **Source:** `src/client.ts:1876-1920`, `src/types.ts` (`PinMessageOptions`) (v10); `src/client.ts:3405-3445` (v9) @@ -583,8 +583,8 @@ Common rules for every generated or rewritten `StreamChat` method: path paramete | `client.removeShadowBan(id, options?)` | `client.unbanUser(id, { shadow: true, ...options })` (still v1 `DELETE /moderation/ban`) | | `client.muteUser(targetID, userID?, options?)` | `client.moderation.mute({ target_ids: [id], timeout? })`; response `MuteResponse` (`mutes`, no single `mute`) | | `client.unmuteUser(targetID, currentUserID?)` | `client.moderation.unmuteUser(id)` or `client.moderation.unmute({ target_ids: [id] })` | - | `client.flagMessage(targetMessageID, { reason, custom, user_id }?)` | `client.moderation.flagMessage(messageId, reason?, { custom, ... }?)`; V2 `/api/v2/moderation/flag`, response `FlagItemResponse` | - | `client.flagUser(targetID, { reason, user_id }?)` | `client.moderation.flagUser(userId, reason?, options?)` | + | `client.flagMessage(targetMessageID, { reason, custom, user_id }?)` | `client.moderation.flagMessage({ message_id, reason?, custom, ... })`; V2 `/api/v2/moderation/flag`, response `FlagItemResponse` | + | `client.flagUser(targetID, { reason, user_id }?)` | `client.moderation.flagUser({ user_id, reason?, ...options })` | | `client.unflagMessage`, `client.unflagUser`, `client.unblockMessage`, `client._unblockMessage` | removed, no replacement in this package (review via `client.moderation.submitAction` or the dashboard) | - **Migrate:** per table; `user_id` overrides are gone (the connected user acts). @@ -697,9 +697,9 @@ The removal of the `connection.changed` / `transport.changed` events is owned by **Kind:** behavior · **Impact:** medium · **PR:** #1841 - **v9:** on reconnect `client.recoverState()` re-queried all `activeChannels` cids in one `queryChannels({ cid: { $in } }, { last_message_at: -1 }, { limit: 30 })`, then dispatched `connection.recovered`; gated by `recoverStateOnReconnect`, which skipped only the query: `connection.recovered` was dispatched after every reconnect either way; public method. -- **v10:** `client.connectionRecovery` re-runs each registered channel list's first page (`channelManager.recover()`) and, independently, `channel.reload()` only for activated channels and `thread.reload()` for displayed threads; no bulk `$in` query; channels nobody activated are not refreshed; `connection.recovered` is withheld if the socket drops mid-recovery; runs on every reconnect path including `closeConnection()` -> `openConnection()`. Toggle `config.client.connectionRecovery.enabled`; manual trigger `client.connectionRecovery.recover()`. `recoverState` removed. Channel side: [activate for recovery](#channel-activate-recovery). With `connectionRecovery.enabled: false` nothing is recovered and `connection.recovered` is never dispatched (v9 dispatched it after every reconnect even with `recoverStateOnReconnect: false`); `client.connectionRecovery.recover()` is then a no-op, and threads are not reloaded either (`ThreadManager` reloads on `connection.recovered`). With an initialized offline DB, active channels are reloaded and `connection.recovered` is dispatched only after the offline sync finishes (pending-task replay), not as soon as the socket is healthy again. -- **Migrate:** remove UI-side reconnect re-queries that existed because `recoverStateOnReconnect` was off; call `channel.activate()` for channels on screen. If you disabled recovery to run your own, listen to `client.wsConnection.state` (an `isHealthy` false -> true edge with `lastUnhealthyAt` set) instead of `connection.recovered`. -- **Source:** `src/connection/ConnectionRecoveryManager.ts:160-215,221-371` (sync-edge path `:196-215`, gate `:273-279`, dispatch `:353`, `recover()` `:362-370`), `src/thread_manager.ts:468`, `src/client.ts:173,383-384` (v10); `src/client.ts:1782-1826` (dispatch in both branches `:1811,1815`) (v9) +- **v10:** `client.connectionRecovery` re-runs each registered channel list's first page (`channelManager.recover()`) and, independently, `channel.reload()` only for activated channels and `thread.reload()` for displayed threads; no bulk `$in` query; channels nobody activated are not refreshed; `connection.recovered` is withheld if the socket drops mid-recovery; runs on every reconnect path including `closeConnection()` -> `openConnection()`. Toggle `config.client.connectionRecovery.enabled`; manual trigger `client.connectionRecovery.recover()`. `recoverState` removed. Channel side: [activate for recovery](#channel-activate-recovery). With `connectionRecovery.enabled: false` nothing is recovered on reconnect and `connection.recovered` is not dispatched, since it means a recovery finished (v9 dispatched it after every reconnect even with `recoverStateOnReconnect: false`), so threads are not reloaded either (`ThreadManager` reloads on `connection.recovered`). `client.connectionRecovery.recover()` still runs a full recovery and dispatches `connection.recovered` when it finishes. With an initialized offline DB, active channels are reloaded and `connection.recovered` is dispatched only after the offline sync finishes (pending-task replay), not as soon as the socket is healthy again. +- **Migrate:** remove UI-side reconnect re-queries that existed because `recoverStateOnReconnect` was off; call `channel.activate()` for channels on screen. If you disabled recovery to run your own, listen to `client.wsConnection.state` (an `isHealthy` false -> true edge with `lastUnhealthyAt` set) instead of `connection.recovered`; to run the SDK's recovery from there, `await client.connectionRecovery.recover()`. +- **Source:** `src/connection/ConnectionRecoveryManager.ts:160-215,221-371` (sync-edge path `:210-224`, gates `:164,219`, dispatch `:356`, `recover()` `:367-375`), `src/thread_manager.ts:468`, `src/client.ts:173,383-384` (v10); `src/client.ts:1782-1826` (dispatch in both branches `:1811,1815`) (v9) @@ -747,14 +747,14 @@ The removal of the `connection.changed` / `transport.changed` events is owned by -### Automatic retries on 429 and `TOKEN_EXPIRED` +### Automatic retries on `TOKEN_EXPIRED` **Kind:** behavior · **Impact:** medium · **PR:** #1828 - **v9:** only `TOKEN_EXPIRED` with a token provider was retried (reload token, back off after the first failure); 429 was thrown at once; static tokens were not retried. -- **v10:** every request runs through `runWithRetry` with up to 10 retries (11 requests) and `retryInterval` backoff (delays of 0.25 to 20.5 s, about 100 s in total) on HTTP 429 and on `TOKEN_EXPIRED` (static tokens included). When retries run out the rejection is `RetryError('Reached maximum amount of retry attempts')` (`name: 'RetryError'`, not root-exported), not `StreamAPIError`. The static-token retry and the error class are tracked in v10-followups.md. -- **Migrate:** expect long waits on persistent 429s; set `requestOptions.signal` / `timeout` to bound them; handle `RetryError` (no `code` / `status`; match on `error.name`). -- **Source:** `src/api-client.ts:226-278`, `src/utils/retryable.ts:64-135`, `src/utils.ts:128-133` (v10); `src/client.ts:1384-1404` (v9) +- **v10:** every request runs through `runWithRetry`, which retries `TOKEN_EXPIRED` from a token provider up to 10 times (11 requests) with `retryInterval` backoff (delays of 0.25 to 20.5 s, about 100 s in total), reloading the token before each retry; v9 retried without a limit. A 429 is not retried, as in v9. When the retries run out the rejection is the `StreamAPIError` of the last attempt (`code: 40`, `status: 401`), which `isEphemeral` treats as definitive, so offline-aware operations fail it instead of queueing it. A static token is not retried (it would reload to the same expired value), as in v9. +- **Migrate:** none for 429s. Treat a `StreamAPIError` with `code: 40` as "the token provider keeps returning expired tokens"; set `requestOptions.signal` / `timeout` to bound the retries. +- **Source:** `src/api-client.ts:226-275`, `src/utils/retryable.ts:64-136`, `src/utils.ts:128-133` (v10); `src/client.ts:1384-1404` (v9) @@ -856,7 +856,7 @@ const p = message.custom.priority; - **v10:** same 13 interfaces, same names, same root re-export. They now type the `custom` object only. Differences: - `CustomAttachmentData` ships two declared members, `mime_type?: string` and `file_size?: number` ([attachment metadata](#attachment-metadata-custom)); augmentations merge with them. - `CustomCommandData` is no longer referenced anywhere (`Command.name` / `set` are plain `string`), so augmenting it has no effect. - - `CustomEventData` types `event.custom`; event names come from `CustomEventTypes` keys, and undeclared custom names are typed `never` ([custom events](#custom-events)). + - `CustomEventData` types `event.custom`; event names come from `CustomEventTypes` keys, and `on()` / `off()` reject an undeclared custom name ([custom events](#custom-events)). - poll options use `CustomPollOptionData` (v9 `PollOptionResponse` used `CustomPollData`). - filters reach custom fields through `custom.` keys derived from the augmentation ([filters](#filter-types)); sort keys are untyped strings ([sort](#sort-shape)). - required members declared in an augmentation become required in every `custom` object, including test fixtures of response types. @@ -1013,8 +1013,8 @@ const p = message.custom.priority; **Kind:** type-shape · **Impact:** medium - **v9:** request dates accepted `string | Date` in many places (`hide_history_before`, `pinned_at_*`, `created_at_*` pagination, `message_timestamp`, `remind_at: string | null`, `end_at: string`, `disabled_until: string`). -- **v10:** generated request fields are `Date` only (22 fields, e.g. `MessageRequest.pin_expires/pinned_at`, `MessagePaginationParams.created_at_*`, `MarkUnreadRequest.message_timestamp`, `CreateReminderRequest.remind_at`, `UpdateLiveLocationRequest.end_at`, `SharedLocation.end_at`, `PushPreferenceInput.disabled_until`, `UpdateChannelRequest.hide_history_before`). `ChannelUpdateOptions.hide_history_before` and `PinnedMessagePaginationOptions.pinned_at_*` (`pinned_at_after`, `pinned_at_after_or_equal`, `pinned_at_around`, `pinned_at_before`, `pinned_at_before_or_equal`) drop `string`. `ChannelUpdateOptions` follows `UpdateChannelRequest` (`accept_invite`, `add_filter_tags`, `add_members`, `cooldown`, `data`, `invites`, `reject_invite`, `remove_filter_tags`, `remove_members`). A `TimestampNS` from a response is not assignable (TS2322). Exceptions: `QueryMembersPayload`, `QueryBannedUsersPayload`, `QueryFutureChannelBansPayload` type `created_at_*` as `TimestampNS` (so `QueryMembersOptions.created_at_*` went `string` -> `TimestampNS`, and `QueryMembersOptions` gains `type`, `id`, `members`, `sort`), and `listUserGroups` takes `created_at_gt: string`. -- **Migrate:** `new Date(iso)` for literals, `nsToDate(ts)` for server values; for `queryMembers` / `queryBannedUsers` / `queryFutureChannelBans` date bounds pass `msToNs(ms)` (server acceptance of ns there is unverified). +- **v10:** generated request fields are `Date` only (22 fields, e.g. `MessageRequest.pin_expires/pinned_at`, `MessagePaginationParams.created_at_*`, `MarkUnreadRequest.message_timestamp`, `CreateReminderRequest.remind_at`, `UpdateLiveLocationRequest.end_at`, `SharedLocation.end_at`, `PushPreferenceInput.disabled_until`, `UpdateChannelRequest.hide_history_before`). `ChannelUpdateOptions.hide_history_before` and `PinnedMessagePaginationOptions.pinned_at_*` (`pinned_at_after`, `pinned_at_after_or_equal`, `pinned_at_around`, `pinned_at_before`, `pinned_at_before_or_equal`) drop `string`. `ChannelUpdateOptions` follows `UpdateChannelRequest` (`accept_invite`, `add_filter_tags`, `add_members`, `cooldown`, `data`, `invites`, `reject_invite`, `remove_filter_tags`, `remove_members`). A `TimestampNS` from a response is not assignable (TS2322). Exceptions: `QueryMembersPayload`, `QueryBannedUsersPayload`, `QueryFutureChannelBansPayload` take `created_at_*` as `Date | string` (the spec declares `date-time`; the generator's `TimestampNS` is retyped when the client is generated, and the API rejects a nanosecond number there with code 4 "Time.UnmarshalJSON: input is not a JSON string"), so a v9 `string` still compiles (`QueryMembersOptions` gains `type`, `id`, `members`, `sort`), and `listUserGroups` takes `created_at_gt: string`. +- **Migrate:** `new Date(iso)` for literals, `nsToDate(ts)` for server values, including the `queryMembers` / `queryBannedUsers` / `queryFutureChannelBans` date bounds. - **Source:** `src/gen/models/index.ts:4725,4820,6815-6818,7180-7183,7223-7226`, `src/types.ts:70,132-135,137`, `src/utils/time.ts:19-21` (v10) @@ -1035,7 +1035,7 @@ const p = message.custom.priority; **Kind:** type-shape · **Impact:** high - **v9:** `Event = CustomEventData & { type: EventTypes; ...~55 optional fields }`, so `e.user?.id`, `e.cid`, `e.total_unread_count` compiled on any event. -- **v10:** `Event = (WSEvent & { received_at?: TimestampNS }) | LocalEvent | CustomEventTypeUnion`. `WSEvent` is the generated union of 83 `{ type: '' } & Event` members (one is `{ type: '*' } & CustomEvent`); `LocalEvent` (not exported) covers `live_location_sharing.started` / `stopped`, `channels.queried` (`queriedChannels`), `connection.recovered`, `connection.fallback_activated` (`mode`), `offline_reactions.queried` (`offlineReactions`), `capabilities.changed`, `message.read_locally`. Fields exist only on the members that declare them; `EventPayload<'message.new'>` extracts one member. `SyncResponse.events` is `WSEvent[]`; `TypingStartEvent` is the generated `typing.start` payload. v9 flat fields now present only on specific members include `ai_message`, `ai_state`, `app`, `channel_custom`, `channel_id`, `channel_last_message_at`, `channel_member_count`, `channel_type`, `clear_history`, `connection_id`, `deleted_for_me`, `draft`, `first_unread_message_id`, `hard_delete`, `last_delivered_at`, `last_delivered_message_id`, `last_read_at`, `last_read_message_id`, `live_location`, `mark_messages_deleted`, `me`, `member`, `online`, `own_capabilities`, `parent_id`, `poll`, `poll_vote`, `reaction`, `reminder`, `shadow`, `team`, `thread`, `thread_id`, `total_unread_count`, `unread_channels`, `unread_count`, `unread_messages`, `unread_thread_messages`, `unread_threads`, `watcher_count`. +- **v10:** `Event = (WSEvent & { received_at?: TimestampNS }) | LocalEvent | CustomEventTypeUnion`. `WSEvent` is the generated union of `{ type: '' } & Event` members (the spec's `{ type: '*' } & CustomEvent` placeholder is dropped when the client is generated, see [event names](#event-type-names)); `LocalEvent` (not exported) covers `live_location_sharing.started` / `stopped`, `channels.queried` (`queriedChannels`), `connection.recovered`, `connection.fallback_activated` (`mode`), `offline_reactions.queried` (`offlineReactions`), `capabilities.changed`, `message.read_locally`. Fields exist only on the members that declare them; `EventPayload<'message.new'>` extracts one member. `SyncResponse.events` is `WSEvent[]`; `TypingStartEvent` is the generated `typing.start` payload. v9 flat fields now present only on specific members include `ai_message`, `ai_state`, `app`, `channel_custom`, `channel_id`, `channel_last_message_at`, `channel_member_count`, `channel_type`, `clear_history`, `connection_id`, `deleted_for_me`, `draft`, `first_unread_message_id`, `hard_delete`, `last_delivered_at`, `last_delivered_message_id`, `last_read_at`, `last_read_message_id`, `live_location`, `mark_messages_deleted`, `me`, `member`, `online`, `own_capabilities`, `parent_id`, `poll`, `poll_vote`, `reaction`, `reminder`, `shadow`, `team`, `thread`, `thread_id`, `total_unread_count`, `unread_channels`, `unread_count`, `unread_messages`, `unread_thread_messages`, `unread_threads`, `watcher_count`. - **Migrate:** narrow on `event.type` (or `'field' in event`), or type handlers as `EventPayload<'...'>`; `client.on('message.new', (e) => ...)` infers the member. - **Source:** `src/types.ts:163-209,438-441`, `src/gen/models/index.ts:11938-12023` (v10); `src/types.ts:1712-1776` (v9) @@ -1046,9 +1046,9 @@ const p = message.custom.priority; **Kind:** renamed · **Impact:** medium · **PR:** #1836 - **v9:** `EventTypes = 'all' | keyof typeof EVENT_MAP | keyof CustomEventTypes`; `Event['type']` was `EventTypes`, so it included `'all'`. `EVENT_MAP` runtime object exported from `src/events.ts` (70 names). -- **v10:** `EventType = Event['type'] | 'all'`; `EVENT_MAP` and `src/events.ts` deleted (no runtime list of names). `'all'` is no longer an `Event['type']` literal but remains in `EventType`, and `client.on(cb)` / `channel.on(cb)` without a name still register under `'all'` and receive every event. `'*'` is a new `Event['type']` literal (spec placeholder member `{ type: '*' } & CustomEvent`). Names no longer in the union: `connection.changed`, `transport.changed` ([removed](#connection-changed-removed)), `channel.muted`, `channel.unmuted`, `user.unread_message_reminder` (not in the v2 event spec; the v9 SDK never handled them). Added: `app.updated`, `channel.frozen`, `channel.unfrozen`, `channel.max_streak_changed`, `connection.ok`, `connection.error`, `connection.fallback_activated`, `message.pending`, `moderation.custom_action`, `moderation.flagged`, `moderation.mark_reviewed`, `poll.deleted`, `scheduled_message.created` / `deleted` / `failed` / `sent` / `updated`, `user.deactivated`, `user.reactivated`, `user.muted`, `user_group.created` / `deleted` / `updated` / `member_added` / `member_removed`. -- **Current v10 typing quirks (tracked in v10-followups.md):** `client.on('all', cb)` / `channel.on('all', cb)` type the event as `never` (`EventHandler<'all'>` extracts no member, TS2339 on any property) even though the listener receives every event at runtime; `client.on('*', cb)` type-checks with a `CustomEvent` payload but never fires, because dispatch keys listeners by the real `event.type` and nothing is dispatched under `'*'`. -- **Migrate:** `import type { EventType }`; replace `Object.keys(EVENT_MAP)` with your own list; use `client.on(cb)` (no name) for all events; listen to `notification.channel_mutes_updated` instead of `channel.muted` / `unmuted`. +- **v10:** `EventType = Event['type'] | 'all'`; `EVENT_MAP` and `src/events.ts` deleted (no runtime list of names). `'all'` is no longer an `Event['type']` literal but remains in `EventType`, and `client.on(cb)` / `channel.on(cb)` without a name still register under `'all'` and receive every event. `'*'` is not an event type: the spec's custom-event placeholder (`{ type: '*' } & CustomEvent`) is dropped from the generated unions, because nothing is dispatched under `'*'` and custom events arrive under their own names. Names no longer in the union: `connection.changed`, `transport.changed` ([removed](#connection-changed-removed)), `channel.muted`, `channel.unmuted`, `user.unread_message_reminder` (not in the v2 event spec; the v9 SDK never handled them). Added: `app.updated`, `channel.frozen`, `channel.unfrozen`, `channel.max_streak_changed`, `connection.ok`, `connection.error`, `connection.fallback_activated`, `message.pending`, `moderation.custom_action`, `moderation.flagged`, `moderation.mark_reviewed`, `poll.deleted`, `scheduled_message.created` / `deleted` / `failed` / `sent` / `updated`, `user.deactivated`, `user.reactivated`, `user.muted`, `user_group.created` / `deleted` / `updated` / `member_added` / `member_removed`. +- **Listener names:** `on()` / `off()` on the client and on a channel accept only an `EventType` (v9 also accepted any string). A typo, a name removed in v10 such as `connection.changed`, or a custom event not declared in `CustomEventTypes` is a compile error at the call site. `'all'` types the event as the full `Event` union. +- **Migrate:** `import type { EventType }`; replace `Object.keys(EVENT_MAP)` with your own list; use `client.on(cb)` or `client.on('all', cb)` for all events; declare custom event names in `CustomEventTypes`; drop listeners for removed names; listen to `notification.channel_mutes_updated` instead of `channel.muted` / `unmuted`. - **Source:** `src/types.ts:196-209`, `src/client.ts:987-1040,1386-1396`, `src/channel.ts:2234-2297,2877`, `src/gen/models/index.ts:11854,11939` (v10); `src/events.ts:1-75`, `src/types.ts:1783`, `src/index.ts:10` (v9) @@ -1058,7 +1058,7 @@ const p = message.custom.priority; **Kind:** behavior · **Impact:** high - **v9:** `StableWSConnection` / `WSConnectionFallback` dispatched `connection.changed` (`{ online }`) on health changes (drop announcement delayed about 5 s); switching to long-poll dispatched `transport.changed` (`{ mode: 'longpoll' }`); `ConnectionChangeEvent` type exported. -- **v10:** neither is emitted and `ConnectionChangeEvent` is removed. `client.on('connection.changed', fn)` still compiles through the string overload but never fires and `e` is `never`. Socket health: `client.wsConnection.state` (`isHealthy`, `lastHealthyAt`, `lastUnhealthyAt`, see [wsConnection](#ws-connection)); device network: `client.networkConnection.state` (`isOnline`, ...); fallback switch: local `connection.fallback_activated` (`{ mode }`, see [long-poll](#long-poll-fallback)); recovery: `connection.recovered`. The stores publish every transition immediately (no 5 s debounce). Offline sync no longer listens to it ([offline sync](#offline-sync-manager)); threads no longer read it ([thread manager](#thread-manager-behaviour)). +- **v10:** neither is emitted and `ConnectionChangeEvent` is removed. `client.on('connection.changed', fn)` no longer compiles: the name is not an `EventType`. Socket health: `client.wsConnection.state` (`isHealthy`, `lastHealthyAt`, `lastUnhealthyAt`, see [wsConnection](#ws-connection)); device network: `client.networkConnection.state` (`isOnline`, ...); fallback switch: local `connection.fallback_activated` (`{ mode }`, see [long-poll](#long-poll-fallback)); recovery: `connection.recovered`. The stores publish every transition immediately (no 5 s debounce). Offline sync no longer listens to it ([offline sync](#offline-sync-manager)); threads no longer read it ([thread manager](#thread-manager-behaviour)). - **Migrate:** `client.wsConnection.state.subscribeWithSelector(({ isHealthy }) => ({ isHealthy }), ({ isHealthy }) => ...)` (fires once immediately); delay banners yourself. - **Source:** `src/connection/wsConnection/WSConnection.ts:289`, `src/connection/ConnectionRecoveryManager.ts:353` (v10); `src/connection.ts:569,576`, `src/connection_fallback.ts:51-55`, `src/client.ts:1878`, `src/types.ts:1707` (v9) @@ -1069,7 +1069,7 @@ const p = message.custom.priority; **Kind:** type-shape · **Impact:** medium - **v9:** custom events were `Event` with `CustomEventData` spread at the top level (`e.text`); any string was a valid listener name; `UserCustomEvent = CustomEventData & { type: string }` for `client.sendUserCustomEvent`. -- **v10:** custom events arrive as `CustomEvent { type; custom: CustomEventData; created_at: TimestampNS; received_at? }`; `CustomEventTypes` keys become union members `{ type: K } & Omit`. An undeclared name yields `never`: `client.on('my_custom', (e) => e.type)` fails with TS2339. `UserCustomEvent` is removed with `sendUserCustomEvent`. Sending: `channel.sendEvent({ event: { type, custom: {...} } })` ([sendEvent](#channel-send-event)). +- **v10:** custom events arrive as `CustomEvent { type; custom: CustomEventData; created_at: TimestampNS; received_at? }`; `CustomEventTypes` keys become union members `{ type: K } & Omit`. An undeclared name is not an `EventType`, so `client.on('my_custom', cb)` and `client.off('my_custom', cb)` do not compile. `UserCustomEvent` is removed with `sendUserCustomEvent`. Sending: `channel.sendEvent({ event: { type, custom: {...} } })` ([sendEvent](#channel-send-event)). - **Migrate:** declare names in `interface CustomEventTypes { my_custom: true }` via module augmentation; read `e.custom.text`. - **Source:** `src/types.ts:196-206`, `src/gen/models/index.ts:2663-2668` (v10); `src/types.ts:1712,1777-1783` (v9) @@ -1080,8 +1080,8 @@ const p = message.custom.priority; **Kind:** signature · **Impact:** low - **v9:** `EventHandler = (event: Event) => void`. -- **v10:** `EventHandler = (event: Extract) => void`. Unparameterised `EventHandler` still receives the full union. With `T = 'all'` the event is typed `never` ([quirk](#event-type-names)). -- **Migrate:** use `client.on(cb)` without a name for all events; annotate handlers as `EventHandler<'message.new'>` or `(e: EventPayload<'message.new'>) => void`. +- **v10:** `EventHandler`: the event is the full `Event` union for `'all'` (the default, so unparameterised `EventHandler` still receives every event) and `Extract` for any other type. `off(type, handler)` takes `EventHandler` as well, so the handler passed to `on(type, handler)` can be removed with the same type. `EventHandler` no longer compiles ([listener names](#event-type-names)). +- **Migrate:** use `client.on(cb)` without a name for all events; annotate handlers as `EventHandler<'message.new'>` or `(e: EventPayload<'message.new'>) => void`; replace `EventHandler` with `EventHandler`. - **Source:** `src/types.ts:209`, `src/client.ts:987-1016`, `src/channel.ts:2234-2243` (v10); `src/types.ts:1781` (v9) @@ -1259,7 +1259,7 @@ Common pattern for every generated or overridden `Channel` method: the trailing **Kind:** signature · **Impact:** medium - **v9:** `sendEvent(event: Event)` (required initialization; arbitrary top-level fields); `updateAIState(messageId, state, options?)` sent `ai_state` / `message_id` / `ai_message` top-level; `clearAIIndicator()`, `stopAIResponse()`. -- **v10:** `sendEvent({ event: { type, parent_id?, custom? } }, requestOptions?)`: non-`type` / `parent_id` fields must go under `event.custom` (custom event types need `CustomEventTypes` augmentation, see [custom events](#custom-events)); no initialization required; synchronous throw for id-less channels. `updateAIState(messageId, state, options?, requestOptions?)` sends the AI fields under `event.custom`. `clearAIIndicator(_request?, requestOptions?)` / `stopAIResponse(_request?, requestOptions?)` take a placeholder first argument. Incoming `ai_indicator.*` events now also drive `channel.state.aiState` (reset to `AIStates.Idle` while offline). +- **v10:** `sendEvent({ event: { type, parent_id?, custom? } }, requestOptions?)`: non-`type` / `parent_id` fields must go under `event.custom` (custom event types need `CustomEventTypes` augmentation, see [custom events](#custom-events)); no initialization required; synchronous throw for id-less channels. `updateAIState(messageId, state, options?, requestOptions?)` sends the AI fields under `event.custom`. `clearAIIndicator(requestOptions?)` / `stopAIResponse(requestOptions?)` take a placeholder first argument. Incoming `ai_indicator.*` events now also drive `channel.state.aiState` (reset to `AIStates.Idle` while offline). - **Migrate:** `channel.sendEvent({ type: 'x', text })` -> `channel.sendEvent({ event: { type: 'x', custom: { text } } })`; pass `undefined` first to reach `requestOptions` on the AI methods. - **Source:** `src/channel.ts:1173-1240,2405-2415`, `src/gen/chat/ChannelApi.ts:194-209` (v10); `src/channel.ts:325-330,1135-1170` (v9) @@ -1270,8 +1270,8 @@ Common pattern for every generated or overridden `Channel` method: the trailing **Kind:** renamed · **Impact:** medium · **PR:** #1832 - **v9:** `sendFile(uri, name?, contentType?, user?, axiosRequestConfig?)`, `sendImage(...)` (accepted `Buffer` / streams); `deleteFile(url)`, `deleteImage(url)`. -- **v10:** `uploadFile({ file, user? }, requestOptions?)` / `uploadImage({ file, upload_sizes?, user? }, requestOptions?)` (aliases of `uploadChannelFile` / `uploadChannelImage`; `file` is `File` / `Blob` / RN `{ uri, name?, type }` / URI `string`, no Node streams or Buffers); routes `/api/v2/chat/channels/{type}/{id}/file|image`. `deleteFile(url, requestOptions?)` / `deleteImage(url, requestOptions?)` (also `deleteChannelFile({ url })` / `deleteChannelImage({ url })`). -- **Migrate:** `channel.sendImage(file, name, type, user, { onUploadProgress, signal })` -> `channel.uploadImage({ file, user }, { onUploadProgress, signal })`. +- **v10:** `uploadFile({ file, user? }, requestOptions?)` / `uploadImage({ file, upload_sizes?, user? }, requestOptions?)` (aliases of `uploadChannelFile` / `uploadChannelImage`; `file` is `File` / `Blob` / RN `{ uri, name?, type }` / URI `string`, no Node streams or Buffers); routes `/api/v2/chat/channels/{type}/{id}/file|image`. `deleteFile({ url }, requestOptions?)` / `deleteImage({ url }, requestOptions?)`, the same request shape as `client.deleteFile` / `deleteImage` (aliases of `deleteChannelFile` / `deleteChannelImage`). +- **Migrate:** `channel.sendImage(file, name, type, user, { onUploadProgress, signal })` -> `channel.uploadImage({ file, user }, { onUploadProgress, signal })`; `channel.deleteImage(url)` -> `channel.deleteImage({ url })`. - **Source:** `src/channel.ts:597-640` (v10); `src/channel.ts:266-316` (v9) @@ -1358,7 +1358,7 @@ Common pattern for every generated or overridden `Channel` method: the trailing **Kind:** signature · **Impact:** low - **v9:** `vote(messageId, pollId, vote)`, `removeVote(messageId, pollId, voteId)`. -- **v10:** `vote({ message_id, poll_id }, { vote: { option_id?, answer_text? } }, requestOptions?)`, `removeVote({ message_id, poll_id, vote_id }, _request?, requestOptions?)`; both require an id. +- **v10:** `vote({ message_id, poll_id }, { vote: { option_id?, answer_text? } }, requestOptions?)`, `removeVote({ message_id, poll_id, vote_id }, requestOptions?)`; both require an id. - **Migrate:** wrap positional arguments into the path object and `{ vote }`. - **Source:** `src/channel.ts:2155-2163` (v10); `src/channel.ts:1802-1815` (v9) @@ -1369,7 +1369,7 @@ Common pattern for every generated or overridden `Channel` method: the trailing **Kind:** signature · **Impact:** medium - **v9:** `update(channelData?, updateMessage?, options?)` stripped reserved keys (`cid`, `id`, `config`, `created_at`, `member_count`, `own_capabilities`, ...) then called `_update(payload)`; `enableSlowMode(n)`, `disableSlowMode()`; `updatePartial(update: PartialUpdateChannel)`. -- **v10:** `update({ data?, message?, skip_push?, hide_history?, hide_history_before?: Date, cooldown?, add_members?, ... }, requestOptions?)` (no reserved-key stripping; `data` is `ChannelInputRequest`); sets `channel.data` from the response. `_update` removed. `enableSlowMode(n, requestOptions?)`, `disableSlowMode(_request?, requestOptions?)`. `updatePartial({ set?, unset? }, requestOptions?)` (wraps generated `updateChannelPartial`) returns `StreamResponse` and dispatches `capabilities.changed` only when the response carries `own_capabilities`. +- **v10:** `update({ data?, message?, skip_push?, hide_history?, hide_history_before?: Date, cooldown?, add_members?, ... }, requestOptions?)` (no reserved-key stripping; `data` is `ChannelInputRequest`); sets `channel.data` from the response. `_update` removed. `enableSlowMode(n, requestOptions?)`, `disableSlowMode(requestOptions?)`. `updatePartial({ set?, unset? }, requestOptions?)` (wraps generated `updateChannelPartial`) returns `StreamResponse` and dispatches `capabilities.changed` only when the response carries `own_capabilities`. - **Migrate:** `channel.update({ name }, { text })` -> `channel.update({ data: { custom: { name } }, message: { text } })`; `_update(payload)` -> `update(payload)`. - **Careful:** `update` is a full update, so `data.custom` replaces the channel's entire custom data; the mapping above drops every other custom field (as v9's `update({ name })` did). To change one field, spread the current values (`update({ data: { custom: { ...channel.data?.custom, name } } })`) or use `channel.updatePartial({ set: { name } })`. - **Source:** `src/channel.ts:693-760` (v10); `src/channel.ts:623-722,936-950` (v9) @@ -1392,8 +1392,8 @@ Common pattern for every generated or overridden `Channel` method: the trailing **Kind:** removed · **Impact:** medium · **PR:** #1849 - **v9:** `addMembers(members, message?, options?)`, `addFilterTags`, `removeFilterTags`, `inviteMembers`, `removeMembers`, `acceptInvite(options?)`, `rejectInvite(options?)`, `addModerators(members, message?, options?)`, `demoteModerators(...)`, `assignRoles(roles, message?, options?)`; `message: Message`, `options: { hide_history?, hide_history_before?, skip_push? }`. -- **v10:** helpers keep their positional shape plus `requestOptions?`; `message` is `MessageRequest`, `options` is `ChannelUpdateOptions = Omit`; `addMembers` / `inviteMembers` map string ids to `{ user_id }`. `acceptInvite` / `rejectInvite(options?: UpdateChannelRequest, requestOptions?)`. `addModerators`, `demoteModerators`, `assignRoles` removed (the client-side `UpdateChannelRequest` has no `add_moderators` / `demote_moderators` / `assign_roles`). -- **Migrate:** moderator promotion and role assignment move to the server-side node SDK; at insert time use `channel.addMembers([{ user_id, channel_role: 'channel_moderator' }])`. +- **v10:** `addMembers({ members, message?, ...options }, requestOptions?)`, and likewise `inviteMembers` / `removeMembers` (`members`) and `addFilterTags` / `removeFilterTags` (`tags`): one request object holding the items, the notification `message` (`MessageRequest`) and any other `ChannelUpdateOptions` (`Omit`) field, typed `AddMembersRequest`, `InviteMembersRequest`, `RemoveMembersRequest`, `FilterTagsRequest`. `addMembers` / `inviteMembers` map string ids to `{ user_id }`. `acceptInvite` / `rejectInvite(options?: UpdateChannelRequest, requestOptions?)`. `addModerators`, `demoteModerators`, `assignRoles` removed (the client-side `UpdateChannelRequest` has no `add_moderators` / `demote_moderators` / `assign_roles`). +- **Migrate:** `addMembers(ids, message, { hide_history: true })` -> `addMembers({ members: ids, message, hide_history: true })`, the same for the other four (`addFilterTags(tags)` -> `addFilterTags({ tags })`). Moderator promotion and role assignment move to the server-side node SDK; at insert time use `channel.addMembers({ members: [{ user_id, channel_role: 'channel_moderator' }] })`. - **Source:** `src/channel.ts:816-958` (v10); `src/channel.ts:787-935` (v9) @@ -1414,7 +1414,7 @@ Common pattern for every generated or overridden `Channel` method: the trailing **Kind:** signature · **Impact:** medium - **v9:** `archive({ user_id? })` etc.; resolved to `resp.channel_member`. -- **v10:** `archive(_request?, requestOptions?)` etc.; act on the connected user only; resolve to the full `updateMemberPartial` response (`{ channel_member?, duration }`). +- **v10:** `archive(requestOptions?)` etc.; act on the connected user only; resolve to the full `updateMemberPartial` response (`{ channel_member?, duration }`). - **Migrate:** `const member = await channel.archive()` -> `const { channel_member } = await channel.archive()`; pass `undefined` first to reach `requestOptions`. - **Source:** `src/channel.ts:1029-1082` (v10); `src/channel.ts:997-1075` (v9) @@ -1458,7 +1458,7 @@ Common pattern for every generated or overridden `Channel` method: the trailing **Kind:** signature · **Impact:** medium - **v9:** `queryMembers(filterConditions, sort?, options?, requestOptions?)` (sort normalized from `{ field: dir }`). -- **v10:** `queryMembers({ payload?: Partial }?, requestOptions?)`; `type` / `id` filled in; an id-less (members-only) channel now rejects with `Channel isn't yet created...` because `_checkHasId()` runs first (v9 sent `data.members` instead; the v10 `members` branch is dead code, tracked in v10-followups.md); sort is `[{ field, direction }]`; date bounds are `TimestampNS` ([request dates](#request-dates)). +- **v10:** `queryMembers({ payload?: Partial }?, requestOptions?)`; `type` / `id` filled in; an id-less (members-only) channel now rejects with `Channel isn't yet created...` because `_checkHasId()` runs first (v9 sent `data.members` instead; the v10 `members` branch is dead code, tracked in v10-followups.md); sort is `[{ field, direction }]`; date bounds take `Date | string` ([request dates](#request-dates)). - **Migrate:** `channel.queryMembers(f, s, o)` -> `channel.queryMembers({ payload: { filter_conditions: f, sort: [...], ...o } })`. To query members of a distinct channel before it is created, call `watch()` / `create()` first (or `client.queryMembers({ payload: { type, members, ... } })`). - **Source:** `src/channel.ts:652-682` (guard `:656`, `members` branch `:665`) (v10); `src/channel.ts:390-427` (v9) @@ -1542,13 +1542,13 @@ Common pattern for every generated or overridden `Channel` method: the trailing | `message.new` / `notification.message_new` | `data.hidden` untouched | sets `channel.data.hidden = false` | | `message.new` (channel message) | pushed to the list only when `isUpToDate`; own unread bumped for every countable message | always ingested (lands in the head interval when an older window is shown); own unread and unread snapshot not bumped while `messagePaginator.isViewingLive`; own read row seeded (`last_read: 0`) when missing | | `message.new` / `message.deleted` thread-only reply | stored in `state.threads` | ignored by the channel (owned by `Thread` via `client.threads`) | - | `message.updated` / `message.undeleted` with `parent_id` | applied to `threads` and (if shown) the channel list | ignored by the channel, including `show_in_channel` replies (tracked in v10-followups.md) | + | `message.updated` / `message.undeleted` with `parent_id` | applied to `threads` and (if shown) the channel list | applied to the channel list for `show_in_channel` replies; thread-only replies are owned by `Thread` | | `message.deleted` soft | message replaced in sets | written to `client.messageStore` only if held | | `message.read`, `message.read_locally` | read row reset, delivery untouched | also advances `last_delivered_at` / `last_delivered_message_id`, clears `first_unread_message_id` | | `notification.mark_read` | not handled | handled like `message.read` unless it carries `thread_id` | | `message.delivered` | overwrote delivery (could move back) | monotonic; new rows get `last_read = created_at` | | `reaction.*` | replaced `event.message` with the state-merged message (own_reactions recomputed) before listeners | `event.message` left as received; the store copy is upserted only if held | - | `typing.*` | `state.typing[id] = event` | `state.setTypingEvent` / `removeTypingEvent` (mirrored to `messageComposer.textComposer.typing`) | + | `typing.*` | `state.typing[id] = event` | `state.setTypingEvent` / `removeTypingEvent`; `channel.state` is the only owner (see [ChannelState store](#channel-state-store)) | | `ai_indicator.*` | none | `state.aiState` | | `channel.updated` | carried over `hidden`, `own_capabilities` | also carries over `member_count` | | `channel.truncated` | `channel.data` untouched | `event.channel` merged into `channel.data` (so `truncated_at`, `truncated_by`, `updated_at`, ... land on `channel.data`, `channel.state.data` and `memberCount`) | @@ -1564,9 +1564,9 @@ Common pattern for every generated or overridden `Channel` method: the trailing **Kind:** behavior · **Impact:** medium - **v9:** hard and soft delete both kept the user's messages, converted to `type: 'deleted'` (hard also stripped content), across all message sets, `state.threads` and `pinnedMessages`. -- **v10:** hard delete removes the messages from `messagePaginator` and `pinnedMessagesPaginator`; soft delete tombstones; only the loaded active window (`paginator.items`) is processed; thread replies are handled by `Thread.handleUserMessagesDeleted`. `message.deleted` with `hard_delete` keeps removing (as in v9). `messageOperations.delete({ localMessage, options: { hard: true } })` removes optimistically. `channel.delete({ hard_delete })` is unchanged. +- **v10:** hard delete removes the messages from `messagePaginator` and `pinnedMessagesPaginator`; soft delete tombstones; every loaded message is processed, in all loaded intervals and not only the active window; thread replies are handled by `Thread.handleUserMessagesDeleted`. `message.deleted` with `hard_delete` keeps removing (as in v9). `messageOperations.delete({ localMessage, options: { hard: true } })` removes optimistically. `channel.delete({ hard_delete })` is unchanged. - **Migrate:** UIs must not expect a "deleted" placeholder after a hard user-messages delete. -- **Source:** `src/channel.ts:2547-2577`, `src/pagination/paginators/MessageIntervalPaginator.ts:1405-1452` (v10); `src/channel.ts:2096-2120`, `src/channel_state.ts:776-800` (v9) +- **Source:** `src/channel.ts:2547-2577`, `src/pagination/paginators/MessageIntervalPaginator.ts:1405-1457` (v10); `src/channel.ts:2096-2120`, `src/channel_state.ts:776-800` (v9) @@ -1579,7 +1579,7 @@ Common pattern for every generated or overridden `Channel` method: the trailing **Kind:** type-shape · **Impact:** high · **PR:** #1834 - **v9:** plain class with writable fields `watcher_count`, `typing`, `read`, `watchers`, `members`, `membership`, `unreadCount` (own counter, assignable, separate from `read[me].unread_messages`). -- **v10:** `class ChannelState extends StateStore` (from `@stream-io/state-store`: `getLatestValue`, `subscribe`, `subscribeWithSelector`, `next`, `partialNext`, `addPreprocessor`, `merge`; protected `value`, `handlers`, `preprocessors`). Flat slices: `watcherCount`, `watchers`, `watchStatus`, `typing`, `read`, `members`, `memberCount`, `membership`, `ownCapabilities`, `data`, `muteStatus`, `initialized`, `offlineMode`, `pendingDisposal`, `supersededBy`, `active`, `aiState`. `members` / `read` / `watchers` / `typing` / `membership` / `watcher_count` / new `member_count` are getter/setter proxies: assigning a new object publishes, in-place mutation (`state.read[uid] = ...`, `state.members[uid].x = ...`) does not notify subscribers. `unreadCount` is a getter only (`read[ownUserId].unread_messages ?? 0`): assigning throws in strict mode. `read[ownUserId].unread_messages` (and so `unreadCount`) is no longer bumped by muted/silent/shadowed messages (v9 bumped the read row for all of them; only its separate `unreadCount` skipped them) or by messages arriving while `isViewingLive`. The `typing` getter reads `messageComposer.textComposer.typing` (still `Record`); `membership` initializes to `{}`. New `setTypingEvent`, `removeTypingEvent`, `resetAIState`. Read timestamps are `TimestampNS`. `ChannelState.formatMessage` remains, but now returns the v10 `LocalMessage` (dates stay `TimestampNS`, see [formatMessage](#format-message)) and accepts only `MessageResponse | LocalMessage` (v9 also accepted `MessageResponseBase`). +- **v10:** `class ChannelState extends StateStore` (from `@stream-io/state-store`: `getLatestValue`, `subscribe`, `subscribeWithSelector`, `next`, `partialNext`, `addPreprocessor`, `merge`; protected `value`, `handlers`, `preprocessors`). Flat slices: `watcherCount`, `watchers`, `watchStatus`, `typing`, `read`, `members`, `memberCount`, `membership`, `ownCapabilities`, `data`, `muteStatus`, `initialized`, `offlineMode`, `pendingDisposal`, `supersededBy`, `active`, `aiState`. `members` / `read` / `watchers` / `typing` / `membership` / `watcher_count` / new `member_count` are getter/setter proxies: assigning a new object publishes, in-place mutation (`state.read[uid] = ...`, `state.members[uid].x = ...`) does not notify subscribers. `unreadCount` is a getter only (`read[ownUserId].unread_messages ?? 0`): assigning throws in strict mode. `read[ownUserId].unread_messages` (and so `unreadCount`) is no longer bumped by muted/silent/shadowed messages (v9 bumped the read row for all of them; only its separate `unreadCount` skipped them) or by messages arriving while `isViewingLive`. `typing` (still `Record`) is owned by `channel.state` alone; the text composer keeps no copy, as in v9. The v10 release candidates mirrored it into `messageComposer.textComposer.state` `typing`, and UIs built on an rc must read the `channel.state` slice instead (stream-chat-react's `TypingIndicator` reads the composer copy: `useStateStore(channel.state, (s) => ({ typing: s.typing }))`); `membership` initializes to `{}`. New `setTypingEvent`, `removeTypingEvent`, `resetAIState`. Read timestamps are `TimestampNS`. `ChannelState.formatMessage` remains, but now returns the v10 `LocalMessage` (dates stay `TimestampNS`, see [formatMessage](#format-message)) and accepts only `MessageResponse | LocalMessage` (v9 also accepted `MessageResponseBase`). - **Migrate:** subscribe with `channel.state.subscribeWithSelector((s) => ({ read: s.read }), ...)` (React: `useStateStore(channel.state, selector)`); replace in-place writes with whole-object assignment or `partialNext`; never assign `unreadCount` (use `markRead` / `markReadLocally`). - **Source:** `src/channel_state.ts:201-374` (`formatMessage` `:338-344`) (v10); `src/channel_state.ts:70-122` (v9) @@ -1799,9 +1799,9 @@ await paginator.toTail(); **Kind:** behavior · **Impact:** medium - **v9:** after 3 failed retries `state.error = new Error('Maximum number of retries reached in queryChannels. Last error message is: ...')` plus `console.warn`; `setChannels` upserted every loaded cid into the offline DB; `disconnectUser` left an integrator-created manager's channels intact. -- **v10:** `paginator.state.lastQueryError` holds the raw error, set on each failed attempt (not set when offline support is enabled and the list already holds items) and cleared on success (no wrapping, no console warn); the offline cid cache stores only the first `pageSize` cids; `client.disconnectUser()` calls `channelManager.resetPaginatorStates()` (every list back to `items: undefined`, registrations kept) and `clearChannels()`. +- **v10:** `paginator.state.lastQueryError` holds the raw error of the last attempt, set once the retries are used up as in v9 (not set when offline support is enabled and the list already holds items) and cleared on success (no wrapping, no console warn); the offline cid cache stores only the first `pageSize` cids; `client.disconnectUser()` calls `channelManager.resetPaginatorStates()` (every list back to `items: undefined`, registrations kept) and `clearChannels()`. - **Migrate:** read `lastQueryError` (check `error.message` of the raw error); re-query whenever `items` is `undefined`, not only on mount. -- **Source:** `src/ChannelManager.ts:1077-1082`, `src/client.ts:851-856`, `src/pagination/paginators/ChannelPaginator.ts:610-646`, `src/pagination/paginators/BasePaginator.ts:3098-3124` (v10); `src/channel_manager.ts:288-412` (v9) +- **Source:** `src/ChannelManager.ts:1077-1082`, `src/client.ts:851-856`, `src/pagination/paginators/ChannelPaginator.ts:610-646`, `src/pagination/paginators/BasePaginator.ts:3098-3127` (v10); `src/channel_manager.ts:288-412` (v9) @@ -1860,7 +1860,7 @@ Raw client-level edit/delete: [updateMessage / deleteMessage](#update-delete-mes **Kind:** signature · **Impact:** high · **PR:** #1906, #1897 - **v9:** `sendReaction(messageID, reaction, options?)` validated input and queued `send-reaction` when an offline DB existed; `deleteReaction(messageID, type, user_id?)` required `_checkInitialized`, wrote `offlineDb.deleteReaction` optimistically and queued `delete-reaction` (internal `_sendReaction` / `_deleteReaction`). Reaction WS events then updated state. -- **v10:** `sendReaction({ id }, { reaction, enforce_unique?, skip_push? }, requestOptions?)`, `deleteReaction({ id, type }, _request?, requestOptions?)`: forward to `client.sendReaction` / `client.deleteReaction`; no validation, no offline queue, no DB write, no id or initialization check. `_sendReaction` / `_deleteReaction` removed. The optimistic + queued + rollback flow is `channel.messageOperations.addReaction({ messageId, reaction, options? })` / `deleteReaction({ messageId, type })`. `user_id` override dropped. +- **v10:** `sendReaction({ id }, { reaction, enforce_unique?, skip_push? }, requestOptions?)`, `deleteReaction({ id, type }, requestOptions?)`: forward to `client.sendReaction` / `client.deleteReaction`; no validation, no offline queue, no DB write, no id or initialization check. `_sendReaction` / `_deleteReaction` removed. The optimistic + queued + rollback flow is `channel.messageOperations.addReaction({ messageId, reaction, options? })` / `deleteReaction({ messageId, type })`. `user_id` override dropped. - **Migrate:** `channel.sendReaction(id, r, o)` -> `channel.sendReaction({ id }, { reaction: r, ...o })` (custom reaction fields under `reaction.custom`); `channel.deleteReaction(id, type)` -> `channel.deleteReaction({ id, type })`. For offline support use `messageOperations`. - **Source:** `src/channel.ts:582-588`, `src/messageOperations/MessageOperations.ts:334-396` (v10); `src/channel.ts:473-621` (v9) @@ -1923,7 +1923,7 @@ Client-level `queryThreads` / `getThread` becoming raw: [threads and reactions]( - **v9:** `new Thread({ client, threadData: ThreadResponse })` (required); apps used `client.threads.threadsById[id] ?? new Thread(...)`. - **v10:** constructor `new Thread({ client, threadData?: ThreadStateResponse, channel?, parentMessage?, draft? })`. Without `threadData`, `channel` and `parentMessage` (with an `id`) are required, else it throws. A thread built from a parent message starts with `isStateStale: true` when the parent has replies or the channel is not watched, and loads once when activated. With `threadData`, `threadData.channel` and `threadData.parent_message` are required (throws otherwise) and the channel comes from `client.channelManager.ensure` (seeded with `channel.custom`, not `{ name }`). `ThreadResponse` -> `ThreadStateResponse` ([same-name changes](#same-name-meaning-changes)). -- **Migrate:** `const thread = client.threads.ensure({ channel, parentMessage })`. Use `client.getThreadAndHydrate(id)` / `client.queryThreadsAndHydrate()` for fetched threads; both return the already-registered instance when one exists. +- **Migrate:** `const thread = client.threads.ensure({ channel, parentMessage })`. Use `client.getThreadAndHydrate({ message_id: id })` / `client.queryThreadsAndHydrate()` for fetched threads; both return the already-registered instance when one exists. - **Source:** `src/thread.ts:133-231`, `src/thread_manager.ts:174-204`, `src/client.ts:2059-2142` (v10); `src/thread.ts:123-196` (v9) @@ -1955,7 +1955,7 @@ Client-level `queryThreads` / `getThread` becoming raw: [threads and reactions]( **Kind:** behavior · **Impact:** low - **v9:** `client.getThread(id, { watch: true })` (server default reply limit), replaced `state.replies` (plus failed replies), rethrew every error. -- **v10:** `client.getThreadAndHydrate(id, { watch: true, reply_limit: max(loaded replies, pageSize) })`, merges into the reply paginator in place and re-ingests failed replies. A not-found error (Stream code or HTTP 404) resolves silently when `replyCount === 0`; other errors rethrow. +- **v10:** `client.getThreadAndHydrate({ message_id: id }, { watch: true, reply_limit: max(loaded replies, pageSize) })`, merges into the reply paginator in place and re-ingests failed replies. A not-found error (Stream code or HTTP 404) resolves silently when `replyCount === 0`; other errors rethrow. - **Migrate:** none. - **Source:** `src/thread.ts:413-466` (v10); `src/thread.ts:213-226` (v9) @@ -2036,7 +2036,7 @@ Poll data types and `PollResponse` renaming: [poll types](#poll-types). State ti | v9 | v10 | | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `createPoll(poll: CreatePollData, userId?)` | `createPoll(request: CreatePollRequest, requestOptions?)` | - | `getPoll(id, userId?)` | `getPoll({ poll_id }, _request?, requestOptions?)` | + | `getPoll(id, userId?)` | `getPoll({ poll_id }, requestOptions?)` | | `updatePoll(poll: PollData, userId?)` | `updatePoll(request: UpdatePollRequest, requestOptions?)` | | `partialUpdatePoll(id, { set, unset }, userId?)` | `updatePollPartial({ poll_id }, { set, unset }, requestOptions?)` | | `closePoll(id, userId?)` | removed: `updatePollPartial({ poll_id }, { set: { is_closed: true } })` or `poll.close()` | @@ -2106,8 +2106,8 @@ Poll data types and `PollResponse` renaming: [poll types](#poll-types). State ti **Kind:** signature · **Impact:** low - **v9:** `createPoll(poll: CreatePollData)`; `queryPolls(filter, sort: PollSort = [], options: Pager = {})`. -- **v10:** `createPoll(poll: CreatePollRequest)`; `queryPolls(filter, sort: SortParamRequest[] = [], options: QueryPollsOptions = {})`, still positional (unlike `client.queryPolls`). New: `registerSubscriptions()` also listens to `user.updated` and replaces that user in every cached poll's votes, answers and `created_by` (and writes the offline DB). -- **Migrate:** rename the type; sort as `SortParamRequest[]`. +- **v10:** `createPoll(poll: CreatePollRequest)`; `queryPolls(request?: QueryPollsRequest, requestOptions?)`, the same request as `client.queryPolls` (`{ filter, sort, limit, next, prev }`). New: `registerSubscriptions()` also listens to `user.updated` and replaces that user in every cached poll's votes, answers and `created_by` (and writes the offline DB). +- **Migrate:** rename the type; `client.polls.queryPolls(filter, sort, { limit })` -> `client.polls.queryPolls({ filter, sort, limit })`, with sort as `SortParamRequest[]`. - **Source:** `src/poll_manager.ts:40-60,80-100,165-170` (v10); `src/poll_manager.ts:49,77-90` (v9) @@ -2344,17 +2344,6 @@ Poll data types and `PollResponse` renaming: [poll types](#poll-types). State ti - **Migrate:** pass `channelQueryOptions: { watch: true }` if result channels must receive events before being opened; do not rely on `channelQuerySort`. - **Source:** `src/search/MessageSearchSource.ts:58-72,165-208,255-305` (v10); `src/search/MessageSearchSource.ts:202,221-229` (v9) - - -### `MessageSearchSource.messageSearchSort` no longer overrides the default order - -**Kind:** behavior · **Impact:** low - -- **v9:** request sort `{ created_at: -1, ...messageSearchSort }`; `messageSearchSort: { created_at: 1 }` flipped the order. -- **v10:** request sort `[{ field: 'created_at', direction: -1 }, ...(messageSearchSort ?? [])]`; the caller's entries are secondary keys only. -- **Migrate:** the primary order cannot be changed through the source; subclass and override `query` if ascending order is needed. -- **Source:** `src/search/MessageSearchSource.ts:235-250` (v10); `src/search/MessageSearchSource.ts:185-195` (v9) - ### Search source sort options take `SortParamRequest[]` @@ -2362,7 +2351,7 @@ Poll data types and `PollResponse` renaming: [poll types](#poll-types). State ti **Kind:** type-shape · **Impact:** medium · **PR:** #1836 - **v9:** `ChannelSearchSourceOptions.sort: ChannelSort`, `UserSearchSourceOptions.sort: UserSort`, `ChannelMemberSearchSourceOptions.sort: MemberSort`, `MessageSearchSourceOptions.messageSearchSort: SearchMessageSort`, `.channelQuerySort: ChannelSort` (object or array form); same-named instance fields. -- **v10:** all `SortParamRequest[]` (`[{ field, direction }]`), forwarded as-is. `UserSearchSource` appends `{ field: 'id', direction: 1 }` last unless an `id` entry exists (v9 object form made `id` the primary key). A non-array sort is no longer normalized: `UserSearchSource` fails inside `query` (`baseSort.some is not a function`), `MessageSearchSource` fails with `... is not iterable`, and `ChannelSearchSource` / `ChannelMemberSearchSource` send the object to the API; client-side failures land in `lastQueryError`. `searchOptions` / `channelQueryOptions` and `filters` types follow the generated request types ([filters](#filter-types)). +- **v10:** all `SortParamRequest[]` (`[{ field, direction }]`), forwarded as-is except `messageSearchSort`, which is merged under the `created_at` primary order as in v9 (a `created_at` entry sets its direction). `UserSearchSource` appends `{ field: 'id', direction: 1 }` last unless an `id` entry exists (v9 object form made `id` the primary key). A non-array sort is no longer normalized: `UserSearchSource` fails inside `query` (`baseSort.some is not a function`), `MessageSearchSource` fails with `... is not iterable`, and `ChannelSearchSource` / `ChannelMemberSearchSource` send the object to the API; client-side failures land in `lastQueryError`. `searchOptions` / `channelQueryOptions` and `filters` types follow the generated request types ([filters](#filter-types)). - **Migrate:** `{ created_at: -1 }` -> `[{ field: 'created_at', direction: -1 }]` ([sort](#sort-shape)). - **Source:** `src/search/ChannelSearchSource.ts:18,32`, `src/search/UserSearchSource.ts:16,26,74-78`, `src/search/ChannelMemberSearchSource.ts:21,31`, `src/search/MessageSearchSource.ts:62,65,82,85,247`, `src/search/ChannelSearchSource.ts:112-123`, `src/search/ChannelMemberSearchSource.ts:82-88` (v10); `src/search/UserSearchSource.ts:74-80`, `src/search/MessageSearchSource.ts:62-65` (v9) @@ -2508,8 +2497,8 @@ Poll data types and `PollResponse` renaming: [poll types](#poll-types). State ti **Kind:** signature · **Impact:** low - **v9:** `flagUser(flaggedUserID, reason: string, options?: ModerationFlagOptions)` and `flagMessage(messageID, reason, options?)`; options `{ custom?, moderation_payload?, user_id? }`; always sent `entity_creator_id: ''`; returned `{ item_id } & APIResponse`. -- **v10:** `flagUser(flaggedUserId, reason?, options?, requestOptions?)` / `flagMessage(messageId, reason?, options?, requestOptions?)`; `reason` is optional; options `Omit` (no `user_id`; `entity_creator_id` settable and omitted unless given); returns `StreamResponse`. -- **Migrate:** drop `user_id`; positional order is unchanged. +- **v10:** `flagUser({ user_id, reason?, ...options }, requestOptions?)` / `flagMessage({ message_id, reason?, ...options }, requestOptions?)`, one request object; `reason` is optional; options `Omit` (no `user_id`; `entity_creator_id` settable and omitted unless given); returns `StreamResponse`. +- **Migrate:** `flagMessage(id, reason, { custom })` -> `flagMessage({ message_id: id, reason, custom })`, `flagUser(id, reason)` -> `flagUser({ user_id: id, reason })`; drop the v9 acting `user_id` option. - **Source:** `src/moderation.ts:34-81`, `src/types.ts:349-352` (v10); `src/moderation.ts:60-76` (v9) @@ -2896,10 +2885,21 @@ Poll data types and `PollResponse` renaming: [poll types](#poll-types). State ti **Kind:** type-shape · **Impact:** medium · **PR:** #1830 - **v9:** `PollComposerFieldErrors` values were English strings (`'Question is required'`, ...), keyed on `CreatePollData`; validators returned strings. -- **v10:** values are `{ code: PollComposerValidationCode; message: string; metadata? }` keyed on `CreatePollRequest` (`allow_answers`, `allow_user_suggested_options`, `description`, `enforce_unique_vote`, `id`, `is_closed`, `max_votes_allowed`, `name`, `options`, `voting_visibility`, plus new `custom`, `team`; no `user_id`). Codes in `POLL_COMPOSER_VALIDATION_CODE` (`validation:poll:maxVotes:notNumeric|outOfRange|uniqueVoteEnforced`, `validation:poll:name:required`, `validation:poll:option:duplicate|empty`). Applies to `PollComposerState.errors`, `PollComposerSnapshot`, `PollComposerStateChangeMiddlewareValue`, `PollComposerCompositionMiddlewareValueState`, `PollStateValidationOutput`, the default validator maps (`defaultPollFieldBlurEventValidators`, `defaultPollFieldChangeEventValidators`, `pollStateChangeValidators`), and `updateFields(data, injectedFieldErrors)`. +- **v10:** values are `{ code: PollComposerValidationCode; message: string; metadata? }` keyed on `CreatePollRequest` (`allow_answers`, `allow_user_suggested_options`, `description`, `enforce_unique_vote`, `id`, `is_closed`, `max_votes_allowed`, `name`, `options`, `voting_visibility`, plus new `custom`, `team`; no `user_id`). Codes in `POLL_COMPOSER_VALIDATION_CODE` (`validation:poll:maxVotes:notNumeric|outOfRange|uniqueVoteEnforced`, `validation:poll:name:required`, `validation:poll:option:duplicate|empty`). Applies to `PollComposerState.errors`, `PollComposerSnapshot`, `PollComposerStateChangeMiddlewareValue`, `PollComposerCompositionMiddlewareValueState`, `PollStateValidationOutput`, the default validator maps (`defaultPollFieldBlurEventValidators`, `defaultPollFieldChangeEventValidators`, `pollStateChangeValidators`), and the `injectedFieldErrors` of [`updateFields`](#poll-composer-update-fields). - **Migrate:** render `errors.name?.message` or translate by `.code`; custom validators and injected errors must return `pollComposerValidationError(code, metadata?)` objects. - **Source:** `src/messageComposer/middleware/pollComposer/types.ts:29`, `src/messageComposer/middleware/pollComposer/state.ts:22-120`, `src/messageComposer/middleware/pollComposer/validation.ts:12-76` (v10); `src/messageComposer/middleware/pollComposer/types.ts:24`, `src/messageComposer/middleware/pollComposer/state.ts:16-90` (v9) + + +### `PollComposer.updateFields` takes one object + +**Kind:** signature · **Impact:** low + +- **v9:** `updateFields(data, injectedFieldErrors?)`. +- **v10:** `updateFields({ data, injectedFieldErrors? })`. +- **Migrate:** `updateFields({ name })` -> `updateFields({ data: { name } })`; `updateFields(data, errors)` -> `updateFields({ data, injectedFieldErrors: errors })`. +- **Source:** `src/messageComposer/pollComposer.ts:122-146` (v10); `src/messageComposer/pollComposer.ts:124-127` (v9) + ### `TextComposerState.typing` added as required @@ -3438,18 +3438,17 @@ Changes where code written for v9 can type-check (or run untyped) against v10 an | Connection | watching / presence requests wait for (or fail without) a connection id | [watch-waits-for-connection-id](#watch-waits-for-connection-id) | | Connection | connection id is cleared on drop / close | [connection-id-lifecycle](#connection-id-lifecycle) | | Connection | reconnect recovery refreshes lists and activated channels only | [connection-recovery](#connection-recovery) | -| Connection | `connectionRecovery.enabled: false` also stops `connection.recovered` and makes `recover()` a no-op; with an offline DB it fires after the sync | [connection-recovery](#connection-recovery) | +| Connection | `connectionRecovery.enabled: false` stops `connection.recovered` on reconnects, `recover()` still works; with an offline DB it fires after sync | [connection-recovery](#connection-recovery) | | Connection | WS connect timeout is 15 s even with the long-poll fallback | [long-poll-fallback](#long-poll-fallback) | | Connection | `persistUserOnConnectionFailure` keeps the socket retrying | [persist-user-on-connection-failure](#persist-user-on-connection-failure) | | Connection | network status comes from `client.networkConnection` reporters | [websocket-impl-network-status](#websocket-impl-network-status) | | Connection | WS handshake path and auth frame changed (proxies) | [ws-handshake](#ws-handshake) | | Errors | non-Stream failures are raw axios errors; message text has no prefix | [stream-api-error](#stream-api-error) | | Errors | `error.client_request_id` is `undefined` | [stream-api-error](#stream-api-error) | -| Errors | 429 and `TOKEN_EXPIRED` retried 10 times (about 100 s); `RetryError` at the end | [automatic-retries](#automatic-retries) | +| Errors | `TOKEN_EXPIRED` from a token provider retried up to 10 times (about 100 s) | [automatic-retries](#automatic-retries) | | Logging | default console output at `info` (v9 was silent) | [logging](#logging) | | Tokens | dev tokens with non-ASCII user ids fail | [dev-token-encoding](#dev-token-encoding) | -| Events | `connection.changed` / `transport.changed` listeners never fire | [connection-changed-removed](#connection-changed-removed) | -| Events | `on('all', cb)` typed `never`; `on('*', cb)` never fires | [event-type-names](#event-type-names) | +| Events | `connection.changed` / `transport.changed` listeners never fire (untyped code; TypeScript rejects the names) | [connection-changed-removed](#connection-changed-removed) | | Types | `Role`, `User`, `Reaction`, `PollResponse`, ... imports compile but mean something else | [same-name-meaning-changes](#same-name-meaning-changes) | | Types | `new Date(timestampNs)` compiles unless used as a `Date` | [date-constructor-guard](#date-constructor-guard) | | Types | `AI_STATE_CHECKING_SOURCES` is now `AI_STATE_EXTERNAL_SOURCES` | [ai-state](#ai-state) | @@ -3492,7 +3491,6 @@ Changes where code written for v9 can type-check (or run untyped) against v10 an | Delivery | `throttledMarkRead` leading edge, errors swallowed | [throttled-mark-read](#throttled-mark-read) | | Search | channel search results are unwatched | [channel-search-source-unwatched](#channel-search-source-unwatched) | | Search | message search channels unwatched; `channelQuerySort` ignored | [message-search-source-hydration](#message-search-source-hydration) | -| Search | `messageSearchSort` is secondary only | [message-search-sort](#message-search-sort) | | Pagination | items deduped and comparator-ordered | [paginator-items](#paginator-items) | | Notifications | client re-derives the manager config on any `client.*` change and on reconnect, replacing `updateConfig` patches | [notification-manager-config](#notification-manager-config) | | Offline | channel guard writes first | [offline-channel-guard](#offline-channel-guard) | @@ -3548,5 +3546,4 @@ Claims the source cannot settle; verify against the live API or the target packa 3. Server outcome of replaying v9-persisted `send-message` / `create-draft` pending tasks (bare message as request body; likely an InputError that deletes the task) ([pending tasks](#pending-task-payloads)). 4. TypeScript older than 5.5 was not tested against the v10 declarations ([TS requirements](#typescript-requirements)). 5. Whether the API still accepts old top-level custom filter keys (`{ color: 'green' }` instead of `{ 'custom.color': 'green' }`) ([filters](#filter-types)). -6. Whether the API accepts nanosecond `created_at_*` bounds on `QueryMembersPayload`, `QueryBannedUsersPayload`, `QueryFutureChannelBansPayload`, which are typed `TimestampNS` while every other request date is `Date` ([request dates](#request-dates)). -7. That `updateReminder({ message_id })` (no `remind_at`) clears the due date server-side is asserted by a docs snippet test against the live API, not run for this report ([reminder methods](#reminder-methods)). +6. That `updateReminder({ message_id })` (no `remind_at`) clears the due date server-side is asserted by a docs snippet test against the live API, not run for this report ([reminder methods](#reminder-methods)). diff --git a/ai-docs/v10-followups.md b/ai-docs/v10-followups.md index 3a47a8669f..6714079e13 100644 --- a/ai-docs/v10-followups.md +++ b/ai-docs/v10-followups.md @@ -32,7 +32,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### Typing indicators go stale after the composer resets +### Typing indicators go stale after the composer resets [SOLVED f97ddd8b] `bug` · confidence: high · pre-release: yes · composer / channel state @@ -43,7 +43,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### One mark-read throttle shared by every channel and thread +### One mark-read throttle shared by every channel and thread [SOLVED 312e2e5c] `bug` · confidence: medium · pre-release: yes · message delivery / threads @@ -54,7 +54,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### `on('all')` and undeclared custom events are typed `never` +### `on('all')` and undeclared custom events are typed `never` [SOLVED c103da00] `bug` · confidence: high · pre-release: yes · types / events @@ -65,7 +65,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### `client.on('*')` type-checks but never fires +### `client.on('*')` type-checks but never fires [SOLVED 79477d20] `bug` · confidence: high · pre-release: yes · types / events @@ -76,7 +76,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### Exhausted retries hide the API error; 429 now holds callers for about 100 s +### Exhausted retries hide the API error; 429 now holds callers for about 100 s [SOLVED e295664a] `bug` · confidence: high · pre-release: yes · client / HTTP @@ -87,7 +87,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### Static expired token is retried 10 times +### Static expired token is retried 10 times [SOLVED eef88681] `bug` · confidence: high · pre-release: yes · client / HTTP @@ -98,7 +98,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### Edits to `show_in_channel` replies are not applied to the channel list +### Edits to `show_in_channel` replies are not applied to the channel list [SOLVED dd1b8c7a] `bug` · confidence: medium · pre-release: yes · channel @@ -109,7 +109,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### `user.messages.deleted` only processes the active window +### `user.messages.deleted` only processes the active window [SOLVED 45290f4e] `bug` · confidence: medium · pre-release: yes · channel / pagination @@ -120,7 +120,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### `connectionRecovery.enabled: false` also suppresses `connection.recovered` +### `connectionRecovery.enabled: false` also suppresses `connection.recovered` [SOLVED 311c28a2] `bug` · confidence: high · pre-release: yes · connection recovery @@ -131,7 +131,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### Reminder list blanks on every "load more" +### Reminder list blanks on every "load more" [SOLVED 73a87752] `bug` · confidence: high · pre-release: yes · pagination / reminders @@ -142,7 +142,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### `lastQueryError` flashes on every failed retry attempt +### `lastQueryError` flashes on every failed retry attempt [SOLVED 9cc208fd] `bug` · confidence: high · pre-release: yes · pagination / channel list @@ -153,7 +153,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### `messageSearchSort` cannot change the primary order +### `messageSearchSort` cannot change the primary order [SOLVED e32c8d5e] `bug` · confidence: high · pre-release: yes · search @@ -173,7 +173,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### Request date filters typed as `TimestampNS` +### Request date filters typed as `TimestampNS` [SOLVED 202e6903] `bug` · confidence: medium · pre-release: yes · types / generated models @@ -184,7 +184,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### Positional `pinMessage` / `unpinMessage` wrappers +### Positional `pinMessage` / `unpinMessage` wrappers [SOLVED be73434c] `simplification` · confidence: low · pre-release: yes · client @@ -194,7 +194,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### Placeholder `_request?: Record` parameters +### Placeholder `_request?: Record` parameters [SOLVED 51fa7695] `simplification` · confidence: high · pre-release: yes · channel / generated API @@ -214,7 +214,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are -### `PollComposer.updateFields` "change with the next major" FIXME +### `PollComposer.updateFields` "change with the next major" FIXME [SOLVED 4f8f95cf] `simplification` · confidence: high · pre-release: yes · composer @@ -1022,7 +1022,7 @@ Only places that survive the guide cleanup: JSDoc and code comments, README, CHA - **Where / What:** - `src/offline-support/offline_support_api.ts:285`: `getChannelsForQuery` `@returns` says `Omit[] | null`; it returns `DBGetChannelsForQueryResult | null` (`{ channels, predefinedFilter? }`). - `src/offline-support/types.ts:189`: `DBGetChannelsForQueryType.options` is commented "Optional filters for channels."; it is the full `QueryChannelsRequest` used as the cache key. - - `src/offline-support/types.ts:31, 155, 167`, `src/moderation.ts:54`: bulk-rename artifacts ("MessageRequest to which the reaction is applied", "MessageRequest ID to be flagged", ...) where "message" is meant. + - `src/offline-support/types.ts:31, 155, 167`, `src/moderation.ts:54`: bulk-rename artifacts ("MessageRequest to which the reaction is applied", "MessageRequest ID to be flagged", ...) where "message" is meant. (`src/moderation.ts:54` [SOLVED be73434c]; the `offline-support/types.ts` ones remain) - **Suggested change:** Fix the `@returns` and describe `predefinedFilter`; reword the options comment; revert the artifacts to "message" and grep `MessageRequest ID\|MessageRequest to\|MessageRequest associated` across `src`. @@ -1146,9 +1146,9 @@ Only places that survive the guide cleanup: JSDoc and code comments, README, CHA | `test/unit/MessageComposer/messageComposer.test.ts:1846, 1879` | "as not delivered" and "as not failed" have identical bodies (only the ms offset differs) and neither sets that status. Confidence medium. | Make each set the status it names. | | `src/messageComposer/attachmentManager.ts:667` | New throw "The upload succeeded but the response carried no file URL" has no test. | Add one. | | `src/search/MessageSearchSource.ts:259, 288` | Both abort guards are untested since v9's "skips the channel hydration request when the query was aborted" was deleted. | Restore the test. | -| `test/unit/search/MessageSearchSource.test.ts:272-276, 324-328` | Pins the contradictory sort from [messageSearchSort](#message-search-sort). | Update with the fix. | +| `test/unit/search/MessageSearchSource.test.ts:272-276, 324-328` | Pins the contradictory sort from [messageSearchSort](#message-search-sort). [SOLVED e32c8d5e] | Update with the fix. | | `test/unit/signing.test.js:33-36` | "round-trips ... non-Latin-1 ids" only asserts no throw. | Assert `UserFromToken(DevToken(id)) === id` for non-ASCII ids. | -| `src/channel.ts:2680` | No test covers `show_in_channel` reply edits ([item](#show-in-channel-edits)). | Add one. | +| `src/channel.ts:2680` | No test covers `show_in_channel` reply edits ([item](#show-in-channel-edits)). [SOLVED dd1b8c7a] | Add one. | | `src/thread_manager.ts:472-475` | No test covers unregister then register of the manager with loaded threads ([item](#thread-manager-reregister)). | Add one. | | `test/unit/messageDelivery/MessageReceiptsTracker.test.ts` (`createChannelMock`) | Mock provides only the deprecated `userID`, blocking the `userId` switch. | Provide `userId`. | diff --git a/scripts/apply-custom-data-types.mts b/scripts/apply-custom-data-types.mts index 16d00de345..5ffa095278 100644 --- a/scripts/apply-custom-data-types.mts +++ b/scripts/apply-custom-data-types.mts @@ -202,6 +202,23 @@ const INTERFACE_CLOSE_RE = /^\}\s*$/; const FILTER_OPEN_RE = /^\s*(\w+)\??:\s*Filters<\{\s*$/; const CUSTOM_ENTRY_OPEN_RE = /^\s*custom:\s*\{\s*$/; const CUSTOM_ENTRY_TYPE_RE = /^(\s*)type:\s*Record;\s*$/; +// The spec's custom-event placeholder, a member of every WS event union. No event is ever dispatched +// with type `'*'` (a custom event arrives under its own name, typed through `CustomEventTypes`), so +// the member is dropped instead of letting `'*'` into `Event['type']`, where `client.on('*', ...)` +// would type-check and never fire. +const WILDCARD_EVENT_MEMBER_RE = /^\s*\|\s*\(\{ type: '\*' \} & CustomEvent\)\s*$/; +// Request payloads whose `created_at_*` filters come out as response timestamps (`TimestampNS`): +// the spec declares them `date-time` strings, like every other request date, but they are only +// reachable through the JSON `payload` query param. The API rejects a nanosecond number there +// ("Time.UnmarshalJSON: input is not a JSON string") and takes RFC3339, so they are typed the way +// every other request date is: a `Date` (serialized as RFC3339) or an RFC3339 string. +const REQUEST_DATE_FILTER_INTERFACES = new Set([ + 'QueryBannedUsersPayload', + 'QueryFutureChannelBansPayload', + 'QueryMembersPayload', +]); +const REQUEST_DATE_FILTER_RE = + /^(\s*)(created_at_(?:after|before)(?:_or_equal)?)(\??):\s*TimestampNS;\s*$/; const lines = source.split('\n'); const rewritten: string[] = []; @@ -213,6 +230,8 @@ let currentInterfaceHadMappedCustom = false; let rewrittenCustomCount = 0; let rewrittenChannelCustomCount = 0; let rewrittenFilterCustomCount = 0; +let droppedWildcardEventMemberCount = 0; +let retypedRequestDateFilterCount = 0; const skippedInterfaces = new Set(); const skippedFilterKeys = new Set(); @@ -236,6 +255,11 @@ const countBraces = (line: string) => { }; for (const line of lines) { + if (WILDCARD_EVENT_MEMBER_RE.test(line)) { + droppedWildcardEventMemberCount += 1; + continue; + } + const openMatch = line.match(INTERFACE_OPEN_RE); if (openMatch) { currentInterface = openMatch[1]; @@ -310,6 +334,17 @@ for (const line of lines) { continue; } + const requestDateFilterMatch = + currentInterface && REQUEST_DATE_FILTER_INTERFACES.has(currentInterface) + ? line.match(REQUEST_DATE_FILTER_RE) + : null; + if (requestDateFilterMatch) { + const [, indent, field, optional] = requestDateFilterMatch; + rewritten.push(`${indent}${field}${optional}: Date | string;`); + retypedRequestDateFilterCount += 1; + continue; + } + const channelCustomMatch = line.match(CHANNEL_CUSTOM_FIELD_RE); if (channelCustomMatch) { const [, indent, optional] = channelCustomMatch; @@ -357,7 +392,9 @@ for (const key of [...skippedFilterKeys].sort()) { console.log( `Rewrote ${rewrittenCustomCount} custom + ${rewrittenChannelCustomCount} channel_custom` + ` + ${rewrittenFilterCustomCount} filter custom fields; ` + - `skipped ${skippedInterfaces.size} interfaces and ${skippedFilterKeys.size} filter blocks.`, + `skipped ${skippedInterfaces.size} interfaces and ${skippedFilterKeys.size} filter blocks; ` + + `dropped ${droppedWildcardEventMemberCount} '*' event union members; ` + + `retyped ${retypedRequestDateFilterCount} request date filters.`, ); /** diff --git a/scripts/drop-placeholder-request-params.mts b/scripts/drop-placeholder-request-params.mts new file mode 100644 index 0000000000..d2a42b17e8 --- /dev/null +++ b/scripts/drop-placeholder-request-params.mts @@ -0,0 +1,30 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +/** + * Removes the `_request?: Record` parameter the generator emits on body-less + * endpoints (`getApp`, `getPoll`, `listDevices`, ...). It is never read and only holds a position, + * so callers had to pass `undefined` before they could reach `requestOptions`. Without it, + * `requestOptions` directly follows the path params, or is the first argument. + * + * Usage: node scripts/drop-placeholder-request-params.mts ... + */ +const PLACEHOLDER_PARAM_RE = /^\s*_request\?: Record,\s*$/; + +const paths = process.argv.slice(2); +if (paths.length === 0) { + console.error( + 'Usage: node scripts/drop-placeholder-request-params.mts ...', + ); + process.exit(1); +} + +for (const path of paths) { + const absolutePath = resolve(process.cwd(), path); + const lines = readFileSync(absolutePath, 'utf8').split('\n'); + const kept = lines.filter((line) => !PLACEHOLDER_PARAM_RE.test(line)); + writeFileSync(absolutePath, kept.join('\n')); + console.log( + `${path}: dropped ${lines.length - kept.length} placeholder request params.`, + ); +} diff --git a/scripts/generate-client.sh b/scripts/generate-client.sh index 4fd8732b9d..ce9a8f7fbd 100755 --- a/scripts/generate-client.sh +++ b/scripts/generate-client.sh @@ -14,4 +14,6 @@ yarn exec prettier --write $OUTPUT_DIR/models/index.ts node ./scripts/apply-custom-data-types.mts -i $OUTPUT_DIR/models/index.ts +node ./scripts/drop-placeholder-request-params.mts $OUTPUT_DIR/chat/ChatApi.ts $OUTPUT_DIR/chat/ChannelApi.ts $OUTPUT_DIR/moderation/ModerationApi.ts + yarn lint-fix \ No newline at end of file diff --git a/src/EventHandlerPipeline.ts b/src/EventHandlerPipeline.ts index 80e7cf4e9b..06528436a5 100644 --- a/src/EventHandlerPipeline.ts +++ b/src/EventHandlerPipeline.ts @@ -7,8 +7,8 @@ import type { Unsubscribe } from '@stream-io/state-store'; * Flat routing view of an event: every field any event can carry, all optional, on one object. * * Handlers route without narrowing, which `Event` does not allow — it is a discriminated union whose - * `{ type: '*' } & CustomEvent` member has none of the channel fields, and it admits bare custom-event - * strings (hence the `unknown` hop in `processOne`). Derived from that union, so a new generated field + * members carry different fields (a custom event has none of the channel fields), and it admits bare + * custom-event strings (hence the `unknown` hop in `processOne`). Derived from that union, so a new generated field * is routable without widening anything by hand. */ type EventObject = Extract; diff --git a/src/api-client.ts b/src/api-client.ts index 61fc4b4e5c..7dbb6b170a 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -223,6 +223,11 @@ export class ApiClient { 'x-client-request-id' ] as string; + // Only a token provider can hand out a fresh token. A static token reloads to the same expired + // value, so its request fails immediately. + const canRefreshExpiredToken = (error: unknown) => + isTokenExpiredError(error) && !this.client.tokenManager.isStatic(); + try { const response = await runWithRetry( async () => { @@ -249,7 +254,7 @@ export class ApiClient { try { requestResponse = await this.client.axiosInstance.request(config); } catch (error) { - if (isTokenExpiredError(error)) { + if (canRefreshExpiredToken(error)) { logger .withExtraTags('_doRequest') .debug( @@ -267,13 +272,7 @@ export class ApiClient { { delayBetweenRetries: (attemptNumber) => retryInterval(attemptNumber + 1), retryAttempts: 10, - isRetryable: (error) => { - if (!isAxiosError(error)) return false; - - if (error.status === 429 || isTokenExpiredError(error)) return true; - - return false; - }, + isRetryable: canRefreshExpiredToken, }, )(); diff --git a/src/channel.ts b/src/channel.ts index b524b6613c..7bc8ba633e 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -36,6 +36,7 @@ import { toDeclarativePaginatorConfig, } from './configuration/utils/declarativeSlices'; import type { + AddMembersRequest, AIState, BanUserOptions, ChannelGetOrCreateRequest, @@ -43,21 +44,22 @@ import type { ChannelMemberResponse, ChannelResponse, ChannelStateResponseFields, - ChannelUpdateOptions, Command, Event, EventHandler, EventPayload, EventType, FileUploadInput, + FilterTagsRequest, + InviteMembersRequest, LocalMessage, MarkReadRequest, MarkReadResponse, MessagePaginationParams, - MessageRequest, MessageResponse, MessageSetType, QueryMembersPayload, + RemoveMembersRequest, SharedLocation, StreamRequestOptions, StreamResponse, @@ -71,7 +73,6 @@ import { AIStates } from './types'; import type { StateStore } from '@stream-io/state-store'; import type { Unsubscribe } from '@stream-io/state-store'; import type { - ChannelMemberRequest as Gen_ChannelMemberRequest, ChannelPushPreferencesResponse as Gen_ChannelPushPreferencesResponse, MuteChannelRequest as Gen_MuteChannelRequest, UnmuteChannelRequest as Gen_UnmuteChannelRequest, @@ -631,12 +632,14 @@ export class Channel extends ChannelApi { uploadImage = (...args: Parameters) => this.uploadChannelImage(...args); - deleteFile(url: string, requestOptions?: StreamRequestOptions) { - return this.deleteChannelFile({ url }, requestOptions); + /** Deletes a file uploaded to this channel, the same request shape as `client.deleteFile`. */ + deleteFile(request: { url: string }, requestOptions?: StreamRequestOptions) { + return this.deleteChannelFile(request, requestOptions); } - deleteImage(url: string, requestOptions?: StreamRequestOptions) { - return this.deleteChannelImage({ url }, requestOptions); + /** Deletes an image uploaded to this channel, the same request shape as `client.deleteImage`. */ + deleteImage(request: { url: string }, requestOptions?: StreamRequestOptions) { + return this.deleteChannelImage(request, requestOptions); } /** @@ -746,16 +749,11 @@ export class Channel extends ChannelApi { /** * Disables slow mode. * - * @param _request - Unused. Holds the request position so `requestOptions` stays the - * second argument, as on the generated methods; pass `undefined`. * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The server response. */ - async disableSlowMode( - _request?: Record, - requestOptions?: StreamRequestOptions, - ) { + async disableSlowMode(requestOptions?: StreamRequestOptions) { return await this.update({ cooldown: 0 }, requestOptions); } @@ -838,17 +836,15 @@ export class Channel extends ChannelApi { /** * Adds members to the channel. * - * @param members - An array of members to add to the channel. - * @param message - Message object for channel members notification (optional). - * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @param request - The members to add, and any other channel update option. + * @param request.members - User ids, or member requests (`{ user_id, channel_role?, custom? }`). + * @param request.message - Message object for channel members notification (optional). * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The server response. */ async addMembers( - members: string[] | Array, - message?: MessageRequest, - options: ChannelUpdateOptions = {}, + { members, ...options }: AddMembersRequest, requestOptions?: StreamRequestOptions, ) { return await this.update( @@ -856,7 +852,6 @@ export class Channel extends ChannelApi { add_members: members.map((member) => typeof member === 'string' ? { user_id: member } : member, ), - message, ...options, }, requestOptions, @@ -866,61 +861,49 @@ export class Channel extends ChannelApi { /** * Adds filter tags to the channel. * - * @param tags - An array of tags to add to the channel. - * @param message - Message object for channel members notification (optional). - * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @param request - The tags to add, and any other channel update option. + * @param request.tags - The tags to add. + * @param request.message - Message object for channel members notification (optional). * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The server response. */ async addFilterTags( - tags: string[], - message?: MessageRequest, - options: ChannelUpdateOptions = {}, + { tags, ...options }: FilterTagsRequest, requestOptions?: StreamRequestOptions, ) { - return await this.update( - { add_filter_tags: tags, message, ...options }, - requestOptions, - ); + return await this.update({ add_filter_tags: tags, ...options }, requestOptions); } /** * Removes filter tags from the channel. * - * @param tags - An array of tags to remove from the channel. - * @param message - Message object for channel members notification (optional). - * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @param request - The tags to remove, and any other channel update option. + * @param request.tags - The tags to remove. + * @param request.message - Message object for channel members notification (optional). * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The server response. */ async removeFilterTags( - tags: string[], - message?: MessageRequest, - options: ChannelUpdateOptions = {}, + { tags, ...options }: FilterTagsRequest, requestOptions?: StreamRequestOptions, ) { - return await this.update( - { remove_filter_tags: tags, message, ...options }, - requestOptions, - ); + return await this.update({ remove_filter_tags: tags, ...options }, requestOptions); } /** * Invite members to the channel. * - * @param members - An array of members to invite to the channel. - * @param message - Message object for channel members notification (optional). - * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @param request - The members to invite, and any other channel update option. + * @param request.members - User ids, or member requests (`{ user_id, custom }`). + * @param request.message - Message object for channel members notification (optional). * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The server response. */ async inviteMembers( - members: string[] | Required>[], - message?: MessageRequest, - options: ChannelUpdateOptions = {}, + { members, ...options }: InviteMembersRequest, requestOptions?: StreamRequestOptions, ) { return await this.update( @@ -928,7 +911,6 @@ export class Channel extends ChannelApi { invites: members.map((member) => typeof member === 'string' ? { user_id: member } : member, ), - message, ...options, }, requestOptions, @@ -938,23 +920,18 @@ export class Channel extends ChannelApi { /** * Removes members from the channel. * - * @param members - An array of member identifiers. - * @param message - Message object for channel members notification (optional). - * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @param request - The members to remove, and any other channel update option. + * @param request.members - The user ids to remove. + * @param request.message - Message object for channel members notification (optional). * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The server response. */ async removeMembers( - members: string[], - message?: MessageRequest, - options: ChannelUpdateOptions = {}, + { members, ...options }: RemoveMembersRequest, requestOptions?: StreamRequestOptions, ) { - return await this.update( - { remove_members: members, message, ...options }, - requestOptions, - ); + return await this.update({ remove_members: members, ...options }, requestOptions); } /** @@ -1020,13 +997,11 @@ export class Channel extends ChannelApi { * @example * await channel.archive(); * - * @param _request - Unused. Holds the request position so `requestOptions` stays the - * second argument, as on the generated methods; pass `undefined`. * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The server response. */ - async archive(_request?: Record, requestOptions?: StreamRequestOptions) { + async archive(requestOptions?: StreamRequestOptions) { return await this.updateMemberPartial({ set: { archived: true } }, requestOptions); } @@ -1036,16 +1011,11 @@ export class Channel extends ChannelApi { * @example * await channel.unarchive(); * - * @param _request - Unused. Holds the request position so `requestOptions` stays the - * second argument, as on the generated methods; pass `undefined`. * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The server response. */ - async unarchive( - _request?: Record, - requestOptions?: StreamRequestOptions, - ) { + async unarchive(requestOptions?: StreamRequestOptions) { return await this.updateMemberPartial({ set: { archived: false } }, requestOptions); } @@ -1055,13 +1025,11 @@ export class Channel extends ChannelApi { * @example * await channel.pin(); * - * @param _request - Unused. Holds the request position so `requestOptions` stays the - * second argument, as on the generated methods; pass `undefined`. * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The server response. */ - async pin(_request?: Record, requestOptions?: StreamRequestOptions) { + async pin(requestOptions?: StreamRequestOptions) { return await this.updateMemberPartial({ set: { pinned: true } }, requestOptions); } @@ -1071,13 +1039,11 @@ export class Channel extends ChannelApi { * @example * await channel.unpin(); * - * @param _request - Unused. Holds the request position so `requestOptions` stays the - * second argument, as on the generated methods; pass `undefined`. * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The server response. */ - async unpin(_request?: Record, requestOptions?: StreamRequestOptions) { + async unpin(requestOptions?: StreamRequestOptions) { return await this.updateMemberPartial({ set: { pinned: false } }, requestOptions); } @@ -1197,15 +1163,10 @@ export class Channel extends ChannelApi { * Sends an event to notify watchers to clear the typing/thinking UI when the AI response starts streaming. * Typically used by the server connected to the AI service to inform clients that the AI response has started. * - * @param _request - Unused. Holds the request position so `requestOptions` stays the - * second argument, as on the generated methods; pass `undefined`. * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). */ - async clearAIIndicator( - _request?: Record, - requestOptions?: StreamRequestOptions, - ) { + async clearAIIndicator(requestOptions?: StreamRequestOptions) { await this.sendEvent( { event: { @@ -1220,15 +1181,10 @@ export class Channel extends ChannelApi { * Sends an event to stop AI response generation, leaving the message in its current state. * Triggered by the user to halt the AI response process. * - * @param _request - Unused. Holds the request position so `requestOptions` stays the - * second argument, as on the generated methods; pass `undefined`. * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). */ - async stopAIResponse( - _request?: Record, - requestOptions?: StreamRequestOptions, - ) { + async stopAIResponse(requestOptions?: StreamRequestOptions) { await this.sendEvent( { event: { @@ -2231,13 +2187,13 @@ export class Channel extends ChannelApi { * @param callbackOrNothing - The callback to call when an event type was provided (optional). * @returns An object with an `unsubscribe()` method. */ - on( + on( eventType: T, callback: EventHandler, ): { unsubscribe: () => void }; on(callback: EventHandler): { unsubscribe: () => void }; on( - callbackOrString: EventHandler | string, + callbackOrString: EventHandler | EventType, callbackOrNothing?: EventHandler, ): { unsubscribe: () => void } { const key = callbackOrNothing ? (callbackOrString as EventType) : 'all'; @@ -2275,9 +2231,12 @@ export class Channel extends ChannelApi { * @param callbackOrString - The event type, or the callback when removing an all-events listener. * @param callbackOrNothing - The callback to remove when an event type was provided (optional). */ - off(eventType: T, callback: EventHandler): void; + off(eventType: T, callback: EventHandler): void; off(callback: EventHandler): void; - off(callbackOrString: EventHandler | string, callbackOrNothing?: EventHandler): void { + off( + callbackOrString: EventHandler | EventType, + callbackOrNothing?: EventHandler, + ): void { const key = callbackOrNothing ? (callbackOrString as EventType) : 'all'; const callback = callbackOrNothing ? callbackOrNothing @@ -2677,7 +2636,13 @@ export class Channel extends ChannelApi { if (event.message) { this._extendEventWithOwnReactions(event); const formattedMessage = formatMessage(event.message); - if (!event.message.parent_id) { + const { parent_id: parentId, show_in_channel: shownInChannel } = event.message; + const isThreadReply = !!parentId && !shownInChannel; + const { threads } = this.getClient(); + // to avoid double state publish + const isAppliedByThread = + !!parentId && threads.hasSubscriptions && !!threads.get(parentId); + if (!isThreadReply && !isAppliedByThread) { this.messagePaginator.ingestItem(formattedMessage); this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage); // ingestItem auto-adds on pin / auto-removes on unpin (matchesFilter { pinned: true }). diff --git a/src/channel_state.ts b/src/channel_state.ts index b7de0b837e..75c72aa041 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -294,17 +294,11 @@ export class ChannelState extends StateStore { } get typing() { - return ( - this._channel?.messageComposer?.textComposer.typing ?? this.getLatestValue().typing - ); + return this.getLatestValue().typing; } set typing(typing: Record) { this.partialNext({ typing }); - - if (this._channel?.messageComposer) { - this._channel.messageComposer.textComposer.setTyping(typing); - } } setTypingEvent(userID: string, event: Event) { diff --git a/src/client.ts b/src/client.ts index 4838ea9b6a..07ef889e8b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -44,13 +44,13 @@ import type { LocalMessage, OwnUserResponse, PartializeAllBut, + PinMessageOptions, QueryChannelsRequest, QueryChannelsResponse, QueryThreadsRequest, ReactionResponse, SdkIdentifier, StreamRequestOptions, - TimestampNS, TokenOrProvider, UnBanUserOptions, UpdateUserPartialRequest, @@ -99,10 +99,6 @@ import type { import { ChatApi } from './gen-imports'; import type { StreamResponse } from './types'; -function isString(value: unknown): value is string { - return typeof value === 'string' || value instanceof String; -} - const logger = chatLoggerSystem.getLogger('client'); const offlineDbLogger = chatLoggerSystem.getLogger('offline-db'); @@ -121,13 +117,6 @@ export type ChannelConfigsState = { export type ClientUser = PartializeAllBut & { anon?: boolean }; -/** - * `T`, unless it is a server-sent {@link TimestampNS}: for parameters where a bare `number` means a - * relative offset rather than an instant. `any` passes through (`0 extends 1 & T`), so untyped - * callers are not suddenly rejected. - */ -type NotATimestampNS = 0 extends 1 & T ? T : T extends TimestampNS ? never : T; - /** * A thread query that asked for `watch: true` came back, so the server now watches the thread's * channel, the same rule `channel.watch()` and `queryChannels` apply. @@ -805,17 +794,12 @@ export class StreamChat extends ChatApi { /** * Retrieves application settings. * - * @param _request - Unused. Holds the request position so `requestOptions` stays the - * second argument, as on the generated methods; pass `undefined`. * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The application settings response. */ - async getAppSettings( - _request?: Record, - requestOptions?: StreamRequestOptions, - ) { - return await (this.appSettingsPromise = this.getApp(undefined, requestOptions)); + async getAppSettings(requestOptions?: StreamRequestOptions) { + return await (this.appSettingsPromise = this.getApp(requestOptions)); } /** @@ -985,12 +969,12 @@ export class StreamChat extends ChatApi { * @returns An object with an `unsubscribe()` method. */ on(callback: EventHandler): { unsubscribe: () => void }; - on( + on( eventType: T, callback: EventHandler, ): { unsubscribe: () => void }; on( - callbackOrString: EventHandler | string, + callbackOrString: EventHandler | EventType, callbackOrNothing?: EventHandler, ): { unsubscribe: () => void } { const key = callbackOrNothing ? (callbackOrString as EventType) : 'all'; @@ -1025,8 +1009,8 @@ export class StreamChat extends ChatApi { * @param callbackOrNothing - The callback to remove when an event type was provided (optional). */ off(callback: EventHandler): void; - off(eventType: string, callback: EventHandler): void; - off(callbackOrString: EventHandler | string, callbackOrNothing?: EventHandler) { + off(eventType: T, callback: EventHandler): void; + off(callbackOrString: EventHandler | EventType, callbackOrNothing?: EventHandler) { const key = callbackOrNothing ? (callbackOrString as EventType) : 'all'; const callback = callbackOrNothing ? callbackOrNothing @@ -1894,98 +1878,28 @@ export class StreamChat extends ChatApi { return false; } - /** - * Transforms an expiration value into an ISO string. - * - * A `number` is an offset in SECONDS, not a timestamp — passing a wire `pin_expires` overflows - * `Date`, which used to surface as an opaque `RangeError` from `toISOString()`. - * - * @param timeoutOrExpirationDate - Expiration date or timeout. Use `number` to set the timeout - * in seconds, `string` or `Date` to set the exact expiration date (optional). - * @returns The expiration as an ISO string, or `null`. - * @throws If a numeric offset does not resolve to a representable date. - */ - _normalizeExpiration(timeoutOrExpirationDate?: null | number | string | Date) { - let pinExpires: null | string = null; - if (typeof timeoutOrExpirationDate === 'number') { - const expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + timeoutOrExpirationDate); - if (Number.isNaN(expiresAt.getTime())) { - throw new Error( - `Expiration offset ${timeoutOrExpirationDate} does not resolve to a valid date`, - ); - } - pinExpires = expiresAt.toISOString(); - } else if (isString(timeoutOrExpirationDate)) { - pinExpires = timeoutOrExpirationDate; - } else if (timeoutOrExpirationDate instanceof Date) { - pinExpires = timeoutOrExpirationDate.toISOString(); - } - return pinExpires; - } - - /** - * Extracts a string message ID from either a message object or a message ID. - * - * @param messageOrMessageId - MessageRequest object or message ID. - * @param errorText - Error message to report in case of message ID absence. - * @returns The extracted message ID. - */ - _validateAndGetMessageId( - messageOrMessageId: string | { id: string }, - errorText: string, - ) { - let messageId: string; - if (typeof messageOrMessageId === 'string') { - messageId = messageOrMessageId; - } else { - if (!messageOrMessageId.id) { - throw Error(errorText); - } - messageId = messageOrMessageId.id; - } - return messageId; - } - /** * Pins the message. * - * A `number` is a relative offset in SECONDS, so a server-sent timestamp (`message.pinned_at`, - * `message.pin_expires`) is rejected at compile time rather than read as ~1.79e18 seconds from now. - * Hand one back as a date with `nsToDate(timestamp)`. - * - * @param messageOrMessageId - MessageRequest object or message ID. - * @param timeoutOrExpirationDate - Expiration date or timeout. Use `number` to set the timeout - * in seconds, `string` or `Date` to set the exact expiration date (optional). - * @param pinnedAt - Date when the message should be pinned. It affects the order of pinned - * messages. Use a negative number to set relative time in the past, `string` or `Date` to - * set the exact date of pin (optional). + * @param message - The message to pin, or just its `{ id }`. + * @param options - When the pin expires and where it sorts (optional). + * @param options.pin_expires - When the pin expires, as a `Date` or an RFC3339 string; `null` or + * leaving it out pins without expiry. Hand a server-sent timestamp back with `nsToDate(timestamp)`. + * @param options.pinned_at - The pin time, which orders the pinned messages, as a `Date` or an + * RFC3339 string (optional, defaults to now). * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The updated message response. */ - pinMessage< - Expiration extends null | number | string | Date | undefined = undefined, - PinnedAt extends number | string | Date | undefined = undefined, - >( - messageOrMessageId: string | { id: string }, - timeoutOrExpirationDate?: NotATimestampNS, - pinnedAt?: NotATimestampNS, + pinMessage( + { id }: { id: string }, + { pin_expires = null, pinned_at }: PinMessageOptions = {}, requestOptions?: StreamRequestOptions, ) { - const id = this._validateAndGetMessageId( - messageOrMessageId, - 'Please specify the message id when calling pinMessage', - ); + if (!id) throw new Error('Please specify the message id when calling pinMessage'); return this.updateMessagePartial( { id }, - { - set: { - pinned: true, - pin_expires: this._normalizeExpiration(timeoutOrExpirationDate), - pinned_at: this._normalizeExpiration(pinnedAt), - }, - }, + { set: { pinned: true, pin_expires, pinned_at: pinned_at ?? null } }, requestOptions, ); } @@ -1993,19 +1907,13 @@ export class StreamChat extends ChatApi { /** * Unpins the message that was previously pinned. * - * @param messageOrMessageId - MessageRequest object or message ID. + * @param message - The message to unpin, or just its `{ id }`. * @param requestOptions - Per-request options such as an abort `signal`. Never serialized * into the request (optional). * @returns The updated message response. */ - unpinMessage( - messageOrMessageId: string | { id: string }, - requestOptions?: StreamRequestOptions, - ) { - const id = this._validateAndGetMessageId( - messageOrMessageId, - 'Please specify the message id when calling unpinMessage', - ); + unpinMessage({ id }: { id: string }, requestOptions?: StreamRequestOptions) { + if (!id) throw new Error('Please specify the message id when calling unpinMessage'); return this.updateMessagePartial({ id }, { set: { pinned: false } }, requestOptions); } @@ -2101,7 +2009,8 @@ export class StreamChat extends ChatApi { /** * Returns the thread of a message by its ID, wrapped in a hydrated `Thread` instance. * - * @param messageId - The message ID. + * @param pathParams - The thread's parent message, as for `getThread`. + * @param pathParams.message_id - The parent message ID. * @param options - Options object for pagination and limiting the participants and replies * (optional, defaults to `{}`). * @param options.watch - Subscribes the user to the channel of the thread (optional). @@ -2112,7 +2021,7 @@ export class StreamChat extends ChatApi { * @returns The thread. */ async getThreadAndHydrate( - messageId: string, + { message_id: messageId }: { message_id: string }, options: GetThreadOptions = {}, requestOptions?: StreamRequestOptions, ) { diff --git a/src/connection/ConnectionRecoveryManager.ts b/src/connection/ConnectionRecoveryManager.ts index 81809cc132..d811032bae 100644 --- a/src/connection/ConnectionRecoveryManager.ts +++ b/src/connection/ConnectionRecoveryManager.ts @@ -158,6 +158,8 @@ export class ConnectionRecoveryManager extends WithSubscriptions { // reconnect still qualifies. if (!lastUnhealthyAt) return; + if (!this.config.enabled) return; + // The lists always recover off this edge; their own deferral handles offline ordering. runDetached(this.recoverChannelLists(), { context: 'recoverChannelLists' }); @@ -209,8 +211,7 @@ export class ConnectionRecoveryManager extends WithSubscriptions { if (!offlineDb?.state.getLatestValue().initialized) return false; const { unsubscribe } = offlineDb.syncManager.onSyncStatusChange((status) => { - // `false` is only published when going offline; there is nothing to recover then. - if (!status) return; + if (!status || !this.config.enabled) return; runDetached(this.recoverActiveChannels(), { context: 'recoverActiveChannels' }); }); this.unsubscribeSyncStatus = unsubscribe; @@ -271,13 +272,10 @@ export class ConnectionRecoveryManager extends WithSubscriptions { } private recoverChannelLists = async () => { - if (!this.config.enabled) return; await this.client.channelManager.recover(); }; private recoverActiveChannels = async ({ isRetry = false } = {}): Promise => { - if (!this.config.enabled) return; - if (this.isRecovering) { this.recoveryRequestedAgain = true; return; diff --git a/src/gen/chat/ChatApi.ts b/src/gen/chat/ChatApi.ts index c7217046c3..1b115ccf67 100644 --- a/src/gen/chat/ChatApi.ts +++ b/src/gen/chat/ChatApi.ts @@ -164,7 +164,6 @@ export class ChatApi { constructor(public readonly apiClient: ApiClient) {} getApp( - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -819,7 +818,6 @@ export class ChatApi { getMessage( pathParams: { id: string }, - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -899,7 +897,6 @@ export class ChatApi { deleteReaction( pathParams: { id: string; type: string }, - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -979,7 +976,6 @@ export class ChatApi { deletePollVote( pathParams: { message_id: string; poll_id: string; vote_id: string }, - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -995,7 +991,6 @@ export class ChatApi { deleteReminder( pathParams: { message_id: string }, - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -1173,7 +1168,6 @@ export class ChatApi { deleteScheduledMessage( pathParams: { id: string }, - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -1313,7 +1307,6 @@ export class ChatApi { } unreadCounts( - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -1343,7 +1336,6 @@ export class ChatApi { } listDevices( - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -1464,7 +1456,6 @@ export class ChatApi { deletePoll( pathParams: { poll_id: string }, - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -1480,7 +1471,6 @@ export class ChatApi { getPoll( pathParams: { poll_id: string }, - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -1544,7 +1534,6 @@ export class ChatApi { deletePollOption( pathParams: { poll_id: string; option_id: string }, - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -1560,7 +1549,6 @@ export class ChatApi { getPollOption( pathParams: { poll_id: string; option_id: string }, - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -1868,7 +1856,6 @@ export class ChatApi { } getBlockedUsers( - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -1898,7 +1885,6 @@ export class ChatApi { } getUserLiveLocations( - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( diff --git a/src/gen/models/index.ts b/src/gen/models/index.ts index 934eb582c3..32a81b1a95 100644 --- a/src/gen/models/index.ts +++ b/src/gen/models/index.ts @@ -6812,10 +6812,10 @@ export interface QueryBannedUsersPayload { operators: '$eq' | '$exists' | '$gt' | '$gte' | '$in' | '$lt' | '$lte'; }; }>; - created_at_after?: TimestampNS; - created_at_after_or_equal?: TimestampNS; - created_at_before?: TimestampNS; - created_at_before_or_equal?: TimestampNS; + created_at_after?: Date | string; + created_at_after_or_equal?: Date | string; + created_at_before?: Date | string; + created_at_before_or_equal?: Date | string; /** * Whether to exclude expired bans or not */ @@ -7177,10 +7177,10 @@ export interface QueryDraftsResponse { } export interface QueryFutureChannelBansPayload { - created_at_after?: TimestampNS; - created_at_after_or_equal?: TimestampNS; - created_at_before?: TimestampNS; - created_at_before_or_equal?: TimestampNS; + created_at_after?: Date | string; + created_at_after_or_equal?: Date | string; + created_at_before?: Date | string; + created_at_before_or_equal?: Date | string; /** * Whether to exclude expired bans or not */ @@ -7220,10 +7220,10 @@ export interface QueryFutureChannelBansResponse { export interface QueryMembersPayload { type: string; - created_at_after?: TimestampNS; - created_at_after_or_equal?: TimestampNS; - created_at_before?: TimestampNS; - created_at_before_or_equal?: TimestampNS; + created_at_after?: Date | string; + created_at_after_or_equal?: Date | string; + created_at_before?: Date | string; + created_at_before_or_equal?: Date | string; id?: string; limit?: number; offset?: number; @@ -11851,7 +11851,6 @@ export interface WSAuthMessage { } export type WSClientEvent = - | ({ type: '*' } & CustomEvent) | ({ type: 'ai_indicator.clear' } & AIIndicatorClearEvent) | ({ type: 'ai_indicator.stop' } & AIIndicatorStopEvent) | ({ type: 'ai_indicator.update' } & AIIndicatorUpdateEvent) @@ -11936,7 +11935,6 @@ export type WSClientEvent = | ({ type: 'user_group.updated' } & UserGroupUpdatedEvent); export type WSEvent = - | ({ type: '*' } & CustomEvent) | ({ type: 'ai_indicator.clear' } & AIIndicatorClearEvent) | ({ type: 'ai_indicator.stop' } & AIIndicatorStopEvent) | ({ type: 'ai_indicator.update' } & AIIndicatorUpdateEvent) diff --git a/src/gen/moderation/ModerationApi.ts b/src/gen/moderation/ModerationApi.ts index c16ea5bad7..084ad7d2b9 100644 --- a/src/gen/moderation/ModerationApi.ts +++ b/src/gen/moderation/ModerationApi.ts @@ -112,7 +112,6 @@ export class ModerationApi { deleteActionConfig( pathParams: { id: string }, - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -143,7 +142,6 @@ export class ModerationApi { getAppeal( pathParams: { id: string }, - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -295,7 +293,6 @@ export class ModerationApi { } listQueues( - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( @@ -326,7 +323,6 @@ export class ModerationApi { getQueue( pathParams: { id: string }, - _request?: Record, requestOptions?: StreamRequestOptions, ): Promise> { return this.apiClient.sendRequest( diff --git a/src/messageComposer/MessageComposerEffectHandlers.ts b/src/messageComposer/MessageComposerEffectHandlers.ts index be330c4daf..ae494c5677 100644 --- a/src/messageComposer/MessageComposerEffectHandlers.ts +++ b/src/messageComposer/MessageComposerEffectHandlers.ts @@ -34,7 +34,6 @@ const applyCommandActivationEffect: MessageComposerEffectHandler< suggestions: undefined, selection: { start: 0, end: 0 }, text: '', - typing: {}, }); const attachmentsToCancel = composer.attachmentManager.attachments; composer.attachmentManager.initState(); diff --git a/src/messageComposer/middleware/textComposer/types.ts b/src/messageComposer/middleware/textComposer/types.ts index 9967368a30..cbee1e0aad 100644 --- a/src/messageComposer/middleware/textComposer/types.ts +++ b/src/messageComposer/middleware/textComposer/types.ts @@ -1,6 +1,6 @@ import type { MessageComposer } from '../../messageComposer'; import type { MessageComposerEffect } from '../../messageComposer'; -import type { Command, Event, UserResponse } from '../../../types'; +import type { Command, UserResponse } from '../../../types'; import type { TokenizationPayload } from './textMiddlewareUtils'; import type { SearchSource, SearchSourceSync } from '../../../search'; import type { CustomTextComposerSuggestion } from '../../types.custom'; @@ -126,11 +126,6 @@ export type TextComposerState = { mentions?: MentionEntity[]; selection: TextSelection; text: string; - /** - * Live typing events keyed by user id. - * Maps `user.id` -> latest typing event (`typing.start`/`typing.stop`) for that user. - */ - typing: Record; command?: Command | null; suggestions?: Suggestions; }; diff --git a/src/messageComposer/pollComposer.ts b/src/messageComposer/pollComposer.ts index d7bc7c252c..ded24f0cae 100644 --- a/src/messageComposer/pollComposer.ts +++ b/src/messageComposer/pollComposer.ts @@ -122,15 +122,18 @@ export class PollComposer { /** * Updates specified fields and generates relevant errors. * - * @param data - Partial poll data with the fields to update. - * @param injectedFieldErrors - Errors produced externally that will take precedence over the - * errors generated in the middleware chain. + * @param params - The fields to update and any externally produced errors. + * @param params.data - Partial poll data with the fields to update. + * @param params.injectedFieldErrors - Errors produced externally that will take precedence over + * the errors generated in the middleware chain (optional). */ - // FIXME: change method params to a single object with the next major release - updateFields = async ( - data: UpdateFieldsData, - injectedFieldErrors?: PollComposerFieldErrors, - ) => { + updateFields = async ({ + data, + injectedFieldErrors, + }: { + data: UpdateFieldsData; + injectedFieldErrors?: PollComposerFieldErrors; + }) => { const { state, status } = await this.stateMiddlewareExecutor.execute({ eventName: 'handleFieldChange', initialValue: { diff --git a/src/messageComposer/textComposer.ts b/src/messageComposer/textComposer.ts index 88e16ffc37..2a79293299 100644 --- a/src/messageComposer/textComposer.ts +++ b/src/messageComposer/textComposer.ts @@ -18,7 +18,7 @@ import { userResponseToMentionEntity, } from './middleware/textComposer/mentionUtils'; import type { MessageComposer } from './messageComposer'; -import type { Command, DraftMessage, Event, LocalMessage, UserResponse } from '../types'; +import type { Command, DraftMessage, LocalMessage, UserResponse } from '../types'; export type TextComposerOptions = { composer: MessageComposer; @@ -120,7 +120,6 @@ const initState = ({ mentionedUsers: [], mentions: [], text, - typing: {}, selection: { start: text.length, end: text.length }, }; } @@ -134,7 +133,6 @@ const initState = ({ mentionedUsers, mentions, text, - typing: {}, selection: { start: text.length, end: text.length }, }; }; @@ -241,29 +239,6 @@ export class TextComposer { return this.state.getLatestValue().text; } - get typing() { - return this.state.getLatestValue().typing; - } - - set typing(typing: Record) { - this.state.partialNext({ typing }); - } - - setTyping = (typing: Record) => { - this.typing = typing; - }; - - setTypingEvent = (userId: string, event: Event) => { - this.typing = { ...this.typing, [userId]: event }; - }; - - removeTypingEvent = (userId: string) => { - if (!this.typing[userId]) return; - const typing = { ...this.typing }; - delete typing[userId]; - this.typing = typing; - }; - get textIsEmpty() { return textIsEmpty(this.text); } @@ -274,8 +249,7 @@ export class TextComposer { /** * Takes over `source`'s text: the text, selection, mentions and command. Not its suggestions, - * which belong to `source`'s open search, nor its typing events, which are the other users' in - * `source`'s channel. + * which belong to `source`'s open search. * * @internal */ diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index 05d2121059..e7cd0ebf36 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -442,21 +442,37 @@ export class MessageDeliveryReporter { * Builds the throttled `markRead`. A factory rather than an inline `throttle(...)` so the interval can * be swapped later — see {@link setMarkAsReadThrottleOptions}. * - * @param intervalMs - minimum gap between automatic `markRead` calls + * @param intervalMs - minimum gap between automatic `markRead` calls for the same collection */ // Auto mark-read is throttled and fire-and-forget: it's triggered by state changes / WS events, // not by an awaiting caller, so a rejection here has nowhere to propagate and would otherwise // surface as an unhandled rejection (e.g. `channel.markRead` throwing when read events are // disabled, or a transient network error). Swallow it — the auto path retries on the next // trigger, and explicit `markRead()` callers still receive the error. - private buildThrottledMarkRead = (intervalMs: number) => - throttle( - (collection: Channel | Thread, options?: MarkReadRequest) => { - void this.markRead(collection, options).catch(() => undefined); - }, - intervalMs, - { leading: true, trailing: true }, - ).throttledFn; + // + // One throttle per collection instance. A shared one keeps only the latest call for its trailing + // edge, so marking a channel inside the window dropped a pending mark-read of a thread or of another + // channel, and nothing retried it until the next unread message. + private buildThrottledMarkRead = (intervalMs: number) => { + const throttled = new WeakMap< + Channel | Thread, + (options?: MarkReadRequest) => void + >(); + return (collection: Channel | Thread, options?: MarkReadRequest) => { + let markRead = throttled.get(collection); + if (!markRead) { + markRead = throttle( + (markReadOptions?: MarkReadRequest) => { + void this.markRead(collection, markReadOptions).catch(() => undefined); + }, + intervalMs, + { leading: true, trailing: true }, + ).throttledFn; + throttled.set(collection, markRead); + } + markRead(options); + }; + }; public throttledMarkRead = this.buildThrottledMarkRead( DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG.markAsReadThrottleTimeoutMs, diff --git a/src/moderation.ts b/src/moderation.ts index 7ede932ccf..fd6e937ed4 100644 --- a/src/moderation.ts +++ b/src/moderation.ts @@ -19,28 +19,30 @@ export class Moderation extends ModerationApi { /** * Flags a user. * - * @param flaggedUserId - User ID to be flagged. - * @param reason - Reason for flagging the user. - * @param options - Additional options for flagging the user (optional, defaults to `{}`). - * @param options.custom - Additional data to be stored with the flag (optional). - * @param options.entity_creator_id - ID of the user who created the flagged entity. + * @param request - The user to flag, the reason and any other flag option. + * @param request.user_id - User ID to be flagged. + * @param request.reason - Reason for flagging the user (optional). + * @param request.custom - Additional data to be stored with the flag (optional). + * @param request.entity_creator_id - ID of the user who created the flagged entity. * Omitted when not supplied; the server resolves it from the entity (optional). - * @param options.moderation_payload - Content submitted for moderation alongside the + * @param request.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. */ flagUser( - flaggedUserId: string, - reason?: string, - options: ModerationFlagOptions = {}, + { + user_id, + reason, + ...options + }: { user_id: string; reason?: string } & ModerationFlagOptions, requestOptions?: StreamRequestOptions, ) { return this.flag( { entity_type: MODERATION_ENTITY_TYPES.user, - entity_id: flaggedUserId, + entity_id: user_id, reason, ...options, }, @@ -51,28 +53,30 @@ export class Moderation extends ModerationApi { /** * Flags a message. * - * @param messageId - MessageRequest ID to be flagged. - * @param reason - Reason for flagging the message. - * @param options - Additional options for flagging the message (optional, defaults to `{}`). - * @param options.custom - Additional data to be stored with the flag (optional). - * @param options.entity_creator_id - ID of the user who created the flagged entity. + * @param request - The message to flag, the reason and any other flag option. + * @param request.message_id - ID of the message to be flagged. + * @param request.reason - Reason for flagging the message (optional). + * @param request.custom - Additional data to be stored with the flag (optional). + * @param request.entity_creator_id - ID of the user who created the flagged entity. * Omitted when not supplied; the server resolves it from the entity (optional). - * @param options.moderation_payload - Content submitted for moderation alongside the + * @param request.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. */ flagMessage( - messageId: string, - reason?: string, - options: ModerationFlagOptions = {}, + { + message_id, + reason, + ...options + }: { message_id: string; reason?: string } & ModerationFlagOptions, requestOptions?: StreamRequestOptions, ) { return this.flag( { entity_type: MODERATION_ENTITY_TYPES.message, - entity_id: messageId, + entity_id: message_id, reason, ...options, }, diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 78e54e939e..a12d02ab8e 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -3102,12 +3102,6 @@ export abstract class BasePaginator { try { return await this.query(params); } catch (e) { - const isOfflineSupportEnabledWithItems = - this.isOfflineSupportEnabled && (this.items ?? []).length > 0; - if (!isOfflineSupportEnabledWithItems) { - this.state.partialNext({ lastQueryError: e as Error }); - } - if (remainingRetries > 0) { await sleep(DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES); return await this.runQueryRetryable({ @@ -3115,6 +3109,14 @@ export abstract class BasePaginator { retryCount: remainingRetries - 1, }); } + + // run only once all retries are exhausted + const isOfflineSupportEnabledWithItems = + this.isOfflineSupportEnabled && (this.items ?? []).length > 0; + if (!isOfflineSupportEnabledWithItems) { + this.state.partialNext({ lastQueryError: e as Error }); + } + if (this.config.throwErrors) { this.state.partialNext({ isLoading: false }); throw e; diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index f021b476f2..572f38e104 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -1411,7 +1411,7 @@ export class MessageIntervalPaginator extends BasePaginator< hardDelete?: boolean; deletedAt: TimestampNS; }) => { - const loadedMessages = this.items ?? []; + const loadedMessages = this._itemIndex.values(); // One logical operation touching many messages so we `coalesce` so this paginator's own window // emits once at the end rather than per message, and the shared-store fan-out folds into a diff --git a/src/pagination/paginators/ReminderPaginator.ts b/src/pagination/paginators/ReminderPaginator.ts index 07925359c0..e27b8b297e 100644 --- a/src/pagination/paginators/ReminderPaginator.ts +++ b/src/pagination/paginators/ReminderPaginator.ts @@ -81,27 +81,27 @@ export class ReminderPaginator extends BasePaginator< }); } - protected getNextQueryShape({ - direction, - }: Required< - Pick, 'direction'> - >): QueryRemindersRequest { - const cursor = this.cursor?.[direction] ?? undefined; + protected getNextQueryShape(): QueryRemindersRequest { return { filter: this.filters, sort: this.sort, limit: this.pageSize, - // The request names its cursors after the response's `next` / `prev`, not after the direction. - ...(cursor ? { [direction === 'tailward' ? 'next' : 'prev']: cursor } : {}), }; } query = async ({ + direction, queryShape, }: PaginationQueryParams): Promise< PaginationQueryReturnValue > => { - const { reminders: items, next, prev } = await this.client.queryReminders(queryShape); + const cursor = direction ? (this.cursor?.[direction] ?? undefined) : undefined; + const request: QueryRemindersRequest = { + ...(queryShape ?? this.getNextQueryShape()), + // The request names its cursors after the response's `next` / `prev`, not after the direction. + ...(cursor ? { [direction === 'tailward' ? 'next' : 'prev']: cursor } : {}), + }; + const { reminders: items, next, prev } = await this.client.queryReminders(request); return { items, headward: prev, tailward: next }; }; diff --git a/src/poll_manager.ts b/src/poll_manager.ts index bcccce6387..5b67773905 100644 --- a/src/poll_manager.ts +++ b/src/poll_manager.ts @@ -4,10 +4,9 @@ import type { LocalMessage, MessageResponse, PollResponseData, - QueryPollsFilters, - QueryPollsOptions, - SortParamRequest, + StreamRequestOptions, } from './types'; +import type { QueryPollsRequest } from './gen/models'; import { Poll } from './poll'; import { formatMessage } from './utils'; import { WithSubscriptions } from './utils/WithSubscriptions'; @@ -77,16 +76,19 @@ export class PollManager extends WithSubscriptions { return this.fromState(id); }; + /** + * Queries polls and caches them as `Poll` instances. Takes the same request as `client.queryPolls`. + * + * @param request - The filter, sort and pagination of the query (optional). + * @param requestOptions - Per-request options such as an abort `signal`. Never serialized + * into the request (optional). + * @returns The `Poll` instances and the cursor of the next page. + */ public queryPolls = async ( - filter: QueryPollsFilters, - sort: SortParamRequest[] = [], - options: QueryPollsOptions = {}, + request: QueryPollsRequest = {}, + requestOptions?: StreamRequestOptions, ) => { - const { polls, next } = await this.client.queryPolls({ - filter, - sort, - ...options, - }); + const { polls, next } = await this.client.queryPolls(request, requestOptions); const pollInstances = polls.map((poll) => { this.setOrOverwriteInCache(poll, true); diff --git a/src/search/MessageSearchSource.ts b/src/search/MessageSearchSource.ts index 47eeb79188..474d5740c4 100644 --- a/src/search/MessageSearchSource.ts +++ b/src/search/MessageSearchSource.ts @@ -232,6 +232,13 @@ export class MessageSearchSource< >, }); + const customSort = this.messageSearchSort ?? []; + const createdAtSort = customSort.find(({ field }) => field === 'created_at'); + const sort: SortParamRequest[] = [ + { field: 'created_at', direction: createdAtSort?.direction ?? -1 }, + ...customSort.filter(({ field }) => field !== 'created_at'), + ]; + const { next, results } = await this.client.search( { payload: { @@ -239,13 +246,7 @@ export class MessageSearchSource< message_filter_conditions: messageFilters, limit: this.pageSize, next: this.next, - sort: [ - { - field: 'created_at', - direction: -1, - }, - ...(this.messageSearchSort ?? []), - ], + sort, }, }, queryOptions, diff --git a/src/thread.ts b/src/thread.ts index 1381c20c94..5c9de8fb0a 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -424,10 +424,13 @@ export class Thread extends WithSubscriptions { Math.max(loadedReplyCount, this.messagePaginator.pageSize ?? 0) || undefined; const reconcileCandidateIds = new Set(loadedReplies.map((reply) => reply.id)); const failedBefore = loadedReplies.filter((reply) => reply.status === 'failed'); - const thread = await this.client.getThreadAndHydrate(this.id, { - watch: true, - reply_limit: requestedReplyLimit, - }); + const thread = await this.client.getThreadAndHydrate( + { message_id: this.id }, + { + watch: true, + reply_limit: requestedReplyLimit, + }, + ); this.hydrateState(thread, { reconcile: { requestedLimit: requestedReplyLimit, diff --git a/src/types.ts b/src/types.ts index 468ebe3d15..aef750d317 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ import type { Attachment, BanRequest, ChannelConfigWithInfo, + ChannelMemberRequest, ChannelOwnCapability, ChannelStateResponseFields, CreateDeviceRequest, @@ -13,6 +14,7 @@ import type { DraftPayloadResponse, FlagRequest, Images, + MessageRequest, MessageResponse, OwnUserResponse, QueryChannelsRequest, @@ -69,6 +71,33 @@ export type APIResponse = { export type ChannelUpdateOptions = Omit; +/** + * A channel update that adds or removes items (members, invitees, filter tags): the items, a message + * notifying the channel members, and any other `update()` option. + */ +type ChannelItemsUpdate = Record & { + /** Message object for channel members notification (optional). */ + message?: MessageRequest; +} & ChannelUpdateOptions; + +/** Request of `channel.addMembers`: user ids, or member requests with a role or custom data. */ +export type AddMembersRequest = ChannelItemsUpdate< + 'members', + string | ChannelMemberRequest +>; + +/** Request of `channel.inviteMembers`. */ +export type InviteMembersRequest = ChannelItemsUpdate< + 'members', + string | Required> +>; + +/** Request of `channel.removeMembers`: the user ids to remove. */ +export type RemoveMembersRequest = ChannelItemsUpdate<'members', string>; + +/** Request of `channel.addFilterTags` and `channel.removeFilterTags`. */ +export type FilterTagsRequest = ChannelItemsUpdate<'tags', string>; + export type ConnectAPIResponse = Promise; export type LocalMessage = MessageResponse & { @@ -79,6 +108,14 @@ export type LocalMessage = MessageResponse & { export type GetThreadOptions = NonNullable[1]>; +/** Options of `client.pinMessage`. */ +export type PinMessageOptions = { + /** When the pin expires; `null` or leaving it out pins without expiry. */ + pin_expires?: Date | string | null; + /** The pin time, which orders the pinned messages (defaults to now). */ + pinned_at?: Date | 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. @@ -206,7 +243,14 @@ type CustomEventTypeUnion = { }[keyof CustomEventTypes]; export type EventType = Event['type'] | 'all'; -export type EventHandler = (event: Extract) => void; +/** + * A listener for events of type `T`. `'all'` (the default) receives every event, any other type the + * event members declaring it. Custom event types have to be declared by augmenting + * `CustomEventTypes`; an undeclared name is not an `EventType`. + */ +export type EventHandler = ( + event: T extends 'all' ? Event : Extract, +) => void; /** * Filter Types diff --git a/src/utils/retryable.ts b/src/utils/retryable.ts index d33b1bfe0c..e5f0a248a0 100644 --- a/src/utils/retryable.ts +++ b/src/utils/retryable.ts @@ -2,7 +2,6 @@ type FunctionToRetry = (...functionArguments: any[]) => PromiseLike; enum RetryErrorType { ABORT, - ATTEMPT_LIMIT_REACHED, } export class RetryError extends Error { @@ -10,7 +9,6 @@ export class RetryError extends Error { private static errorMap = { [RetryErrorType.ABORT]: 'Value changed, retry handler aborted', - [RetryErrorType.ATTEMPT_LIMIT_REACHED]: 'Reached maximum amount of retry attempts', } satisfies Record; constructor({ type }: { type: RetryErrorType }) { @@ -51,6 +49,7 @@ export type RunWithRetryOptions = { /** * Function which wraps asynchronous functions with retry mechanism which'll keep executing said * function pre-defined number of times until it resolves, rejects or the retry mechanism runs out of available attempts. + * Running out of attempts rejects with the error of the last attempt. * * #### Available options: * - `didValueChange` - check function with initial argument value to check against new values, return `true` to abort next attempt @@ -73,10 +72,11 @@ export const runWithRetry = ( async function retryable(...functionArguments: Parameters) { // starting with -1 as first attempt is not considered a retry let retryAttempt = -1; + let error: unknown = null; do { let data: Awaited> | null = null; - let error: unknown = null; + error = null; if (await didValueChange?.(...functionArguments)) { throw new RetryError({ type: RetryErrorType.ABORT }); @@ -113,5 +113,5 @@ export const runWithRetry = ( } } while (retryAttempt < retryAttempts); - throw new RetryError({ type: RetryErrorType.ATTEMPT_LIMIT_REACHED }); + throw error; }; diff --git a/src/utils/time.ts b/src/utils/time.ts index f422f69518..2b0fd86cf5 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -18,7 +18,10 @@ * * Outgoing **request** date fields are unaffected: they are still typed `Date`, because * `JSON.stringify` emits RFC3339 for a `Date` and that is the format the request spec declares. - * Use {@link nsToDate} when handing a server-sent timestamp back to the API. + * Use {@link nsToDate} when handing a server-sent timestamp back to the API. That includes the + * `created_at_*` filters of `QueryMembersPayload`, `QueryBannedUsersPayload` and + * `QueryFutureChannelBansPayload`, which take a `Date` or an RFC3339 string: the API rejects a + * nanosecond number there. * * Precision note: nanosecond epoch values exceed `Number.MAX_SAFE_INTEGER` (~9.01e15), so they are * quantised to roughly 256 ns steps. Ordering is unaffected; exact equality against a value that diff --git a/stream-chat-react-v10-breaking-changes.md b/stream-chat-react-v10-breaking-changes.md new file mode 100644 index 0000000000..6c4864ca1f --- /dev/null +++ b/stream-chat-react-v10-breaking-changes.md @@ -0,0 +1,81 @@ +# stream-chat-react: impact of the v10 pre-release fixes + +Throwaway working file. It lists how the fixes landed on stream-chat-js `fix/v10-hotfixes` (section 1 of `ai-docs/v10-followups.md`) affect stream-chat-react, one entry per fix as it lands. + +- **stream-chat-react:** `master` @ `2db4e7b5e` (`15.0.0-rc.2`, `stream-chat` `^10.0.0-rc.22`). + +## Breaking for React + +### 1. `typing` removed from the text composer + +`TextComposerState` no longer has `typing`, and `TextComposer` no longer has the `typing` getter / setter, `setTyping`, `setTypingEvent` or `removeTypingEvent`. `channel.state` is the only owner of the typing map (`TypingUsersState['typing']`, still `Record`). v9 had no composer typing either; the copy existed only in the v10 release candidates. + +React reads the composer copy in four places, all through the same selector over `messageComposer.textComposer.state`: + +- `src/components/TypingIndicator/TypingIndicator.tsx:30,62-63` +- `src/components/TypingIndicator/TypingIndicatorHeader.tsx:21,45-46` +- `src/components/ChannelHeader/ChannelHeader.tsx:18,28-29` +- `src/components/Thread/ThreadHeader.tsx:25,49-50` + +Each one compiles against a missing `typing` key and then reads `{}`, so no typing indicator renders. The replacement reads the channel's store; `messageComposer.channel` is already available in all four: + +```ts +import type { TypingUsersState } from 'stream-chat'; + +const typingSelector = ({ typing }: TypingUsersState) => ({ typing }); + +const { typing = {} } = + useStateStore(messageComposer.channel.state, typingSelector) ?? {}; +``` + +The existing `parent_id` filters stay as they are. Inside a thread, `useMessageComposerController()` returns the thread's own composer, and `stream-chat` only ever copied typing into `channel.messageComposer`, so on the RCs the thread-mode indicators (`TypingIndicator`, `TypingIndicatorHeader`, `ThreadHeader`) always saw an empty map. Reading `channel.state` makes them work for the first time. + +Tests that seed the composer copy need to seed `channel.state` instead (`channel.state.setTypingEvent(userId, event)`, or `channel.state.typing = {...}`): + +- `src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx:23-71,116` (builds a fake `textComposer.state` store for thread mode and assigns `channel.messageComposer.textComposer.typing`) +- `src/components/Thread/__tests__/ThreadHeader.test.tsx` + +### 14. Positional client and channel methods take one request object + +`pinMessage` / `unpinMessage`, `getThreadAndHydrate`, the channel member and filter-tag helpers, `channel.deleteImage` / `deleteFile`, `client.polls.queryPolls` and `moderation.flagMessage` / `flagUser` now take the same object shapes as the generated methods. `requestOptions` stays the last argument. + +| Method | Before | After | +| -------------------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `client.pinMessage` | `(messageOrId, timeoutOrExpiration?, pinnedAt?)` | `({ id }, { pin_expires?, pinned_at? }?)`, `pin_expires` a `Date` / RFC3339 string / `null`, no relative seconds | +| `client.unpinMessage` | `(messageOrId)` | `({ id })` | +| `client.getThreadAndHydrate` | `(messageId, options?)` | `({ message_id }, options?)` | +| `channel.addMembers` / `inviteMembers` / `removeMembers` | `(members, message?, options?)` | `({ members, message?, ...options })` | +| `channel.addFilterTags` / `removeFilterTags` | `(tags, message?, options?)` | `({ tags, message?, ...options })` | +| `channel.deleteImage` / `deleteFile` | `(url)` | `({ url })` | +| `client.polls.queryPolls` | `(filter, sort?, options?)` | `({ filter, sort?, ...options })` | +| `client.moderation.flagMessage` / `flagUser` | `(id, reason?, options?)` | `({ message_id, reason?, ...options })` / `({ user_id, reason?, ...options })` | + +React call sites that change: + +- `src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx:240`: `channel.addMembers(selectedUserIds)` -> `channel.addMembers({ members: selectedUserIds })` +- `src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx:603`: `channel.removeMembers([targetUserId])` -> `channel.removeMembers({ members: [targetUserId] })` +- `src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx:644`: `channel.removeMembers([client.userID])` -> `channel.removeMembers({ members: [client.userID] })` +- `src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx:270`: `channel.removeMembers([client.userID!])` -> `channel.removeMembers({ members: [client.userID!] })` +- `src/components/Message/hooks/useFlagHandler.ts:22`: `client.moderation.flagMessage(message.id)` -> `client.moderation.flagMessage({ message_id: message.id })` +- `src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts:78`: `client.getThreadAndHydrate(parentId, { watch: true })` -> `client.getThreadAndHydrate({ message_id: parentId }, { watch: true })` + +`client.pinMessage(message)` / `client.unpinMessage(message)` in `src/components/Message/hooks/usePinHandler.ts:69,94` pass the message object, which still compiles. + +Test assertions on the old argument shapes: + +- `src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx:161,214` (`addMembers`) +- `src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx:448` (`removeMembers`) +- `src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx:464` (`removeMembers`) +- `src/components/Message/__tests__/Message.test.tsx:557,577` (`flagMessage`) +- `src/components/Message/__tests__/MessageUI.test.tsx:787` and `src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx:171` (`getThreadAndHydrate`) + +### 17. `PollComposer.updateFields` takes one object + +`updateFields(data, injectedFieldErrors?)` -> `updateFields({ data, injectedFieldErrors? })`. 11 React call sites, all in `src/components/Poll/PollCreationDialog/`: + +- `NameField.tsx:53` +- `OptionFieldSet.tsx:84,101,166,260` +- `MultipleAnswersField.tsx:62,75,120`; `:120` also passes injected errors, so it becomes `updateFields({ data: { max_votes_allowed: ... }, injectedFieldErrors: nativeFieldValidation })` +- `PollCreationDialog.tsx:62,76,90` + +The rest are one-argument calls: wrap the argument, `updateFields({ name: e.target.value })` -> `updateFields({ data: { name: e.target.value } })`. No React test asserts on `updateFields`. diff --git a/test/docs-snippets/pages/chat/03-channels/04-query_members.test.ts b/test/docs-snippets/pages/chat/03-channels/04-query_members.test.ts index a4033a6cb1..c12ae5d797 100644 --- a/test/docs-snippets/pages/chat/03-channels/04-query_members.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/04-query_members.test.ts @@ -40,7 +40,7 @@ describe(DOCS, () => { if (channel.cid) cleanup.channels.push(channel.cid); // Added one by one so the members have distinct created_at values. for (const id of [tommaso, jane, bob]) { - await channel.addMembers([id]); + await channel.addMembers({ members: [id] }); } }); diff --git a/test/docs-snippets/pages/chat/03-channels/06-channel_members.test.ts b/test/docs-snippets/pages/chat/03-channels/06-channel_members.test.ts index bf5311134a..24b0118ef7 100644 --- a/test/docs-snippets/pages/chat/03-channels/06-channel_members.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/06-channel_members.test.ts @@ -76,13 +76,17 @@ describe('_default/03-channels/06-channel_members.md', () => { // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Adding Members" tab="JavaScript" index=1 // COPY: thierry="thierry", josh="josh", jamesBond="james_bond", alecTrevelyan="alec_trevelyan" - await channel.addMembers([thierry, josh]); + await channel.addMembers({ members: [thierry, josh] }); // Add user to the channel with role set - await channel.addMembers([{ user_id: jamesBond, channel_role: 'channel_moderator' }]); + await channel.addMembers({ + members: [{ user_id: jamesBond, channel_role: 'channel_moderator' }], + }); // Add new channel member with custom data - await channel.addMembers([{ user_id: alecTrevelyan, custom: { code_name: '006' } }]); + await channel.addMembers({ + members: [{ user_id: alecTrevelyan, custom: { code_name: '006' } }], + }); // #endregion snippet const members = await membersOf(channel); @@ -118,7 +122,7 @@ describe('_default/03-channels/06-channel_members.md', () => { // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Removing Members" tab="JavaScript" index=1 // COPY: tommaso="tommaso" - await channel.removeMembers([tommaso]); + await channel.removeMembers({ members: [tommaso] }); // #endregion snippet expect(Object.keys(await membersOf(channel))).toEqual([owner]); @@ -139,7 +143,7 @@ describe('_default/03-channels/06-channel_members.md', () => { // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Leaving a Channel" tab="JavaScript" index=1 // COPY: owner="my_user_id" // Remove own channel membership - await channel.removeMembers([owner]); + await channel.removeMembers({ members: [owner] }); // #endregion snippet expect(Object.keys(await membersOf(channel))).toEqual([thierry]); @@ -151,7 +155,7 @@ describe('_default/03-channels/06-channel_members.md', () => { // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="Hide History" tab="JavaScript" index=1 // COPY: thierry="thierry" - await channel.addMembers([thierry], undefined, { hide_history: true }); + await channel.addMembers({ members: [thierry], hide_history: true }); // #endregion snippet // The message sent before thierry joined is hidden from him. @@ -166,9 +170,7 @@ describe('_default/03-channels/06-channel_members.md', () => { // COPY: thierry="thierry" const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 7); // Last 7 days - await channel.addMembers([thierry], undefined, { - hide_history_before: cutoff, - }); + await channel.addMembers({ members: [thierry], hide_history_before: cutoff }); // #endregion snippet // Only history older than the cutoff is hidden: today's message is visible. @@ -180,7 +182,10 @@ describe('_default/03-channels/06-channel_members.md', () => { // #region snippet docs="chat/_default/03-channels/06-channel_members.md" heading="System Message Parameter" tab="JavaScript" index=1 // COPY: tommaso="tommaso" - await channel.addMembers([tommaso], { text: 'Tommaso joined the channel.' }); + await channel.addMembers({ + members: [tommaso], + message: { text: 'Tommaso joined the channel.' }, + }); // #endregion snippet const { messages } = await serverClient.chat @@ -209,7 +214,9 @@ describe('_default/03-channels/06-channel_members.md', () => { await channel.create(); // Add custom data with addMembers method - await channel.addMembers([{ user_id: userid1, custom: { key1: 'value1' } }]); + await channel.addMembers({ + members: [{ user_id: userid1, custom: { key1: 'value1' } }], + }); // #endregion snippet const members = await membersOf(channel); diff --git a/test/docs-snippets/pages/chat/03-channels/07-channel_management/16-channel_invites.test.ts b/test/docs-snippets/pages/chat/03-channels/07-channel_management/16-channel_invites.test.ts index 43215c402c..ac63815e09 100644 --- a/test/docs-snippets/pages/chat/03-channels/07-channel_management/16-channel_invites.test.ts +++ b/test/docs-snippets/pages/chat/03-channels/07-channel_management/16-channel_invites.test.ts @@ -63,7 +63,7 @@ describe('_default/03-channels/07-channel_management/16-channel_invites.md', () // #region snippet docs="chat/_default/03-channels/07-channel_management/16-channel_invites.md" heading="Invite Users" tab="JavaScript" index=1 // COPY: nickId="nick" - await channel.inviteMembers([nickId]); + await channel.inviteMembers({ members: [nickId] }); // #endregion snippet const member = await getServerMember(serverClient, `messaging:${id}`, nickId); diff --git a/test/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts b/test/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts index 7da0dcde98..42ff09dbd7 100644 --- a/test/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/02-file_uploads.test.ts @@ -168,8 +168,8 @@ describe('_default/04-messages/02-file_uploads.md', () => { // #region snippet docs="chat/_default/04-messages/02-file_uploads.md" heading="Deleting Files" tab="JavaScript" index=1 // Delete from channel - await channel.deleteFile(fileURL); - await channel.deleteImage(imageURL); + await channel.deleteFile({ url: fileURL }); + await channel.deleteImage({ url: imageURL }); // #endregion snippet // The CDN may keep serving a cached copy for a while, so check the API responses instead. @@ -178,8 +178,8 @@ describe('_default/04-messages/02-file_uploads.md', () => { spy.mock.results.map((r) => r.value), ), ); - expect(deleteFileSpy).toHaveBeenCalledWith(fileURL); - expect(deleteImageSpy).toHaveBeenCalledWith(imageURL); + expect(deleteFileSpy).toHaveBeenCalledWith({ url: fileURL }); + expect(deleteImageSpy).toHaveBeenCalledWith({ url: imageURL }); expect(responses).toHaveLength(2); responses.forEach((response) => expect(response.duration).toBeDefined()); }); diff --git a/test/docs-snippets/pages/chat/04-messages/03-threads.test.ts b/test/docs-snippets/pages/chat/04-messages/03-threads.test.ts index 7f1d548682..2e805476f0 100644 --- a/test/docs-snippets/pages/chat/04-messages/03-threads.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/03-threads.test.ts @@ -199,11 +199,14 @@ describe('_default/04-messages/03-threads.md', () => { it('gets a thread by id', async () => { // #region snippet docs="chat/_default/04-messages/03-threads.md" heading="Getting a Thread by ID" tab="JavaScript" index=1 - const thread = await client.getThreadAndHydrate(parentMessageId, { - watch: true, - reply_limit: 10, - participant_limit: 25, - }); + const thread = await client.getThreadAndHydrate( + { message_id: parentMessageId }, + { + watch: true, + reply_limit: 10, + participant_limit: 25, + }, + ); // #endregion snippet expect(thread.id).toBe(parentMessageId); diff --git a/test/docs-snippets/pages/chat/04-messages/05-pinned_messages.test.ts b/test/docs-snippets/pages/chat/04-messages/05-pinned_messages.test.ts index b9e93d4f7e..1d92ef8ae1 100644 --- a/test/docs-snippets/pages/chat/04-messages/05-pinned_messages.test.ts +++ b/test/docs-snippets/pages/chat/04-messages/05-pinned_messages.test.ts @@ -73,13 +73,13 @@ describe('_default/04-messages/05-pinned_messages.md', () => { }); // Pin an existing message for 120 seconds - await client.pinMessage(message, 120); + await client.pinMessage(message, { pin_expires: new Date(Date.now() + 120 * 1000) }); // Pin with a specific expiration date - await client.pinMessage(message, '2077-01-01T00:00:00Z'); + await client.pinMessage(message, { pin_expires: '2077-01-01T00:00:00Z' }); // Pin indefinitely (remove expiration) - await client.pinMessage(message, null); + await client.pinMessage(message, { pin_expires: null }); // Unpin a message await client.unpinMessage(message); diff --git a/test/docs-snippets/pages/chat/05-features/07-polls_api.test.ts b/test/docs-snippets/pages/chat/05-features/07-polls_api.test.ts index a83ee74ab1..da3f03a721 100644 --- a/test/docs-snippets/pages/chat/05-features/07-polls_api.test.ts +++ b/test/docs-snippets/pages/chat/05-features/07-polls_api.test.ts @@ -498,10 +498,10 @@ describe(DOCS, () => { }); // or if you're using PollManager - const { polls: pollInstances } = await chatClient.polls.queryPolls( - { is_closed: true }, - [{ field: 'created_at', direction: -1 }], - ); + const { polls: pollInstances } = await chatClient.polls.queryPolls({ + filter: { is_closed: true }, + sort: [{ field: 'created_at', direction: -1 }], + }); // the returned polls will be ones with reactive state // #endregion snippet diff --git a/test/docs-snippets/pages/chat/12-best_practices/02-moderation.test.ts b/test/docs-snippets/pages/chat/12-best_practices/02-moderation.test.ts index c67241925c..86dc0cac4c 100644 --- a/test/docs-snippets/pages/chat/12-best_practices/02-moderation.test.ts +++ b/test/docs-snippets/pages/chat/12-best_practices/02-moderation.test.ts @@ -76,9 +76,9 @@ describe(DOCS, () => { // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Giving moderators more permissions" tab="JavaScript" index=1 // COPY: jamesBondId="james_bond" // Add a member with moderator role - await channel.addMembers([ - { user_id: jamesBondId, channel_role: 'channel_moderator' }, - ]); + await channel.addMembers({ + members: [{ user_id: jamesBondId, channel_role: 'channel_moderator' }], + }); // #endregion snippet const member = await getServerMember(serverClient, `messaging:${id}`, jamesBondId); @@ -120,10 +120,12 @@ describe(DOCS, () => { // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Flag" tab="JavaScript" index=1 // A user's flag on a message is recorded once: flagging it again doesn't update the reason or custom data // Flag a message - const flag = await client.moderation.flagMessage(messageId); + const flag = await client.moderation.flagMessage({ message_id: messageId }); // Flag with a reason and custom data - const flagWithReason = await client.moderation.flagMessage(messageId, 'spammy_user', { + const flagWithReason = await client.moderation.flagMessage({ + message_id: messageId, + reason: 'spammy_user', custom: { user_comment: 'This user is spamming.', }, @@ -148,10 +150,15 @@ describe(DOCS, () => { // #region snippet docs="chat/_default/12-best_practices/02-moderation.md" heading="Reasons & custom data" tab="JavaScript" index=1 // A user's flag on a message is recorded once: flagging it again doesn't update the reason or custom data // flag with a reason - let flag = await client.moderation.flagMessage(messageID, 'spammy_user'); + let flag = await client.moderation.flagMessage({ + message_id: messageID, + reason: 'spammy_user', + }); // flag with a reason and additional custom data - flag = await client.moderation.flagMessage(messageID, 'spammy_user', { + flag = await client.moderation.flagMessage({ + message_id: messageID, + reason: 'spammy_user', custom: { user_comment: 'This user is spamming the homepage.', page: 'homepage', @@ -159,7 +166,8 @@ describe(DOCS, () => { }); // flag with only custom data - flag = await client.moderation.flagMessage(messageID, undefined, { + flag = await client.moderation.flagMessage({ + message_id: messageID, custom: { page: 'homepage', }, diff --git a/test/types/event-listeners.ts b/test/types/event-listeners.ts new file mode 100644 index 0000000000..209b921bfa --- /dev/null +++ b/test/types/event-listeners.ts @@ -0,0 +1,71 @@ +/** + * Compile-time contract for `on()` / `off()` on the client and on a channel. Type-checked, never + * executed. + * + * Every `@ts-expect-error` is an assertion: when the line below it stops being an error, tsc fails on + * the unused directive. `friendship_request` is the custom event declared in `custom-events.ts`; the + * `CustomEventTypes` augmentation there is global to this program. + */ +import type { + Channel, + Event, + EventHandler, + EventPayload, + EventType, + StreamChat, +} from 'stream-chat'; + +declare const client: StreamChat; +declare const channel: Channel; + +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +const assertType = (value: T) => value; + +// --- 'all' and the callback-only form receive every event ---------------------------------------- + +client.on('all', (event) => assertType>(true)); +channel.on('all', (event) => assertType>(true)); +client.on((event) => assertType>(true)); +channel.on((event) => assertType>(true)); +assertType[0], Event>>(true); + +// --- A named type narrows to its members --------------------------------------------------------- + +client.on('message.new', (event) => + assertType>>(true), +); +channel.on('typing.start', (event) => + assertType>>(true), +); +client.on('connection.recovered', (event) => + assertType>>(true), +); +client.on('friendship_request', (event) => event.custom.text); + +// --- Undeclared names are rejected --------------------------------------------------------------- + +// @ts-expect-error -- removed in v10, never dispatched +client.on('connection.changed', () => undefined); +// @ts-expect-error -- a typo is not an event type +channel.on('message.nwe', () => undefined); +// @ts-expect-error -- a custom event has to be declared through `CustomEventTypes` +client.on('undeclared_custom', () => undefined); +// @ts-expect-error -- the spec's custom-event placeholder; nothing is dispatched under `'*'` +client.on('*', () => undefined); +// @ts-expect-error -- the spec's custom-event placeholder; nothing is dispatched under `'*'` +channel.on('*', () => undefined); +// @ts-expect-error -- `'*'` is not an event type +export const wildcardType: EventType = '*'; + +// --- off() takes the handler that was registered ------------------------------------------------- + +const onMessageNew: EventHandler<'message.new'> = (event) => event.message; +client.on('message.new', onMessageNew); +client.off('message.new', onMessageNew); +channel.on('message.new', onMessageNew); +channel.off('message.new', onMessageNew); +// @ts-expect-error -- registered for a different type +client.off('message.updated', onMessageNew); +// @ts-expect-error -- removed in v10, never dispatched +client.off('connection.changed', () => undefined); diff --git a/test/types/request-date-filters.ts b/test/types/request-date-filters.ts new file mode 100644 index 0000000000..5070652807 --- /dev/null +++ b/test/types/request-date-filters.ts @@ -0,0 +1,36 @@ +/** + * Compile-time contract for the `created_at_*` filters of the ban and member queries. Type-checked, + * never executed. + * + * They are request fields, sent as JSON, so they take a `Date` (serialized as RFC3339) or an RFC3339 + * string like every other request date. The API rejects a nanosecond number there + * ("Time.UnmarshalJSON: input is not a JSON string"), so a `TimestampNS` must not compile. Every + * `@ts-expect-error` is an assertion: when the line below it stops being an error, tsc fails on the + * unused directive. + */ +import type { Channel, StreamChat } from 'stream-chat'; +import { msToNs } from 'stream-chat'; + +declare const client: StreamChat; +declare const channel: Channel; + +const since = new Date('2025-01-01T00:00:00.000Z'); + +client.queryBannedUsers({ + payload: { filter_conditions: {}, created_at_after: since }, +}); +client.queryBannedUsers({ + payload: { filter_conditions: {}, created_at_before_or_equal: '2025-01-01T00:00:00Z' }, +}); +client.queryFutureChannelBans({ + payload: { created_at_after_or_equal: since, created_at_before: since }, +}); +channel.queryMembers({ payload: { created_at_after: since } }); +channel.queryMembers({ payload: { created_at_before: '2025-01-01T00:00:00Z' } }); + +client.queryBannedUsers({ + // @ts-expect-error -- a nanosecond timestamp is rejected by the API + payload: { filter_conditions: {}, created_at_after: msToNs(Date.now()) }, +}); +// @ts-expect-error -- a nanosecond timestamp is rejected by the API +channel.queryMembers({ payload: { created_at_before: msToNs(Date.now()) } }); diff --git a/test/types/timestamps.ts b/test/types/timestamps.ts index 6ad7a6dc4a..86089d572f 100644 --- a/test/types/timestamps.ts +++ b/test/types/timestamps.ts @@ -61,16 +61,19 @@ export const minted: TimestampNS[] = [ asTimestampNS(0), ]; -// --- `pinMessage`: a `number` is a relative offset in seconds ---------------------------------------- +// --- `pinMessage`: dates are a `Date` or an RFC3339 string -------------------------------------------- -// @ts-expect-error -- a server timestamp would be read as ~1.79e18 seconds from now -client.pinMessage(message.id, message.pin_expires); -// @ts-expect-error -- same for `pinnedAt` -client.pinMessage(message.id, null, message.pinned_at); +// @ts-expect-error -- a server timestamp has to go through `nsToDate` +client.pinMessage(message, { pin_expires: message.pin_expires }); +// @ts-expect-error -- same for `pinned_at` +client.pinMessage(message, { pinned_at: message.pinned_at }); +// @ts-expect-error -- a relative offset in seconds is not accepted +client.pinMessage(message, { pin_expires: 60 }); -client.pinMessage(message.id); -client.pinMessage(message, null); -client.pinMessage(message.id, 60); -client.pinMessage(message.id, null, -60); -client.pinMessage(message.id, nsToDate(message.created_at), '2026-01-01T00:00:00Z'); -client.pinMessage(message.id, untyped, untyped); +client.pinMessage(message); +client.pinMessage({ id: message.id }, { pin_expires: null }); +client.pinMessage(message, { + pin_expires: nsToDate(message.created_at), + pinned_at: '2026-01-01T00:00:00Z', +}); +client.pinMessage(message, { pin_expires: untyped, pinned_at: untyped }); diff --git a/test/unit/ConnectionRecoveryManager.test.ts b/test/unit/ConnectionRecoveryManager.test.ts index 3de0776211..0749ab2392 100644 --- a/test/unit/ConnectionRecoveryManager.test.ts +++ b/test/unit/ConnectionRecoveryManager.test.ts @@ -508,20 +508,42 @@ describe('ConnectionRecoveryManager', () => { }); describe('connectionRecovery.enabled: false', () => { - it('recovers nothing', async () => { - // The kill switch is declarative configuration now, read when a recovery actually runs — so it - // is honoured from the next reconnect without the manager being re-wired. + // The kill switch is declarative configuration, read on every reconnect — so it is honoured from + // the next one without the manager being re-wired. + const disableRecovery = () => client.config.set({ client: { connectionRecovery: { enabled: false } } }); + + it('recovers nothing on a reconnect and dispatches no connection.recovered', async () => { + disableRecovery(); const { reload } = activeChannel('active'); const recoverLists = vi .spyOn(client.channelManager, 'recover') .mockResolvedValue([]); + const recovered = vi.fn(); + client.on('connection.recovered', recovered); online(); - await client.connectionRecovery.recover(); + await new Promise((resolve) => setTimeout(resolve, 20)); expect(reload).not.toHaveBeenCalled(); expect(recoverLists).not.toHaveBeenCalled(); + expect(recovered).not.toHaveBeenCalled(); + }); + + it('runs a full recovery when recover() is called, and dispatches connection.recovered', async () => { + disableRecovery(); + const { reload } = activeChannel('active'); + const recoverLists = vi + .spyOn(client.channelManager, 'recover') + .mockResolvedValue([]); + const recovered = vi.fn(); + client.on('connection.recovered', recovered); + + await client.connectionRecovery.recover(); + + expect(reload).toHaveBeenCalledTimes(1); + expect(recoverLists).toHaveBeenCalledTimes(1); + expect(recovered).toHaveBeenCalledTimes(1); }); }); @@ -567,6 +589,22 @@ describe('ConnectionRecoveryManager', () => { expect(order).to.deep.equal(['executePendingTasks', 'sync', 'reload']); }); + it('stops reloading off the sync edge once recovery is switched off', async () => { + // The edge subscription is bound by the first reconnect and outlives it, so the switch has to + // be honoured there too, not only on the socket edge. + await attachDb(); + const { reload } = activeChannel('active'); + + online(); + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1)); + + client.config.set({ client: { connectionRecovery: { enabled: false } } }); + online(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(reload).toHaveBeenCalledTimes(1); + }); + it('still reloads when the sync fails', async () => { // The sync manager isolates replay and sync so the status edge is published either way. A // failed sync must degrade to possibly-stale data, never to a recovery that never happens. diff --git a/test/unit/MessageComposer/pollComposer.test.ts b/test/unit/MessageComposer/pollComposer.test.ts index 2682675642..84ea4ac307 100644 --- a/test/unit/MessageComposer/pollComposer.test.ts +++ b/test/unit/MessageComposer/pollComposer.test.ts @@ -260,7 +260,7 @@ describe('PollComposer', () => { const updateData = { name: 'Test Poll' }; const spy = vi.spyOn(pollComposer.stateMiddlewareExecutor, 'execute'); - await pollComposer.updateFields(updateData); + await pollComposer.updateFields({ data: updateData }); expect(spy).toHaveBeenCalledWith({ eventName: 'handleFieldChange', @@ -268,6 +268,24 @@ describe('PollComposer', () => { }); }); + it('passes the data and injected errors to the middleware', async () => { + const spy = vi.spyOn(pollComposer.stateMiddlewareExecutor, 'execute'); + const injectedFieldErrors = { max_votes_allowed: 'Too many' }; + + await pollComposer.updateFields({ + data: { max_votes_allowed: '20' }, + injectedFieldErrors, + }); + + expect(spy).toHaveBeenCalledWith({ + eventName: 'handleFieldChange', + initialValue: expect.objectContaining({ + injectedFieldErrors, + targetFields: { max_votes_allowed: '20' }, + }), + }); + }); + it('should not update state if middleware returns discard status', async () => { const updateData = { name: 'Test Poll' }; const originalState = pollComposer.state.getLatestValue(); @@ -281,7 +299,7 @@ describe('PollComposer', () => { status: 'discard', }); - await pollComposer.updateFields(updateData); + await pollComposer.updateFields({ data: updateData }); // Check that the state wasn't updated expect(pollComposer.state.getLatestValue()).toEqual(originalState); diff --git a/test/unit/MessageComposer/textComposer.test.ts b/test/unit/MessageComposer/textComposer.test.ts index 8caea8d11a..1ee1283749 100644 --- a/test/unit/MessageComposer/textComposer.test.ts +++ b/test/unit/MessageComposer/textComposer.test.ts @@ -122,7 +122,6 @@ describe('TextComposer', () => { mentions: [], selection: { start: 0, end: 0 }, text: '', - typing: {}, }); }); @@ -135,7 +134,6 @@ describe('TextComposer', () => { mentions: [], selection: { start: defaultValue.length, end: defaultValue.length }, text: defaultValue, - typing: {}, }); }); @@ -281,7 +279,6 @@ describe('TextComposer', () => { mentions: [], selection: { start: 0, end: 0 }, text: '', - typing: {}, }; const { messageComposer: { textComposer }, diff --git a/test/unit/api-client.test.ts b/test/unit/api-client.test.ts index b8c3fb10ac..8609376c78 100644 --- a/test/unit/api-client.test.ts +++ b/test/unit/api-client.test.ts @@ -1,6 +1,6 @@ import type { AxiosRequestConfig } from 'axios'; import { AxiosError } from 'axios'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { requiresConnectionId } from '../../src/api-client'; import { isAlreadyExistsError, parseError } from '../../src/errors'; @@ -97,8 +97,17 @@ describe('ApiClient request options', () => { it('keeps the same signal across retries', async () => { const controller = new AbortController(); + client.tokenManager.tokenProvider = async () => 'fresh-token'; + client.tokenManager.type = 'provider'; requestSpy.mockRejectedValueOnce( - Object.assign(new AxiosError('rate limited'), { status: 429 }), + Object.assign(new AxiosError('token expired'), { + response: { + data: { code: 40, message: 'token expired' }, + headers: {}, + status: 401, + }, + status: 401, + }), ); await sendRequest({ signal: controller.signal }); @@ -268,16 +277,93 @@ describe('ApiClient error wrapping', () => { expect(isAlreadyExistsError(error, parseError(error))).toBe(true); }); - it('retries a rate-limited request from another axios copy', async () => { - requestSpy - .mockRejectedValueOnce( - foreignAxiosError(429, { code: 9, message: 'Too many requests' }), - ) - .mockResolvedValueOnce({ data: {}, status: 200, headers: {} }); + it('does not retry a rate-limited request and rejects with the API error', async () => { + const rateLimited = { code: 9, message: 'Too many requests', StatusCode: 429 }; + requestSpy.mockRejectedValue(foreignAxiosError(429, rateLimited)); - await client.api.sendRequest('GET', '/api/v2/chat/channels'); + const error = await client.api + .sendRequest('GET', '/api/v2/chat/channels') + .catch((e: unknown) => e); - expect(requestSpy).toHaveBeenCalledTimes(2); + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(error).toBeInstanceOf(StreamAPIError); + expect(error).toMatchObject({ code: 9, message: rateLimited.message, status: 429 }); + }); + + it('does not retry TOKEN_EXPIRED for a static token', async () => { + const tokenExpired = { code: 40, message: 'Token has expired', StatusCode: 401 }; + client.tokenManager.type = 'static'; + const loadTokenSpy = vi.spyOn(client.tokenManager, 'loadToken'); + requestSpy.mockRejectedValue(foreignAxiosError(401, tokenExpired)); + + const error = await client.api + .sendRequest('GET', '/api/v2/chat/channels') + .catch((e: unknown) => e); + + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(loadTokenSpy).not.toHaveBeenCalled(); + expect(error).toBeInstanceOf(StreamAPIError); + expect(error).toMatchObject({ code: 40, message: tokenExpired.message, status: 401 }); + }); + + describe('when TOKEN_EXPIRED retries run out', () => { + const tokenExpired = { code: 40, message: 'Token has expired', StatusCode: 401 }; + + beforeEach(() => { + vi.useFakeTimers(); + client.tokenManager.tokenProvider = async () => 'fresh-token'; + client.tokenManager.type = 'provider'; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('rejects with the API error of the last attempt', async () => { + requestSpy.mockRejectedValue(foreignAxiosError(401, tokenExpired)); + + const settled = client.api + .sendRequest('GET', '/api/v2/chat/channels') + .catch((e: unknown) => e); + await vi.runAllTimersAsync(); + const error = await settled; + + // the first request plus 10 retries + expect(requestSpy).toHaveBeenCalledTimes(11); + expect(error).toBeInstanceOf(StreamAPIError); + expect(error).toMatchObject({ + code: 40, + message: tokenExpired.message, + status: 401, + }); + expect((error as StreamAPIError).response?.status).toBe(401); + }); + + it('rejects with the error of the last attempt when the failure changes', async () => { + requestSpy.mockRejectedValue(foreignAxiosError(401, tokenExpired)); + requestSpy.mockRejectedValueOnce( + foreignAxiosError(401, { ...tokenExpired, message: 'first attempt' }), + ); + + const settled = client.api + .sendRequest('GET', '/api/v2/chat/channels') + .catch((e: unknown) => e); + await vi.runAllTimersAsync(); + + expect(await settled).toMatchObject({ code: 40, message: tokenExpired.message }); + }); + + it('resolves when a retry succeeds', async () => { + requestSpy + .mockRejectedValueOnce(foreignAxiosError(401, tokenExpired)) + .mockResolvedValueOnce({ data: { ok: true }, status: 200, headers: {} }); + + const settled = client.api.sendRequest('GET', '/api/v2/chat/channels'); + await vi.runAllTimersAsync(); + + expect(await settled).toMatchObject({ ok: true }); + expect(requestSpy).toHaveBeenCalledTimes(2); + }); }); it('rethrows an axios error with no Stream error body unwrapped', async () => { diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index d10c940d5f..2e43afde49 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -1392,6 +1392,26 @@ describe('Channel _handleChannelEvent', function () { expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; }); + it('message.updated applies an edit of a reply shown in the channel', function () { + const parentMessage = generateMsg({ id: 'shown-reply-parent-id' }); + const shownReply = generateMsg({ + id: 'shown-reply-id', + parent_id: parentMessage.id, + show_in_channel: true, + text: 'before update', + }); + seedLatestWindow(channel, [parentMessage, shownReply]); + + channel._handleChannelEvent({ + type: 'message.updated', + message: { ...shownReply, text: 'after update' }, + }); + + expect(channel.messagePaginator.getItem(shownReply.id)?.text).to.equal( + 'after update', + ); + }); + it('message.updated syncs quoted_message references in messagePaginator', function () { const quotedMessage = generateMsg({ id: 'quoted-message-id', @@ -1501,6 +1521,26 @@ describe('Channel _handleChannelEvent', function () { expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; }); + it('message.undeleted restores a reply shown in the channel', function () { + const parentMessage = generateMsg({ id: 'shown-reply-parent-id-2' }); + const shownReply = generateMsg({ + id: 'shown-reply-id-2', + parent_id: parentMessage.id, + show_in_channel: true, + text: 'restored reply', + }); + seedLatestWindow(channel, [ + parentMessage, + { ...shownReply, type: 'deleted', text: '' }, + ]); + + channel._handleChannelEvent({ type: 'message.undeleted', message: shownReply }); + + const stored = channel.messagePaginator.getItem(shownReply.id); + expect(stored?.type).to.equal(shownReply.type); + expect(stored?.text).to.equal('restored reply'); + }); + it('message.undeleted syncs quoted_message references in messagePaginator', function () { const quotedMessage = generateMsg({ id: 'quoted-message-id-undeleted', @@ -5033,3 +5073,68 @@ describe('Channel.updateAIState', () => { ); }); }); + +describe('Channel member, filter-tag and upload-delete requests', () => { + let channel; + let update; + + beforeEach(async () => { + const client = await getClientWithUser({ id: 'user' }); + channel = client.channel('messaging', 'requests'); + update = vi.spyOn(channel, 'update').mockResolvedValue({}); + }); + + it('addMembers turns user ids into member requests and passes the options through', async () => { + await channel.addMembers({ + members: ['u1', { user_id: 'u2', channel_role: 'channel_moderator' }], + message: { text: 'welcome' }, + hide_history: true, + }); + + expect(update).toHaveBeenCalledWith( + { + add_members: [ + { user_id: 'u1' }, + { user_id: 'u2', channel_role: 'channel_moderator' }, + ], + message: { text: 'welcome' }, + hide_history: true, + }, + undefined, + ); + }); + + it('inviteMembers turns user ids into member requests', async () => { + await channel.inviteMembers({ members: ['u1'] }); + + expect(update).toHaveBeenCalledWith({ invites: [{ user_id: 'u1' }] }, undefined); + }); + + it('removeMembers sends the user ids', async () => { + await channel.removeMembers({ members: ['u1'], message: { text: 'bye' } }); + + expect(update).toHaveBeenCalledWith( + { remove_members: ['u1'], message: { text: 'bye' } }, + undefined, + ); + }); + + it('addFilterTags / removeFilterTags send the tags', async () => { + await channel.addFilterTags({ tags: ['a'] }); + await channel.removeFilterTags({ tags: ['b'] }); + + expect(update).toHaveBeenNthCalledWith(1, { add_filter_tags: ['a'] }, undefined); + expect(update).toHaveBeenNthCalledWith(2, { remove_filter_tags: ['b'] }, undefined); + }); + + it('deleteImage / deleteFile take the url in a request object', async () => { + const deleteImage = vi.spyOn(channel, 'deleteChannelImage').mockResolvedValue({}); + const deleteFile = vi.spyOn(channel, 'deleteChannelFile').mockResolvedValue({}); + + await channel.deleteImage({ url: 'https://cdn/image.png' }); + await channel.deleteFile({ url: 'https://cdn/file.pdf' }); + + expect(deleteImage).toHaveBeenCalledWith({ url: 'https://cdn/image.png' }, undefined); + expect(deleteFile).toHaveBeenCalledWith({ url: 'https://cdn/file.pdf' }, undefined); + }); +}); diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index 1698f8dc20..49cd3fae05 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -299,7 +299,7 @@ describe('ChannelState typing store', () => { expect(state.getLatestValue()).to.deep.include({ typing: {} }); }); - it('keeps typing store and textComposer typing in sync via setTypingEvent/removeTypingEvent', () => { + it('tracks typing in the store via setTypingEvent/removeTypingEvent', () => { const client = new StreamChat(); const channel = new Channel(client, 'type', 'id', {}); const state = channel.state; @@ -312,13 +312,66 @@ describe('ChannelState typing store', () => { expect(state.typing).to.have.property('alice'); expect(state.getLatestValue().typing).to.have.property('alice'); - expect(channel.messageComposer.textComposer.typing).to.have.property('alice'); + expect( + channel.messageComposer.textComposer.state.getLatestValue(), + ).to.not.have.property('typing'); state.removeTypingEvent('alice'); expect(state.typing).to.not.have.property('alice'); expect(state.getLatestValue().typing).to.not.have.property('alice'); - expect(channel.messageComposer.textComposer.typing).to.not.have.property('alice'); + }); + + describe('across text composer resets', () => { + let channel; + let composer; + + const typingStart = (userId, receivedAtMs = Date.now()) => ({ + type: 'typing.start', + user: { id: userId }, + received_at: msToNs(receivedAtMs), + }); + const typerIds = () => Object.keys(channel.state.typing).sort(); + + beforeEach(() => { + const client = new StreamChat(); + client.user = { id: 'observer' }; + channel = new Channel(client, 'type', 'id', {}); + composer = channel.messageComposer; + channel.state.setTypingEvent('bob', typingStart('bob')); + channel.state.setTypingEvent('carol', typingStart('carol')); + }); + + it('keeps the typers when the composer is cleared', () => { + composer.clear(); + + expect(typerIds()).to.eql(['bob', 'carol']); + }); + + it('removes a typer who stops after the composer was cleared', () => { + composer.clear(); + channel.state.removeTypingEvent('carol'); + + expect(typerIds()).to.eql(['bob']); + }); + + it('prunes a stale typer after the composer was cleared', () => { + channel.state.setTypingEvent('carol', typingStart('carol', Date.now() - 10000)); + composer.clear(); + + channel.state.clean(); + + expect(typerIds()).to.eql(['bob']); + }); + + it('does not bring back a stopped typer when a composer snapshot is restored', () => { + const snapshot = composer.getSnapshot(); + channel.state.removeTypingEvent('carol'); + + composer.restoreSnapshot(snapshot); + + expect(typerIds()).to.eql(['bob']); + }); }); }); diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 75c291785f..0359bd797a 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -122,7 +122,7 @@ describe('StreamChat getInstance', () => { // `getApp` has no request, so `requestOptions` comes after an unused `_request` slot; nothing // from it may reach the query string. - it('getAppSettings forwards requestOptions passed after the empty request slot', async () => { + it('getAppSettings forwards its requestOptions', async () => { const client = new StreamChat('key'); client.tokenManager.getToken = () => 'mock-token'; const controller = new AbortController(); @@ -131,7 +131,7 @@ describe('StreamChat getInstance', () => { .spyOn(client.axiosInstance, 'request') .mockResolvedValueOnce({ data: {}, status: 200 }); - await client.getAppSettings(undefined, { signal: controller.signal, timeout: 1234 }); + await client.getAppSettings({ signal: controller.signal, timeout: 1234 }); expect(requestSpy).toHaveBeenCalledTimes(1); const config = requestSpy.mock.calls[0][0]; @@ -1648,7 +1648,7 @@ describe('StreamChat.queryThreads', () => { const { threads: [requeried], } = await client.queryThreadsAndHydrate(); - const fetched = await client.getThreadAndHydrate(parentMessage.id); + const fetched = await client.getThreadAndHydrate({ message_id: parentMessage.id }); expect(requeried).to.equal(registered); expect(fetched).to.equal(registered); @@ -2681,42 +2681,114 @@ describe('channel store eviction when the current user is removed (#2599)', () = }); }); -describe('_normalizeExpiration', () => { +describe('Client pinMessage / unpinMessage', () => { let client; + let updateMessagePartial; beforeEach(async () => { client = await getClientWithUser({ id: 'user' }); + updateMessagePartial = vi.spyOn(client, 'updateMessagePartial').mockResolvedValue({}); }); - it('reads a number as an offset in seconds', () => { - const before = Date.now(); - const iso = client._normalizeExpiration(3600); - const offsetMs = new Date(iso).getTime() - before; - expect(offsetMs).toBeGreaterThanOrEqual(3600 * 1000 - 1000); - expect(offsetMs).toBeLessThanOrEqual(3600 * 1000 + 1000); + it('pins without expiry at the current time by default', async () => { + await client.pinMessage({ id: 'm1' }); + + expect(updateMessagePartial).toHaveBeenCalledWith( + { id: 'm1' }, + { set: { pinned: true, pin_expires: null, pinned_at: null } }, + undefined, + ); + }); + + it('sends the given expiry and pin time', async () => { + const pinExpires = new Date('2077-01-01T00:00:00.000Z'); + + await client.pinMessage( + { id: 'm1' }, + { pin_expires: pinExpires, pinned_at: '2026-01-01T00:00:00Z' }, + ); + + expect(updateMessagePartial).toHaveBeenCalledWith( + { id: 'm1' }, + { + set: { pinned: true, pin_expires: pinExpires, pinned_at: '2026-01-01T00:00:00Z' }, + }, + undefined, + ); + }); + + it('unpins', async () => { + await client.unpinMessage({ id: 'm1' }); + + expect(updateMessagePartial).toHaveBeenCalledWith( + { id: 'm1' }, + { set: { pinned: false } }, + undefined, + ); + }); + + it('rejects a message without an id', () => { + expect(() => client.pinMessage({})).toThrow(/specify the message id/); + expect(() => client.unpinMessage({})).toThrow(/specify the message id/); + }); +}); + +describe('Client moderation flagMessage / flagUser', () => { + let client; + let flag; + + beforeEach(async () => { + client = await getClientWithUser({ id: 'user' }); + flag = vi.spyOn(client.moderation, 'flag').mockResolvedValue({}); }); - it.each([ - ['a Date', new Date('2026-12-25T00:00:00.000Z'), '2026-12-25T00:00:00.000Z'], - ['a string', '2026-12-25T00:00:00.000Z', '2026-12-25T00:00:00.000Z'], - ['null', null, null], - ['undefined', undefined, null], - ])('passes %s through', (_label, input, expected) => { - expect(client._normalizeExpiration(input)).toBe(expected); + it('flags a message by id, with the reason and options', async () => { + await client.moderation.flagMessage({ + message_id: 'm1', + reason: 'spam', + custom: { note: 'x' }, + }); + + expect(flag).toHaveBeenCalledWith( + { + entity_type: 'stream:chat:v1:message', + entity_id: 'm1', + reason: 'spam', + custom: { note: 'x' }, + }, + undefined, + ); }); - // A wire timestamp (~1.79e18) added as seconds leaves `Date`'s range. That used to throw an - // opaque `RangeError` from `toISOString()`, as an unhandled rejection inside `pinMessage`. - it.each([ - ['a wire nanosecond timestamp', 1798156800000000000], - ['NaN', Number.NaN], - ])('throws an actionable error for %s', (_label, input) => { - expect(() => client._normalizeExpiration(input)).toThrow( - /does not resolve to a valid date/, + it('flags a user by id', async () => { + await client.moderation.flagUser({ user_id: 'u1' }); + + expect(flag).toHaveBeenCalledWith( + { entity_type: 'stream:user', entity_id: 'u1', reason: undefined }, + undefined, ); }); }); +describe('Client polls.queryPolls', () => { + it('sends the request as client.queryPolls does', async () => { + const client = await getClientWithUser({ id: 'user' }); + const queryPolls = vi + .spyOn(client, 'queryPolls') + .mockResolvedValue({ polls: [], next: 'next-page' }); + const request = { + filter: { is_closed: true }, + sort: [{ field: 'created_at', direction: -1 }], + limit: 5, + }; + + const { next } = await client.polls.queryPolls(request); + + expect(queryPolls).toHaveBeenCalledWith(request, undefined); + expect(next).toBe('next-page'); + }); +}); + describe('Client WSFallback', () => { const userToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYW1pbiJ9.1R88K_f1CC2yrR6j1_OzMEbasfS_dxRSNbundEDBlJI'; diff --git a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts index c980786940..db61653075 100644 --- a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts +++ b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts @@ -947,4 +947,62 @@ describe('MessageDeliveryReporter', () => { vi.advanceTimersByTime(1000); expect(spy).toHaveBeenCalledTimes(1); // no extra trailing for a solitary call }); + + describe('throttles each collection separately', () => { + const markedCollections = (spy: { mock: { calls: unknown[][] } }) => + spy.mock.calls.map(([collection]) => collection); + + it('marks a thread read when its channel is marked inside the same window', () => { + const reporter = client.messageDeliveryReporter; + const spy = vi.spyOn(reporter, 'markRead').mockResolvedValue(null); + const thread = new Thread({ + client, + threadData: generateThreadResponse( + { type: channelType, id: channelId, cid: channel.cid }, + mkMsg('parent', 1000), + ), + }); + + reporter.throttledMarkRead(channel); + reporter.throttledMarkRead(thread); + reporter.throttledMarkRead(channel); + vi.advanceTimersByTime(1000); + + expect(markedCollections(spy)).toEqual([channel, thread, channel]); + }); + + it('marks another channel read when the first is marked again inside the same window', () => { + const reporter = client.messageDeliveryReporter; + const spy = vi.spyOn(reporter, 'markRead').mockResolvedValue(null); + const otherChannel = client.channelManager.ensure({ + type: channelType, + id: 'other-channel', + }); + + reporter.throttledMarkRead(channel); + reporter.throttledMarkRead(otherChannel); + reporter.throttledMarkRead(channel); + vi.advanceTimersByTime(1000); + + expect(markedCollections(spy)).toEqual([channel, otherChannel, channel]); + }); + + it('still throttles repeated calls for one collection', () => { + const reporter = client.messageDeliveryReporter; + const spy = vi.spyOn(reporter, 'markRead').mockResolvedValue(null); + const otherChannel = client.channelManager.ensure({ + type: channelType, + id: 'other-channel', + }); + + reporter.throttledMarkRead(channel); + reporter.throttledMarkRead(otherChannel); + reporter.throttledMarkRead(channel); + reporter.throttledMarkRead(channel); + + expect(markedCollections(spy)).toEqual([channel, otherChannel]); + vi.advanceTimersByTime(1000); + expect(markedCollections(spy)).toEqual([channel, otherChannel, channel]); + }); + }); }); diff --git a/test/unit/offline-support/offline_support_api.test.ts b/test/unit/offline-support/offline_support_api.test.ts index 54a714cc73..d076a91850 100644 --- a/test/unit/offline-support/offline_support_api.test.ts +++ b/test/unit/offline-support/offline_support_api.test.ts @@ -2877,22 +2877,17 @@ describe('OfflineSupportApi', () => { expect(deleteReactionSpy).toHaveBeenCalledWith(...task.payload); }); - // `deleteReaction` has no request of its own, so its `requestOptions` sits behind an unused - // `_request` slot. That slot persists as `null` and has to come back as `undefined`, with - // the options still in third position. - it('replays a persisted delete-reaction with requestOptions behind the empty request slot', async () => { + // `deleteReaction` has no request of its own, so its `requestOptions` directly follow the + // path params and have to survive the JSON round trip in that position. + it('replays a persisted delete-reaction with its requestOptions', async () => { const task = generatePendingTask('delete-reaction') as PendingTask; const persisted = JSON.parse( - JSON.stringify({ - ...task, - payload: [task.payload[0], undefined, { timeout: 5000 }], - }), + JSON.stringify({ ...task, payload: [task.payload[0], { timeout: 5000 }] }), ) as PendingTask; - expect(persisted.payload).toEqual([task.payload[0], null, { timeout: 5000 }]); await offlineDb['executeTask']({ task: persisted }); - expect(deleteReactionSpy).toHaveBeenCalledWith(task.payload[0], undefined, { + expect(deleteReactionSpy).toHaveBeenCalledWith(task.payload[0], { timeout: 5000, }); }); diff --git a/test/unit/pagination/paginators/BasePaginator.test.ts b/test/unit/pagination/paginators/BasePaginator.test.ts index 1ce6ef3291..4b98ee5dbc 100644 --- a/test/unit/pagination/paginators/BasePaginator.test.ts +++ b/test/unit/pagination/paginators/BasePaginator.test.ts @@ -1082,7 +1082,8 @@ describe('BasePaginator', () => { paginator.queryReject(error); // hand over to finish the cleanup and state update after the query execution await toNextTick(); - expect(paginator.lastQueryError).toEqual(error); + // a retry is pending, so the failed attempt is not the query's error + expect(paginator.lastQueryError).toBeUndefined(); vi.advanceTimersByTime(DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES); await toNextTick(); @@ -1144,18 +1145,46 @@ describe('BasePaginator', () => { vi.useRealTimers(); }); + it('publishes lastQueryError once, after the last attempt fails', async () => { + vi.useFakeTimers(); + const retryCount = 3; + const paginator = new Paginator({ initialCursor: ZERO_PAGE_CURSOR, retryCount }); + const published: Array = []; + const unsubscribe = paginator.state.subscribeWithSelector( + ({ lastQueryError }) => ({ lastQueryError }), + ({ lastQueryError }) => published.push(lastQueryError), + ); + const promise = paginator.toTail(); + + await failEveryAttempt(paginator, retryCount + 1); + await promise; + unsubscribe(); + + // the initial value, then the error once + expect(published).toEqual([undefined, new Error('Failed')]); + vi.useRealTimers(); + }); + it('stops retrying as soon as an attempt succeeds', async () => { vi.useFakeTimers(); const paginator = new Paginator({ initialCursor: ZERO_PAGE_CURSOR, retryCount: 3 }); const promise = paginator.toTail(); + const published: Array = []; + const unsubscribe = paginator.state.subscribeWithSelector( + ({ lastQueryError }) => ({ lastQueryError }), + ({ lastQueryError }) => published.push(lastQueryError), + ); await failEveryAttempt(paginator, 1); await toNextTick(); paginator.queryResolve({ items: [{ id: 'id1' }] }); await promise; + unsubscribe(); expect(paginator.mockClientQuery).toHaveBeenCalledTimes(2); expect(paginator.lastQueryError).toBeUndefined(); + // a failure the retry recovered from never surfaces + expect(published).not.toContainEqual(new Error('Failed')); expect(paginator.items).toEqual([{ id: 'id1' }]); vi.useRealTimers(); }); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 675302fda9..01d9624213 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -1431,6 +1431,98 @@ describe('MessagePaginator', () => { deletedAt.toISOString(), ); }); + + describe('after a jump to an older message', () => { + const bannedUser = { id: 'banned-user' }; + const otherUser = { id: 'other-user' }; + const at = (minute: number) => + convertDateToTimestamp( + `2025-02-01T14:${String(minute).padStart(2, '0')}:00.000Z`, + ); + + // The head interval holds the banned user's newest message and a quote of it; the viewer + // has jumped to a disjoint older interval, so neither is in `items`. + const setupJumpedAway = () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + const bannedHeadMessage = createMessage({ + cid: channel.cid, + created_at: at(50), + id: 'banned-head', + user: bannedUser, + }); + const headQuoteCarrier = createMessage({ + cid: channel.cid, + created_at: at(51), + id: 'head-quote-carrier', + quoted_message: bannedHeadMessage, + quoted_message_id: bannedHeadMessage.id, + user: otherUser, + }); + paginator.ingestPage({ + page: [bannedHeadMessage, headQuoteCarrier], + isHead: true, + isTail: false, + setActive: true, + }); + paginator.ingestPage({ + page: [ + createMessage({ + cid: channel.cid, + created_at: at(1), + id: 'old-1', + user: otherUser, + }), + createMessage({ + cid: channel.cid, + created_at: at(2), + id: 'old-2', + user: otherUser, + }), + ], + isHead: false, + isTail: false, + setActive: true, + }); + expect(paginator.items?.map((m) => m.id)).toEqual(['old-1', 'old-2']); + return { bannedHeadMessage, headQuoteCarrier, paginator }; + }; + + it('soft deletes the user messages in the head interval', () => { + const { bannedHeadMessage, headQuoteCarrier, paginator } = setupJumpedAway(); + + paginator.applyMessageDeletionForUser({ + userId: bannedUser.id, + hardDelete: false, + deletedAt: at(55), + }); + + expect(paginator.getItem(bannedHeadMessage.id)?.type).toBe('deleted'); + expect(paginator.getItem(headQuoteCarrier.id)?.quoted_message?.type).toBe( + 'deleted', + ); + expect( + paginator.headItems?.find((m) => m.id === bannedHeadMessage.id)?.type, + ).toBe('deleted'); + expect(paginator.items?.map((m) => m.id)).toEqual(['old-1', 'old-2']); + }); + + it('hard deletes the user messages in the head interval', () => { + const { bannedHeadMessage, headQuoteCarrier, paginator } = setupJumpedAway(); + + paginator.applyMessageDeletionForUser({ + userId: bannedUser.id, + hardDelete: true, + deletedAt: at(55), + }); + + expect(paginator.getItem(bannedHeadMessage.id)).toBeUndefined(); + expect(paginator.headItems?.map((m) => m.id)).toEqual([headQuoteCarrier.id]); + expect(paginator.getItem(headQuoteCarrier.id)?.quoted_message?.type).toBe( + 'deleted', + ); + expect(paginator.items?.map((m) => m.id)).toEqual(['old-1', 'old-2']); + }); + }); }); describe('reflectQuotedMessageUpdate()', () => { diff --git a/test/unit/pagination/paginators/ReminderPaginator.test.ts b/test/unit/pagination/paginators/ReminderPaginator.test.ts index 538fee9f9d..127977c18e 100644 --- a/test/unit/pagination/paginators/ReminderPaginator.test.ts +++ b/test/unit/pagination/paginators/ReminderPaginator.test.ts @@ -74,6 +74,34 @@ describe('ReminderPaginator', () => { expect(paginator.cursor?.tailward).toBeNull(); }); + it('keeps the loaded reminders while the next page loads', async () => { + const paginator = new ReminderPaginator(client, { pageSize: 2 }); + const spy = vi.spyOn(client, 'queryReminders'); + spy.mockResolvedValueOnce( + response( + [ + makeReminder('m1', '2020-01-01T00:00:00.000Z'), + makeReminder('m2', '2020-01-02T00:00:00.000Z'), + ], + { next: 'c1' }, + ), + ); + await paginator.executeQuery({}); + + const published: Array = []; + const unsubscribe = paginator.state.subscribeWithSelector( + ({ items }) => ({ items }), + ({ items }) => published.push(items?.map((r) => r.message_id)), + ); + spy.mockResolvedValueOnce(response([makeReminder('m3', '2020-01-03T00:00:00.000Z')])); + await paginator.toTail(); + unsubscribe(); + + // A next page is not a new query: the list must never be blanked while it loads. + expect(published).not.toContain(undefined); + expect(published.at(-1)).toEqual(['m1', 'm2', 'm3']); + }); + it('sends the tailward cursor as the request `next`', async () => { const paginator = new ReminderPaginator(client, { pageSize: 2 }); const spy = vi.spyOn(client, 'queryReminders'); diff --git a/test/unit/poll_manager.test.ts b/test/unit/poll_manager.test.ts index 5afcecd44a..6d1da0cd50 100644 --- a/test/unit/poll_manager.test.ts +++ b/test/unit/poll_manager.test.ts @@ -644,7 +644,7 @@ describe('PollManager', () => { .resolves({ poll: pollMessage1.poll as PollResponse, duration: '10' }); }); it('should return a Poll instance on queryPolls', async () => { - const { polls } = await pollManager.queryPolls({}, {}, {}); + const { polls } = await pollManager.queryPolls(); expect(polls).to.have.lengthOf(2); polls.forEach((poll) => { @@ -653,7 +653,7 @@ describe('PollManager', () => { expect(stubbedQueryPolls.calledOnce).to.be.true; }); it('should properly populate the pollCache on queryPolls', async () => { - const { polls } = await pollManager.queryPolls({}, {}, {}); + const { polls } = await pollManager.queryPolls(); expect(pollManager.data.size).to.equal(2); expect(pollManager.fromState(pollId1)).to.not.be.undefined; @@ -673,7 +673,7 @@ describe('PollManager', () => { const previousPollFromCache = pollManager.fromState(pollId1); - const { polls } = await pollManager.queryPolls({}, {}, {}); + const { polls } = await pollManager.queryPolls(); const pollFromQuery = polls[0]; diff --git a/test/unit/publishAmplification.baseline.test.ts b/test/unit/publishAmplification.baseline.test.ts index 8ef7f95654..31ff310659 100644 --- a/test/unit/publishAmplification.baseline.test.ts +++ b/test/unit/publishAmplification.baseline.test.ts @@ -192,6 +192,22 @@ describe('publish amplification — one event, one publish per collection', () = expect(recorder.stateCounts()).toEqual(FLOOR); recorder.stop(); }); + + it('message.updated of a reply shown in the channel reaches every holder through the held thread', () => { + const { channel, client, held, recorder } = linked(); + const thread = client.threads.get(PARENT_ID); + + client.dispatchEvent({ + cid: channel.cid, + message: generateMsg({ ...held, id: 'seed', text: 'edited' }), + type: 'message.updated', + } as never); + + expect(channel.messagePaginator.getItem('seed')?.text).toBe('edited'); + expect(channel.pinnedMessagesPaginator.getItem('seed')?.text).toBe('edited'); + expect(thread?.messagePaginator.getItem('seed')?.text).toBe('edited'); + recorder.stop(); + }); }); describe('publish amplification — baseline (unthrottled)', () => { diff --git a/test/unit/search/MessageSearchSource.test.ts b/test/unit/search/MessageSearchSource.test.ts index fe896e1f80..a0e648012f 100644 --- a/test/unit/search/MessageSearchSource.test.ts +++ b/test/unit/search/MessageSearchSource.test.ts @@ -270,10 +270,7 @@ describe('MessageSearchSource', () => { }, limit: searchSource.pageSize, next: 'next-token-old', - sort: [ - { field: 'created_at', direction: -1 }, - { field: 'created_at', direction: 1 }, - ], + sort: [{ field: 'created_at', direction: 1 }], }), }, withoutSignal, @@ -322,16 +319,44 @@ describe('MessageSearchSource', () => { }, limit: searchSource.pageSize, next: 'next-token-old', - sort: [ - { field: 'created_at', direction: -1 }, - { field: 'created_at', direction: 1 }, - ], + sort: [{ field: 'created_at', direction: 1 }], }), }, withoutSignal, ); }); + describe('messageSearchSort', () => { + const sentSort = () => searchMock.mock.calls.at(-1)?.[0].payload.sort; + + it('keeps created_at descending as the primary order under a secondary key', async () => { + searchSource.messageSearchSort = [{ field: 'reply_count', direction: -1 }]; + + // @ts-expect-error protected access + await searchSource.query('hello'); + + expect(sentSort()).toEqual([ + { field: 'created_at', direction: -1 }, + { field: 'reply_count', direction: -1 }, + ]); + }); + + it('takes the direction of a created_at entry and keeps it primary', async () => { + searchSource.messageSearchSort = [ + { field: 'reply_count', direction: -1 }, + { field: 'created_at', direction: 1 }, + ]; + + // @ts-expect-error protected access + await searchSource.query('hello'); + + expect(sentSort()).toEqual([ + { field: 'created_at', direction: 1 }, + { field: 'reply_count', direction: -1 }, + ]); + }); + }); + it('overrides the message type', async () => { searchSource.messageSearchFilters = { type: 'deleted' }; searchSource.state.partialNext({ next: 'next-token-old' }); diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 5f7387c394..3fb0037949 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -3583,10 +3583,13 @@ describe('Threads 2.0', () => { thread: threadResponse(), } as never); - await client.getThreadAndHydrate(parentMessageResponse.id, { watch: false }); + await client.getThreadAndHydrate( + { message_id: parentMessageResponse.id }, + { watch: false }, + ); expect(channel.watchStatus).to.equal(ChannelWatchStatus.NotWatching); - await client.getThreadAndHydrate(parentMessageResponse.id); + await client.getThreadAndHydrate({ message_id: parentMessageResponse.id }); expect(channel.watchStatus).to.equal(ChannelWatchStatus.Watching); });