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