diff --git a/src/channel_manager.ts b/src/channel_manager.ts index 7e0322922..871599a20 100644 --- a/src/channel_manager.ts +++ b/src/channel_manager.ts @@ -1,10 +1,11 @@ -import type { StreamChat } from './client'; +import type { QueryChannelsResponseWithChannels, StreamChat } from './client'; import type { ChannelFilters, ChannelOptions, ChannelSort, ChannelStateOptions, Event, + QueryChannelsAPIResponse, } from './types'; import type { ValueOrPatch } from './store'; import { isPatch, StateStore } from './store'; @@ -34,6 +35,8 @@ export type ChannelManagerPagination = { isLoading: boolean; isLoadingNext: boolean; options: ChannelOptions; + responseFilters?: ChannelFilters; + responseSort?: ChannelSort; sort: ChannelSort; }; @@ -133,9 +136,14 @@ export type ChannelManagerOptions = { lockChannelOrder?: boolean; }; +export type QueryChannelsRequestOutput = Channel[] | QueryChannelsResponseWithChannels; + export type QueryChannelsRequestType = ( - ...params: Parameters -) => Promise; + filters: ChannelFilters, + sort?: ChannelSort, + options?: ChannelOptions, + stateOptions?: ChannelStateOptions, +) => Promise; export const DEFAULT_CHANNEL_MANAGER_OPTIONS = { abortInFlightQuery: false, @@ -152,6 +160,54 @@ export const DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS = { offset: 0, }; +const mapPredefinedFilterSortToChannelSort = ( + sort: NonNullable['sort'], +): ChannelSort => + (sort ?? []).map(({ direction = 1, field }) => ({ + [field]: direction, + })) as ChannelSort; + +const getResponsePaginationParams = ({ + queryChannelsResponse, + sort, +}: { + queryChannelsResponse?: Pick; + sort: ChannelSort; +}): Pick => { + const predefinedFilter = queryChannelsResponse?.predefined_filter; + + if (!predefinedFilter) { + return {}; + } + + return { + responseFilters: predefinedFilter.filter as ChannelFilters, + responseSort: + predefinedFilter.sort !== undefined + ? mapPredefinedFilterSortToChannelSort(predefinedFilter.sort) + : sort, + }; +}; + +const getResponseFiltersAndSort = ( + pagination: ChannelManagerPagination, +): Pick => ({ + filters: pagination.responseFilters ?? pagination.filters, + sort: pagination.responseSort ?? pagination.sort, +}); + +const omitResponsePaginationParams = (pagination: ChannelManagerPagination) => { + const paginationWithoutResponseParams = { ...pagination }; + delete paginationWithoutResponseParams.responseFilters; + delete paginationWithoutResponseParams.responseSort; + + return paginationWithoutResponseParams; +}; + +const isQueryChannelsResponseWithChannels = ( + response: QueryChannelsRequestOutput, +): response is QueryChannelsResponseWithChannels => !Array.isArray(response); + /** * A class that manages a list of channels and changes it based on configuration and WS events. The * list of channels is reactive as well as the pagination and it can be subscribed to for state updates. @@ -279,23 +335,39 @@ export class ChannelManager extends WithSubscriptions { ...options, }; try { - const channels = await this.queryChannelsRequest( + const queryChannelsResponse = await this.queryChannelsRequest( filters, sort, options, - stateOptions, + { ...stateOptions, withResponse: true }, ); + const channels = isQueryChannelsResponseWithChannels(queryChannelsResponse) + ? queryChannelsResponse.channels + : queryChannelsResponse; const newOffset = offset + (channels?.length ?? 0); const newOptions = { ...options, offset: newOffset }; const { pagination } = this.state.getLatestValue(); + const responsePaginationParams = getResponsePaginationParams({ + queryChannelsResponse: isQueryChannelsResponseWithChannels(queryChannelsResponse) + ? queryChannelsResponse + : undefined, + sort, + }); + const paginationWithoutResponseParams = omitResponsePaginationParams(pagination); this.state.partialNext({ channels, pagination: { - ...pagination, + // Drop response derived filter/sort from the previous query before applying + // the current response. Non predefined queries do not return this metadata, + // so keeping the old values would make later WS mutations use stale + // predefined filter semantics. Also the predefined_filter might change, producing + // a different combination as well so we always need to first clean up. + ...paginationWithoutResponseParams, hasNext: (channels?.length ?? 0) >= (limit ?? 1), isLoading: false, options: newOptions, + ...responsePaginationParams, }, initialized: true, error: undefined, @@ -368,7 +440,7 @@ export class ChannelManager extends WithSubscriptions { this.state.next((currentState) => ({ ...currentState, pagination: { - ...currentState.pagination, + ...omitResponsePaginationParams(currentState.pagination), isLoading: true, isLoadingNext: false, filters, @@ -434,12 +506,15 @@ export class ChannelManager extends WithSubscriptions { this.state.partialNext({ pagination: { ...pagination, isLoading: false, isLoadingNext: true }, }); - const nextChannels = await this.queryChannelsRequest( + const queryChannelsResponse = await this.queryChannelsRequest( filters, sort, options, this.stateOptions, ); + const nextChannels = isQueryChannelsResponseWithChannels(queryChannelsResponse) + ? queryChannelsResponse.channels + : queryChannelsResponse; const { channels } = this.state.getLatestValue(); const newOffset = offset + (nextChannels?.length ?? 0); const newOptions = { ...options, offset: newOffset }; @@ -498,7 +573,7 @@ export class ChannelManager extends WithSubscriptions { return; } - const { sort } = pagination ?? {}; + const { sort } = getResponseFiltersAndSort(pagination); this.setChannels( promoteChannel({ @@ -535,7 +610,7 @@ export class ChannelManager extends WithSubscriptions { if (!channels) { return; } - const { filters, sort } = pagination ?? {}; + const { filters, sort } = getResponseFiltersAndSort(pagination); const channelType = event.channel_type; const channelId = event.channel_id; @@ -594,7 +669,7 @@ export class ChannelManager extends WithSubscriptions { }); const { channels, pagination } = this.state.getLatestValue(); - const { filters, sort } = pagination ?? {}; + const { filters, sort } = getResponseFiltersAndSort(pagination); const considerArchivedChannels = shouldConsiderArchivedChannels(filters); const isTargetChannelArchived = isChannelArchived(channel); @@ -631,7 +706,7 @@ export class ChannelManager extends WithSubscriptions { }); const { channels, pagination } = this.state.getLatestValue(); - const { sort, filters } = pagination ?? {}; + const { filters, sort } = getResponseFiltersAndSort(pagination); const considerArchivedChannels = shouldConsiderArchivedChannels(filters); const isTargetChannelArchived = isChannelArchived(channel); @@ -658,7 +733,7 @@ export class ChannelManager extends WithSubscriptions { private memberUpdatedHandler = (event: Event) => { const { pagination, channels } = this.state.getLatestValue(); - const { filters, sort } = pagination; + const { filters, sort } = getResponseFiltersAndSort(pagination); if ( !event.member?.user || event.member.user.id !== this.client.userID || diff --git a/src/client.ts b/src/client.ts index 6d77bb3df..eb8fa66ea 100644 --- a/src/client.ts +++ b/src/client.ts @@ -281,6 +281,13 @@ function isString(x: unknown): x is string { type MessageComposerTearDownFunction = () => void; +export type QueryChannelsResponseWithChannels = Omit< + QueryChannelsAPIResponse, + 'channels' +> & { + channels: Channel[]; +}; + type MessageComposerSetupFunction = ({ composer, }: { @@ -1888,20 +1895,26 @@ export class StreamChat { } /** - * queryChannelsRequest - Queries channels and returns the raw response + * queryChannelsRequestWithResponse - Queries channels and returns the full API response + * including top-level metadata such as `predefined_filter`. + * + * This exists as a compatibility bridge, as changing `queryChannelsRequest()` to return + * `QueryChannelsAPIResponse` would be a breaking change because it currently returns + * only the channel list. In the next major release, the request/response APIs should + * be consolidated so callers can access the full response through the primary API. * * @param {ChannelFilters} filterConditions object MongoDB style filters. Can be empty object when using predefined_filter in options. * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}] * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters. * - * @return {Promise>} search channels response + * @return {Promise} full search channels response */ - async queryChannelsRequest( + async queryChannelsRequestWithResponse( filterConditions: ChannelFilters, sort: ChannelSort = [], options: ChannelOptions = {}, - ) { + ): Promise { const defaultOptions: ChannelOptions = { state: true, watch: true, @@ -1934,9 +1947,33 @@ export class StreamChat { ...restOptions, }; - const data = await this.post( - this.baseURL + '/channels', - payload, + return await this.post(this.baseURL + '/channels', payload); + } + + /** + * queryChannelsRequest - Queries channels and returns the raw channel response list. + * + * This preserves the historical return shape for backwards compatibility. Use + * `queryChannelsRequestWithResponse()` when response level metadata such as + * `predefined_filter` is needed. In the next major release these APIs should be + * consolidated into a single full-response API. + * + * @param {ChannelFilters} filterConditions object MongoDB style filters. Can be empty object when using predefined_filter in options. + * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}. + * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}] + * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters. + * + * @return {Promise>} search channels response + */ + async queryChannelsRequest( + filterConditions: ChannelFilters, + sort: ChannelSort = [], + options: ChannelOptions = {}, + ) { + const data = await this.queryChannelsRequestWithResponse( + filterConditions, + sort, + options, ); // FIXME: In the next major release, return the full QueryChannelsAPIResponse @@ -1955,16 +1992,34 @@ export class StreamChat { * @param {ChannelStateOptions} [stateOptions] State options object. These options will only be used for state management and won't be sent in the request. * - stateOptions.skipInitialization - Skips the initialization of the state for the channels matching the ids in the list. * - stateOptions.skipHydration - Skips returning the channels as instances of the Channel class and rather returns the raw query response. + * - stateOptions.withResponse - Returns the full query response with hydrated channels. This is a compatibility bridge for internal callers that need response-level metadata while the default return value remains `Channel[]`. * * @return {Promise>} search channels response */ + async queryChannels( + filterConditions: ChannelFilters, + sort: ChannelSort, + options: ChannelOptions, + stateOptions: ChannelStateOptions & { withResponse: true }, + ): Promise; + async queryChannels( + filterConditions?: ChannelFilters, + sort?: ChannelSort, + options?: ChannelOptions, + stateOptions?: ChannelStateOptions, + ): Promise; async queryChannels( filterConditions: ChannelFilters, sort: ChannelSort = [], options: ChannelOptions = {}, stateOptions: ChannelStateOptions = {}, - ) { - const channels = await this.queryChannelsRequest(filterConditions, sort, options); + ): Promise { + const queryChannelsResponse = await this.queryChannelsRequestWithResponse( + filterConditions, + sort, + options, + ); + const channels = queryChannelsResponse.channels; this.dispatchEvent({ type: 'channels.queried', @@ -1980,7 +2035,16 @@ export class StreamChat { }); } - return this.hydrateActiveChannels(channels, stateOptions, options); + const hydratedChannels = this.hydrateActiveChannels(channels, stateOptions, options); + + if (stateOptions.withResponse) { + return { + ...queryChannelsResponse, + channels: hydratedChannels, + }; + } + + return hydratedChannels; } /** diff --git a/src/types.ts b/src/types.ts index 9dbeb49b7..0f0adaaf5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1111,6 +1111,15 @@ export type ChannelStateOptions = { offlineMode?: boolean; skipInitialization?: string[]; skipHydration?: boolean; + /** + * Returns the full query response with hydrated channels from `queryChannels()`. + * + * This is a compatibility bridge for internal callers that need response level + * metadata such as `predefined_filter`. The default `queryChannels()` return value + * remains `Channel[]` to avoid a breaking change. This should be folded into a + * single full response API in the next major release. + */ + withResponse?: boolean; }; export type CreateChannelOptions = { diff --git a/test/unit/channel_manager.test.ts b/test/unit/channel_manager.test.ts index 8b06479c5..74b586322 100644 --- a/test/unit/channel_manager.test.ts +++ b/test/unit/channel_manager.test.ts @@ -10,6 +10,7 @@ import { channelManagerEventToHandlerMapping, DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, QueryChannelsRequestType, + QueryChannelsAPIResponse, } from '../../src'; import { generateChannel } from './test-utils/generateChannel'; @@ -1477,6 +1478,41 @@ describe('ChannelManager', () => { (typeof utils)['findLastPinnedChannelIndex'] >; let extractSortValueStub: MockInstance<(typeof utils)['extractSortValue']>; + const setChannelMembership = ( + channelId: string, + membership: Record, + ) => { + const channel = client.channel('messaging', channelId); + channel.state.membership = { + user: { id: client.userID }, + user_id: client.userID, + ...membership, + } as never; + + return channel; + }; + const queryChannelsWithPredefinedFilterResponse = async ({ + filter, + sort, + }: { + filter: Record; + sort?: NonNullable['sort']; + }) => { + vi.spyOn(client, 'post').mockResolvedValueOnce({ + duration: '0.01s', + channels: channelsResponse, + predefined_filter: { + name: 'messaging_channels', + filter, + sort, + }, + } satisfies QueryChannelsAPIResponse); + + await channelManager.queryChannels({}, [], { + predefined_filter: 'messaging_channels', + }); + setChannelsStub.mockClear(); + }; beforeEach(() => { setChannelsStub = vi.spyOn(channelManager, 'setChannels'); @@ -1499,6 +1535,247 @@ describe('ChannelManager', () => { sinon.reset(); }); + describe('predefined filter response metadata', () => { + it('does not promote an archived channel into a resolved non-archived list on message.new', async () => { + await queryChannelsWithPredefinedFilterResponse({ + filter: { archived: false }, + }); + setChannelMembership('channel2', { + archived_at: '2024-01-15T10:30:00Z', + }); + + client.dispatchEvent({ + type: 'message.new', + channel_type: 'messaging', + channel_id: 'channel2', + }); + + expect(setChannelsStub).toHaveBeenCalledTimes(0); + }); + + it('does not promote an unarchived channel into a resolved archived list on message.new', async () => { + await queryChannelsWithPredefinedFilterResponse({ + filter: { archived: true }, + }); + + client.dispatchEvent({ + type: 'message.new', + channel_type: 'messaging', + channel_id: 'channel2', + }); + + expect(setChannelsStub).toHaveBeenCalledTimes(0); + }); + + it('does not move a pinned channel when resolved predefined sort considers pinned_at', async () => { + await queryChannelsWithPredefinedFilterResponse({ + filter: {}, + sort: [{ field: 'pinned_at', direction: -1 }], + }); + setChannelMembership('channel2', { + pinned_at: '2024-01-15T10:30:00Z', + }); + + client.dispatchEvent({ + type: 'message.new', + channel_type: 'messaging', + channel_id: 'channel2', + }); + + expect(setChannelsStub).toHaveBeenCalledTimes(0); + }); + + it('does not promote an archived channel into a resolved non-archived list on notification.message_new', async () => { + const clock = sinon.useFakeTimers(); + await queryChannelsWithPredefinedFilterResponse({ + filter: { archived: false }, + }); + const channel = setChannelMembership('channel4', { + archived_at: '2024-01-15T10:30:00Z', + }); + getAndWatchChannelStub.mockResolvedValueOnce(channel); + + client.dispatchEvent({ + type: 'notification.message_new', + channel: { type: 'messaging', id: 'channel4' } as unknown as ChannelResponse, + }); + + await clock.runAllAsync(); + clock.restore(); + + expect(setChannelsStub).toHaveBeenCalledTimes(0); + }); + + it('does not add an archived channel into a resolved non-archived list on channel.visible', async () => { + const clock = sinon.useFakeTimers(); + await queryChannelsWithPredefinedFilterResponse({ + filter: { archived: false }, + }); + const channel = setChannelMembership('channel4', { + archived_at: '2024-01-15T10:30:00Z', + }); + getAndWatchChannelStub.mockResolvedValueOnce(channel); + + client.dispatchEvent({ + type: 'channel.visible', + channel_id: 'channel4', + channel_type: 'messaging', + }); + + await clock.runAllAsync(); + clock.restore(); + + expect(setChannelsStub).toHaveBeenCalledTimes(0); + }); + + it('uses resolved predefined filter metadata when handling member.updated archive changes', async () => { + await queryChannelsWithPredefinedFilterResponse({ + filter: { archived: false }, + }); + setChannelMembership('channel2', { + archived_at: '2024-01-15T10:30:00Z', + }); + + client.dispatchEvent({ + type: 'member.updated', + channel_id: 'channel2', + channel_type: 'messaging', + member: { user: { id: client.userID } }, + }); + + expect(setChannelsStub).toHaveBeenCalledOnce(); + expect( + (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), + ).to.deep.equal(['channel1', 'channel3']); + }); + + it('uses resolved predefined sort metadata when handling member.updated pin changes', async () => { + await queryChannelsWithPredefinedFilterResponse({ + filter: {}, + sort: [{ field: 'pinned_at', direction: -1 }], + }); + setChannelMembership('channel1', { + pinned_at: '2024-01-15T10:30:00Z', + }); + setChannelMembership('channel3', { + pinned_at: '2024-01-15T10:30:00Z', + }); + + client.dispatchEvent({ + type: 'member.updated', + channel_id: 'channel3', + channel_type: 'messaging', + member: { user: { id: client.userID } }, + }); + + expect(setChannelsStub).toHaveBeenCalledOnce(); + expect( + (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), + ).to.deep.equal(['channel3', 'channel1', 'channel2']); + }); + + it('keeps non-predefined query behavior based on caller filters and sort', async () => { + vi.spyOn(client, 'post').mockResolvedValueOnce({ + duration: '0.01s', + channels: channelsResponse, + } satisfies QueryChannelsAPIResponse); + await channelManager.queryChannels({ archived: false }, [], { limit: 10 }); + setChannelsStub.mockClear(); + setChannelMembership('channel2', { + archived_at: '2024-01-15T10:30:00Z', + }); + + client.dispatchEvent({ + type: 'message.new', + channel_type: 'messaging', + channel_id: 'channel2', + }); + + expect(setChannelsStub).toHaveBeenCalledTimes(0); + }); + + it('preserves resolved predefined response metadata after loading the next page', async () => { + vi.spyOn(client, 'post') + .mockResolvedValueOnce({ + duration: '0.01s', + channels: channelsResponse, + predefined_filter: { + name: 'messaging_channels', + filter: { archived: false }, + sort: [{ field: 'pinned_at', direction: -1 }], + }, + } satisfies QueryChannelsAPIResponse) + .mockResolvedValueOnce({ + duration: '0.01s', + channels: [ + generateChannel({ channel: { id: 'channel4' } }), + generateChannel({ channel: { id: 'channel5' } }), + ], + predefined_filter: { + name: 'messaging_channels', + filter: { archived: false }, + sort: [{ field: 'pinned_at', direction: -1 }], + }, + } satisfies QueryChannelsAPIResponse); + + await channelManager.queryChannels({}, [], { + predefined_filter: 'messaging_channels', + limit: 2, + offset: 0, + }); + await channelManager.loadNext(); + setChannelsStub.mockClear(); + setChannelMembership('channel2', { + archived_at: '2024-01-15T10:30:00Z', + }); + + client.dispatchEvent({ + type: 'message.new', + channel_type: 'messaging', + channel_id: 'channel2', + }); + + expect(setChannelsStub).toHaveBeenCalledTimes(0); + }); + + it('clears resolved predefined response metadata when switching to a non-predefined query', async () => { + vi.spyOn(client, 'post') + .mockResolvedValueOnce({ + duration: '0.01s', + channels: channelsResponse, + predefined_filter: { + name: 'messaging_channels', + filter: { archived: false }, + sort: [{ field: 'pinned_at', direction: -1 }], + }, + } satisfies QueryChannelsAPIResponse) + .mockResolvedValueOnce({ + duration: '0.01s', + channels: channelsResponse, + } satisfies QueryChannelsAPIResponse); + + await channelManager.queryChannels({}, [], { + predefined_filter: 'messaging_channels', + }); + await channelManager.queryChannels({}, [], { limit: 10 }); + setChannelsStub.mockClear(); + setChannelMembership('channel2', { + archived_at: '2024-01-15T10:30:00Z', + }); + + client.dispatchEvent({ + type: 'message.new', + channel_type: 'messaging', + channel_id: 'channel2', + }); + + expect(setChannelsStub).toHaveBeenCalledOnce(); + expect( + (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), + ).to.deep.equal(['channel2', 'channel1', 'channel3']); + }); + }); + describe('channelDeletedHandler, channelHiddenHandler and notificationRemovedFromChannelHandler', () => { let channelToRemove: ChannelResponse;