diff --git a/src/channel.ts b/src/channel.ts index bd94876f20..f8e0468855 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -9,7 +9,6 @@ import { normalizeQuerySort, } from './utils'; import type { StreamChat } from './client'; -import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants'; import type { AIState, APIResponse, @@ -1562,8 +1561,7 @@ export class Channel { ...messageSetPagination({ parentSet: messageSet, messagePaginationOptions: options?.messages, - requestedPageSize: - options?.messages?.limit ?? DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE, + requestedPageSize: options?.messages?.limit, returnedPage: state.messages, logger: this.getClient().logger, }), diff --git a/src/client.ts b/src/client.ts index 0bdf1dcbf7..03d88793c0 100644 --- a/src/client.ts +++ b/src/client.ts @@ -246,7 +246,6 @@ import { InsightMetrics, postInsights } from './insights'; import { Thread } from './thread'; import { Moderation } from './moderation'; import { ThreadManager } from './thread_manager'; -import { DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE } from './constants'; import { PollManager } from './poll_manager'; import type { ChannelManagerEventHandlerOverrides, @@ -2041,9 +2040,7 @@ export class StreamChat { ...updatedMessagesSet.pagination, ...messageSetPagination({ parentSet: updatedMessagesSet, - requestedPageSize: - queryChannelsOptions?.message_limit || - DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE, + requestedPageSize: queryChannelsOptions?.message_limit, returnedPage: channelState.messages, logger: this.logger, }), diff --git a/src/constants.ts b/src/constants.ts index 94c66e146f..67996fb448 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,12 +1,9 @@ -export const DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE = 25; -export const DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE = 100; export const DEFAULT_MESSAGE_SET_PAGINATION = Object.freeze({ hasNext: false, hasPrev: false, }); export const DEFAULT_UPLOAD_SIZE_LIMIT_BYTES = 100 * 1024 * 1024; // 100 MB export const API_MAX_FILES_ALLOWED_PER_MESSAGE = 10; -export const MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY = 100; export const RESERVED_UPDATED_MESSAGE_FIELDS = Object.freeze({ // Dates should not be converted back to ISO strings as JS looses precision on them (milliseconds) created_at: true, diff --git a/src/messageComposer/middleware/textComposer/mentions.ts b/src/messageComposer/middleware/textComposer/mentions.ts index 2007012317..c3f6befc88 100644 --- a/src/messageComposer/middleware/textComposer/mentions.ts +++ b/src/messageComposer/middleware/textComposer/mentions.ts @@ -16,7 +16,6 @@ import type { UserSort, } from '../../../types'; import type { Channel } from '../../../channel'; -import { MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY } from '../../../constants'; import type { Middleware } from '../../../middleware'; import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor'; @@ -107,8 +106,8 @@ export class MentionsSearchSource extends BaseSearchSource { } get allMembersLoadedWithInitialChannelQuery() { - const countLoadedMembers = Object.keys(this.channel.state.members || {}).length; - return countLoadedMembers < MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY; + const { state, data } = this.channel; + return Object.keys(state.members).length === (data?.member_count ?? 0); } toUserSuggestion = (user: UserResponse): UserSuggestion => ({ @@ -236,13 +235,13 @@ export class MentionsSearchSource extends BaseSearchSource { }; }; - queryUsers = async (searchQuery: string) => { + queryUsers = async (searchQuery: string): Promise => { const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery); const { users } = await this.client.queryUsers(filters, sort, options); return users; }; - queryMembers = async (searchQuery: string) => { + queryMembers = async (searchQuery: string): Promise => { const { filters, sort, options } = this.prepareQueryMembersParams(searchQuery); const response = await this.channel.queryMembers(filters, sort, options); diff --git a/src/utils.ts b/src/utils.ts index 8bd9580986..08f934617e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -809,12 +809,30 @@ export const uniqBy = ( type MessagePaginationUpdatedParams = { parentSet: MessageSet; - requestedPageSize: number; + requestedPageSize?: number; returnedPage: MessageResponse[]; logger?: Logger; messagePaginationOptions?: MessagePaginationOptions; }; +type PaginationBoundaries = Pick< + MessagePaginationUpdatedParams, + 'requestedPageSize' | 'returnedPage' +>; + +const toPageSize = ({ requestedPageSize, returnedPage }: PaginationBoundaries) => + typeof requestedPageSize === 'number' + ? requestedPageSize + : Math.max(returnedPage.length, 1); + +const hasMoreByPageSize = ({ requestedPageSize, returnedPage }: PaginationBoundaries) => { + // If the caller doesn't provide a page size, we optimistically + // continue pagination until we receive an empty page. + if (typeof requestedPageSize !== 'number') return returnedPage.length > 0; + if (requestedPageSize <= 0) return false; + return returnedPage.length >= requestedPageSize; +}; + export function binarySearchByDateEqualOrNearestGreater( array: { created_at?: string; @@ -866,13 +884,13 @@ const messagePaginationCreatedAtAround = ({ const wholePageHasOlderMessages = !!lastPageMsg?.created_at && new Date(lastPageMsg.created_at) < createdAtAroundDate; + const pageSize = toPageSize({ requestedPageSize, returnedPage }); const requestedPageSizeNotMet = - requestedPageSize > parentSet.messages.length && - requestedPageSize > returnedPage.length; + pageSize > parentSet.messages.length && pageSize > returnedPage.length; const noMoreMessages = - (requestedPageSize > parentSet.messages.length || + (pageSize > parentSet.messages.length || parentSet.messages.length >= returnedPage.length) && - requestedPageSize > returnedPage.length; + pageSize > returnedPage.length; if (wholePageHasNewerMessages) { hasPrev = false; @@ -937,10 +955,11 @@ const messagePaginationIdAround = ({ let updateHasNext = lastPageMsgIsLastInSet; const midPoint = Math.floor(returnedPage.length / 2); + const pageSize = toPageSize({ requestedPageSize, returnedPage }); const noMoreMessages = - (requestedPageSize > parentSet.messages.length || + (pageSize > parentSet.messages.length || parentSet.messages.length >= returnedPage.length) && - requestedPageSize > returnedPage.length; + pageSize > returnedPage.length; if (noMoreMessages) { hasNext = hasPrev = false; @@ -1009,7 +1028,7 @@ const messagePaginationLinear = ({ !messagePaginationOptions?.id_around && !messagePaginationOptions?.created_at_around; - const hasMore = returnedPage.length >= requestedPageSize; + const hasMore = hasMoreByPageSize({ requestedPageSize, returnedPage }); if (typeof queriedPrevMessages !== 'undefined' || containsUnrecognizedOptionsOnly) { hasPrev = hasMore; diff --git a/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts b/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts index 42cb04d04f..c34a1cb131 100644 --- a/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts @@ -5,7 +5,6 @@ import { } from '../../../../../src/messageComposer/middleware/textComposer/mentions'; import { Channel } from '../../../../../src/channel'; import { StreamChat } from '../../../../../src/client'; -import { MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY } from '../../../../../src/constants'; import type { ChannelMemberResponse, Mute, @@ -83,6 +82,9 @@ describe('MentionsSearchSource', () => { } as any; channel = { + data: { + member_count: Object.keys(mockMembers).length, + }, getClient: vi.fn().mockReturnValue(client), state: { members: mockMembers, @@ -123,18 +125,54 @@ describe('MentionsSearchSource', () => { const source = new MentionsSearchSource(channel); source.activate(); - // Simulate more members than MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY - const manyMembers: Record = {}; - for (let i = 0; i < MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY + 1; i++) { - manyMembers[`user${i}`] = { user: { id: `user${i}`, name: `User ${i}` } }; - } - channel.state.members = manyMembers; + // Simulate a partial members cache to force remote query path. + channel.state.members = { user1: mockMembers.user1 }; + channel.data = { member_count: Object.keys(mockMembers).length }; const result = await source.query('john'); expect(channel.queryMembers).toHaveBeenCalled(); expect(result.items).toHaveLength(Object.keys(mockMembers).length); }); + it('should query members when member_count is missing', async () => { + const source = new MentionsSearchSource(channel); + source.activate(); + source.config.textComposerText = '@jo'; + channel.data = undefined; + + const result = await source.query('jo'); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + expect(result.items).toHaveLength(Object.keys(mockMembers).length); + }); + + it('should query members when member_count does not match loaded members', async () => { + const source = new MentionsSearchSource(channel); + source.activate(); + source.config.textComposerText = '@jo'; + channel.data = { member_count: Object.keys(mockMembers).length + 10 }; + + const result = await source.query('jo'); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + expect(result.items).toHaveLength(Object.keys(mockMembers).length); + }); + + it('should return queryMembers users without mutating channel members cache', async () => { + const source = new MentionsSearchSource(channel); + source.activate(); + source.config.textComposerText = '@new'; + channel.state.members = {}; + channel.data = { member_count: 2 }; + channel.queryMembers = vi.fn().mockResolvedValue({ + members: [{ user: { id: 'new-user', name: 'New User' } }], + }); + + const result = await source.query('new'); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + expect(result.items).toHaveLength(1); + expect(result.items[0].name).toBe('New User'); + expect(channel.state.members['new-user']).toBeUndefined(); + }); + it('should query all app users when mentionAllAppUsers is true', async () => { const source = new MentionsSearchSource(channel, { mentionAllAppUsers: true }); source.activate(); @@ -181,6 +219,24 @@ describe('MentionsSearchSource', () => { expect(result.items[0].id).toBe('user2'); }); + it('should apply mute filtering when query is executed through BaseSearchSource pipeline', async () => { + const source = new MentionsSearchSource(channel); + source.activate(); + source.config.textComposerText = '@'; + const mute: Mute = { + target: { id: 'user1' }, + user: { id: 'currentUser' }, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + client.mutedUsers = [mute]; + + await source.executeQuery(''); + const items = source.state.getLatestValue().items ?? []; + expect(items).toHaveLength(1); + expect(items[0].id).toBe('user2'); + }); + it('should preserve items in state before first query', () => { const source = new MentionsSearchSource(channel); source.activate(); diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 36d4d853be..e6990bbfd1 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -8,12 +8,13 @@ import sinon from 'sinon'; import { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse'; import { ChannelState, StreamChat } from '../../src'; -import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from '../../src/constants'; import { MockOfflineDB } from './offline-support/MockOfflineDB'; import { generateUUIDv4 as uuidv4 } from '../../src/utils'; import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest'; +const DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE = 100; + describe('Channel count unread', function () { let lastRead; let user; @@ -1933,7 +1934,7 @@ describe('Channel.query', async () => { mock.restore(); }); - it('should not update pagination for queried message set', async () => { + it('should not update pagination for queried message set when explicit limit is not met', async () => { const client = await getClientWithUser(); const channel = client.channel('messaging', uuidv4()); const mockedChannelQueryResponse = { @@ -1945,7 +1946,9 @@ describe('Channel.query', async () => { }; const mock = sinon.mock(client); mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); - await channel.query(); + await channel.query({ + messages: { limit: DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE }, + }); expect(channel.state.messageSets.length).to.be.equal(1); expect(channel.state.messageSets[0].pagination).to.eql({ hasNext: false, diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 35e26adea4..b1d35f6e05 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -7,7 +7,6 @@ import { StreamChat } from '../../src/client'; import { ConnectionState } from '../../src/connection_fallback'; import { StableWSConnection } from '../../src/connection'; import { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse'; -import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from '../../src/constants'; import { describe, @@ -23,6 +22,8 @@ import { Channel } from '../../src'; import { normalizeQuerySort } from '../../src/utils'; import { MockOfflineDB } from './offline-support/MockOfflineDB'; +const DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE = 100; + describe('StreamChat getInstance', () => { beforeEach(() => { delete StreamChat._instance;