diff --git a/src/channel.ts b/src/channel.ts index df58fdaf0..27d24337c 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -7,6 +7,7 @@ import { BanUserOptions, ChannelAPIResponse, ChannelData, + ChannelFilters, ChannelMemberAPIResponse, ChannelMemberResponse, ChannelQueryOptions, @@ -33,6 +34,8 @@ import { QueryMembersOptions, Reaction, ReactionAPIResponse, + SearchOptions, + SearchPayload, SearchAPIResponse, SendMessageAPIResponse, TruncateChannelAPIResponse, @@ -41,6 +44,7 @@ import { UserFilters, UserResponse, UserSort, + SearchMessageSortBase, } from './types'; import { Role } from './permissions'; @@ -299,7 +303,7 @@ export class Channel< * search - Query messages * * @param {MessageFilters | string} query search query or object MongoDB style filters - * @param {{client_id?: string; connection_id?: string; limit?: number; offset?: number; query?: string; message_filter_conditions?: MessageFilters}} options Option object, {user_id: 'tommaso'} + * @param {{client_id?: string; connection_id?: string; query?: string; message_filter_conditions?: MessageFilters}} options Option object, {user_id: 'tommaso'} * * @return {Promise>} search messages response */ @@ -314,10 +318,9 @@ export class Channel< UserType > | string, - options: { + options: SearchOptions & { client_id?: string; connection_id?: string; - limit?: number; message_filter_conditions?: MessageFilters< AttachmentType, ChannelType, @@ -326,14 +329,30 @@ export class Channel< ReactionType, UserType >; - offset?: number; query?: string; } = {}, ) { + if (options.offset && (options.sort || options.next)) { + throw Error(`Cannot specify offset with sort or next parameters`); + } // Return a list of channels - const payload = { - filter_conditions: { cid: this.cid }, + const payload: SearchPayload< + AttachmentType, + ChannelType, + CommandType, + MessageType, + ReactionType, + UserType + > = { + filter_conditions: { cid: this.cid } as ChannelFilters< + ChannelType, + CommandType, + UserType + >, ...options, + sort: options.sort + ? normalizeQuerySort>(options.sort) + : undefined, }; if (typeof query === 'string') { payload.query = query; @@ -342,7 +361,6 @@ export class Channel< } else { throw Error(`Invalid type ${typeof query} for query parameter`); } - // Make sure we wait for the connect promise if there is a pending one await this.getClient().wsPromise; diff --git a/src/client.ts b/src/client.ts index aa7febcb3..8f2dce83a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -81,9 +81,9 @@ import { PermissionAPIResponse, PermissionsAPIResponse, ReactionResponse, - SearchAPIResponse, SearchOptions, SearchPayload, + SearchAPIResponse, SendFileAPIResponse, StreamChatOptions, TestPushDataInput, @@ -102,6 +102,7 @@ import { UserOptions, UserResponse, UserSort, + SearchMessageSortBase, } from './types'; function isString(x: unknown): x is string { @@ -1761,7 +1762,7 @@ export class StreamChat< * * @param {ChannelFilters} filterConditions MongoDB style filter conditions * @param {MessageFilters | string} query search query or object MongoDB style filters - * @param {SearchOptions} [options] Option object, {user_id: 'tommaso'} + * @param {SearchOptions} [options] Option object, {user_id: 'tommaso'} * * @return {Promise>} search messages response */ @@ -1777,9 +1778,11 @@ export class StreamChat< ReactionType, UserType >, - options: SearchOptions = {}, + options: SearchOptions = {}, ) { - // Return a list of channels + if (options.offset && (options.sort || options.next)) { + throw Error(`Cannot specify offset with sort or next parameters`); + } const payload: SearchPayload< AttachmentType, ChannelType, @@ -1790,6 +1793,9 @@ export class StreamChat< > = { filter_conditions: filterConditions, ...options, + sort: options.sort + ? normalizeQuerySort>(options.sort) + : undefined, }; if (typeof query === 'string') { payload.query = query; diff --git a/src/types.ts b/src/types.ts index db0464261..285a74079 100644 --- a/src/types.ts +++ b/src/types.ts @@ -649,8 +649,17 @@ export type SearchAPIResponse< UserType >; }[]; + next?: string; + previous?: string; + results_warning?: SearchWarning; }; +export type SearchWarning = { + channel_search_cids: string[]; + channel_search_count: number; + warning_code: number; + warning_description: string; +}; export type SendFileAPIResponse = APIResponse & { file: string }; export type SendMessageAPIResponse< @@ -936,9 +945,11 @@ export type QueryMembersOptions = { user_id_lte?: string; }; -export type SearchOptions = { +export type SearchOptions = { limit?: number; + next?: string; offset?: number; + sort?: SearchMessageSort; }; export type StreamChatOptions = AxiosRequestConfig & { @@ -1408,9 +1419,34 @@ export type UserSort = | Sort> | Array>>; -export type QuerySort = +export type SearchMessageSortBase = Sort & { + attachments?: AscDesc; + 'attachments.type'?: AscDesc; + created_at?: AscDesc; + id?: AscDesc; + 'mentioned_users.id'?: AscDesc; + parent_id?: AscDesc; + pinned?: AscDesc; + relevance?: AscDesc; + reply_count?: AscDesc; + text?: AscDesc; + type?: AscDesc; + updated_at?: AscDesc; + 'user.id'?: AscDesc; +}; + +export type SearchMessageSort = + | SearchMessageSortBase + | Array>; + +export type QuerySort< + ChannelType = UnknownType, + UserType = UnknownType, + MessageType = UnknownType +> = | BannedUsersSort | ChannelSort + | SearchMessageSort | UserSort; /** @@ -1900,7 +1936,7 @@ export type SearchPayload< MessageType = UnknownType, ReactionType = UnknownType, UserType = UnknownType -> = SearchOptions & { +> = Omit, 'sort'> & { client_id?: string; connection_id?: string; filter_conditions?: ChannelFilters; @@ -1913,6 +1949,10 @@ export type SearchPayload< UserType >; query?: string; + sort?: Array<{ + direction: AscDesc; + field: keyof SearchMessageSortBase; + }>; }; export type TestPushDataInput = { diff --git a/src/utils.ts b/src/utils.ts index ee4518ce2..376f2e452 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,7 +3,6 @@ import { AscDesc, LiteralStringForUnion, OwnUserResponse, - QuerySort, UnknownType, UserResponse, } from './types'; @@ -96,15 +95,13 @@ export function addFileToFormData( return data; } - -export function normalizeQuerySort(sort: T) { - const sortFields = []; +export function normalizeQuerySort>( + sort: T | T[], +) { + const sortFields: Array<{ direction: AscDesc; field: keyof T }> = []; const sortArr = Array.isArray(sort) ? sort : [sort]; for (const item of sortArr) { - const entries = (Object.entries(item) as unknown) as [ - T extends (infer K)[] ? keyof K : keyof T, - AscDesc, - ][]; + const entries = Object.entries(item) as [keyof T, AscDesc][]; if (entries.length > 1) { console.warn( "client._buildSort() - multiple fields in a single sort object detected. Object's field order is not guaranteed", diff --git a/test/unit/channel.js b/test/unit/channel.js index a5ca545b7..42f09ffb8 100644 --- a/test/unit/channel.js +++ b/test/unit/channel.js @@ -425,3 +425,34 @@ describe('event subscription and unsubscription', () => { expect(channel.listeners['all'].length).to.be.equal(0); }); }); +describe('Channel search', async () => { + const client = await getClientWithUser(); + const channel = client.channel('messaging', uuidv4()); + + it('search with sorting by defined field', async () => { + client.get = (url, config) => { + expect(config.payload.sort).to.be.eql([ + { field: 'updated_at', direction: -1 }, + ]); + }; + await channel.search('query', { sort: [{ updated_at: -1 }] }); + }); + it('search with sorting by custom field', async () => { + client.get = (url, config) => { + expect(config.payload.sort).to.be.eql([ + { field: 'custom_field', direction: -1 }, + ]); + }; + await channel.search('query', { sort: [{ custom_field: -1 }] }); + }); + it('sorting and offset fails', async () => { + await expect( + channel.search('query', { offset: 1, sort: [{ custom_field: -1 }] }), + ).to.be.rejectedWith(Error); + }); + it('next and offset fails', async () => { + await expect( + channel.search('query', { offset: 1, next: 'next' }), + ).to.be.rejectedWith(Error); + }); +}); diff --git a/test/unit/client.js b/test/unit/client.js index 4f6374266..980e39d3f 100644 --- a/test/unit/client.js +++ b/test/unit/client.js @@ -1,11 +1,12 @@ import chai from 'chai'; - +import chaiAsPromised from 'chai-as-promised'; import { generateMsg } from './test-utils/generateMessage'; import { getClientWithUser } from './test-utils/getClient'; import { StreamChat } from '../../src/client'; const expect = chai.expect; +chai.use(chaiAsPromised); describe('StreamChat getInstance', () => { beforeEach(() => { @@ -268,3 +269,44 @@ describe('updateMessage should ensure sanity of `mentioned_users`', () => { ); }); }); + +describe('Client search', async () => { + const client = await getClientWithUser(); + + it('search with sorting by defined field', async () => { + client.get = (url, config) => { + expect(config.payload.sort).to.be.eql([ + { field: 'updated_at', direction: -1 }, + ]); + }; + await client.search({ cid: 'messaging:my-cid' }, 'query', { + sort: [{ updated_at: -1 }], + }); + }); + it('search with sorting by custom field', async () => { + client.get = (url, config) => { + expect(config.payload.sort).to.be.eql([ + { field: 'custom_field', direction: -1 }, + ]); + }; + await client.search({ cid: 'messaging:my-cid' }, 'query', { + sort: [{ custom_field: -1 }], + }); + }); + it('sorting and offset fails', async () => { + await expect( + client.search({ cid: 'messaging:my-cid' }, 'query', { + offset: 1, + sort: [{ custom_field: -1 }], + }), + ).to.be.rejectedWith(Error); + }); + it('next and offset fails', async () => { + await expect( + client.search({ cid: 'messaging:my-cid' }, 'query', { + offset: 1, + next: 'next', + }), + ).to.be.rejectedWith(Error); + }); +});