From 7045e1a4b4f1199fe30b8d77942bb02333273089 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Fri, 16 Oct 2026 14:42:50 -0500 Subject: [PATCH 1/3] chore: docs snippets for state docs --- .../javascript/06-state/01-overview.test.ts | 77 ++++++ .../javascript/06-state/02-messages.test.ts | 243 +++++++++++++++++ .../javascript/06-state/03-channel.test.ts | 222 +++++++++++++++ .../javascript/06-state/04-thread.test.ts | 197 ++++++++++++++ .../06-state/05-channel-list.test.ts | 231 ++++++++++++++++ .../06-state/06-thread-list.test.ts | 256 ++++++++++++++++++ .../chat/javascript/06-state/07-users.test.ts | 87 ++++++ .../javascript/06-state/08-connection.test.ts | 153 +++++++++++ .../javascript/06-state/09-composer.test.ts | 112 ++++++++ .../chat/javascript/06-state/10-polls.test.ts | 127 +++++++++ .../javascript/06-state/11-reminders.test.ts | 150 ++++++++++ .../06-state/12-notifications.test.ts | 81 ++++++ 12 files changed, 1936 insertions(+) create mode 100644 test/docs-snippets/pages/chat/javascript/06-state/01-overview.test.ts create mode 100644 test/docs-snippets/pages/chat/javascript/06-state/02-messages.test.ts create mode 100644 test/docs-snippets/pages/chat/javascript/06-state/03-channel.test.ts create mode 100644 test/docs-snippets/pages/chat/javascript/06-state/04-thread.test.ts create mode 100644 test/docs-snippets/pages/chat/javascript/06-state/05-channel-list.test.ts create mode 100644 test/docs-snippets/pages/chat/javascript/06-state/06-thread-list.test.ts create mode 100644 test/docs-snippets/pages/chat/javascript/06-state/07-users.test.ts create mode 100644 test/docs-snippets/pages/chat/javascript/06-state/08-connection.test.ts create mode 100644 test/docs-snippets/pages/chat/javascript/06-state/09-composer.test.ts create mode 100644 test/docs-snippets/pages/chat/javascript/06-state/10-polls.test.ts create mode 100644 test/docs-snippets/pages/chat/javascript/06-state/11-reminders.test.ts create mode 100644 test/docs-snippets/pages/chat/javascript/06-state/12-notifications.test.ts diff --git a/test/docs-snippets/pages/chat/javascript/06-state/01-overview.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/01-overview.test.ts new file mode 100644 index 000000000..0db7bcf71 --- /dev/null +++ b/test/docs-snippets/pages/chat/javascript/06-state/01-overview.test.ts @@ -0,0 +1,77 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { Channel, StreamChat } from '../../../../../../src'; +import { + disconnectClients, + getClientSideClient, + getServerClient, +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +describe('javascript/06-state/01-overview.md', () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + const channelId = uniqueId('state'); + let client: StreamChat; + let channel: Channel; + + beforeAll(async () => { + cleanup.users.push(userId); + client = await getClientSideClient({ id: userId }); + channel = client.channel('messaging', channelId); + cleanup.channels.push(`messaging:${channelId}`); + await channel.watch(); + await channel.sendMessage({ message: { text: 'hello' } }); + // The sent message reaches the paginator with its `message.new` event + await retry(() => { + expect(channel.messagePaginator.items).toHaveLength(1); + return Promise.resolve(); + }); + }); + + afterAll(async () => { + await disconnectClients(client); + await cleanup.run(); + }); + + it('reads and subscribes to state', () => { + const renderChannel = vi.fn(); + const renderHeader = vi.fn(); + + // #region snippet docs="chat/javascript/06-state/01-overview.md" heading="Reading state" tab="JavaScript" index=1 + // Read the current value + const { items } = channel.messagePaginator.state.getLatestValue(); + + // React to any change in the store + const unsubscribeChannel = channel.state.subscribe((state) => renderChannel(state)); + + // React to changes of selected values only. + // Return a small, flat object: it is compared key by key. + const unsubscribeHeader = channel.state.subscribeWithSelector( + ({ typing, memberCount }) => ({ typing, memberCount }), + ({ typing, memberCount }) => renderHeader(typing, memberCount), + ); + + // Stop listening + unsubscribeChannel(); + unsubscribeHeader(); + // #endregion snippet + + expect(items?.map((message) => message.text)).toContain('hello'); + // Both call back once right away with the current value + expect(renderChannel).toHaveBeenCalledTimes(1); + expect(renderChannel).toHaveBeenCalledWith(channel.state.getLatestValue()); + expect(renderHeader).toHaveBeenCalledTimes(1); + expect(renderHeader).toHaveBeenCalledWith( + {}, + channel.state.getLatestValue().memberCount, + ); + + // After unsubscribing no more calls arrive + channel.state.partialNext({ typing: {} }); + expect(renderChannel).toHaveBeenCalledTimes(1); + expect(renderHeader).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/02-messages.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/02-messages.test.ts new file mode 100644 index 000000000..c37af952d --- /dev/null +++ b/test/docs-snippets/pages/chat/javascript/06-state/02-messages.test.ts @@ -0,0 +1,243 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { Channel, StreamChat } from '../../../../../../src'; +import { + disconnectClients, + getClientSideClient, + getServerClient, +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { sendServerMessage } from '../../../../helpers/server'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'chat/javascript/06-state/02-messages.md'; +// One more than the first page `watch()` loads (100) +const MESSAGE_COUNT = 110; + +describe(DOCS, () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + const channelId = uniqueId('messages'); + const cid = `messaging:${channelId}`; + const messageIds: string[] = []; + let client: StreamChat; + let watchedChannel: Channel; + + /** The id of the newest loaded message. */ + const newestId = (channel: Channel) => + [...(channel.messagePaginator.items ?? [])].sort( + (a, b) => b.created_at - a.created_at, + )[0]?.id; + + beforeAll(async () => { + cleanup.users.push(userId); + client = await getClientSideClient({ id: userId }); + await serverClient.chat + .channel('messaging', channelId) + .getOrCreate({ data: { created_by_id: userId, members: [{ user_id: userId }] } }); + cleanup.channels.push(cid); + // In parallel batches, so the order within a batch is not fixed + for (let batch = 0; batch < MESSAGE_COUNT / 10; batch++) { + const messages = await Promise.all( + Array.from({ length: 10 }, (_, i) => + sendServerMessage(serverClient, cid, { + text: `message ${batch * 10 + i}`, + user_id: userId, + }), + ), + ); + messageIds.push(...messages.map((message) => message.id)); + } + const { message: pinned } = await serverClient.chat.updateMessagePartial({ + id: messageIds[0], + set: { pinned: true }, + user_id: userId, + }); + expect(pinned?.pinned).toBe(true); + }); + + afterAll(async () => { + await disconnectClients(client); + await cleanup.run(); + }); + + it('loads messages', async () => { + const channel = client.channel('messaging', channelId); + await channel.watch(); + + watchedChannel = channel; + // `watch()` loads the first page (100 messages) + expect(channel.messagePaginator.items).toHaveLength(100); + expect(channel.messagePaginator.state.getLatestValue().hasMoreTail).toBe(true); + }); + + it('loads older messages', async () => { + const channel = watchedChannel; + + // #region snippet docs="chat/javascript/06-state/02-messages.md" heading="Loading older messages" tab="JavaScript" index=1 + const { hasMoreTail } = channel.messagePaginator.state.getLatestValue(); + + if (hasMoreTail) { + await channel.messagePaginator.toTail(); + } + // #endregion snippet + + expect(hasMoreTail).toBe(true); + // `toTail()` loaded the rest + expect(channel.messagePaginator.items).toHaveLength(MESSAGE_COUNT); + expect(channel.messagePaginator.state.getLatestValue().hasMoreTail).toBe(false); + }); + + it('reads messages', () => { + const channel = watchedChannel; + const messageId = messageIds[5]; + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + // #region snippet docs="chat/javascript/06-state/02-messages.md" heading="Reading" tab="JavaScript" index=1 + // Messages on screen, loading and pagination flags + const { items, isLoading, hasMoreTail } = + channel.messagePaginator.state.getLatestValue(); + + // Latest message of the channel + const lastMessage = channel.messagePaginator.lastMessage; + + // Any loaded message by id + const message = client.messageStore.get(messageId); + + // Did it send? "sending", "received" or "failed" + console.log(message?.status); + + // Pinned messages + const { items: pinnedMessages } = + channel.pinnedMessagesPaginator.state.getLatestValue(); + // #endregion snippet + + expect(items).toHaveLength(MESSAGE_COUNT); + expect(isLoading).toBe(false); + expect(hasMoreTail).toBe(false); + expect(lastMessage?.id).toBe(newestId(channel)); + expect(message?.id).toBe(messageId); + expect(logSpy).toHaveBeenCalledWith('received'); + expect(pinnedMessages?.map((pinned) => pinned.id)).toEqual([messageIds[0]]); + } finally { + logSpy.mockRestore(); + } + }); + + it('subscribes to messages', async () => { + const channel = watchedChannel; + const renderMessages = vi.fn(); + const renderPreview = vi.fn(); + + // #region snippet docs="chat/javascript/06-state/02-messages.md" heading="Reading" tab="JavaScript" index=2 + const unsubscribe = channel.messagePaginator.state.subscribeWithSelector( + (state) => ({ items: state.items }), + ({ items }) => renderMessages(items), + ); + + // The latest message is reactive through `aggregateState` + const unsubscribeLastMessage = + channel.messagePaginator.aggregateState.subscribeWithSelector( + (state) => ({ lastMessage: state.lastMessage }), + ({ lastMessage }) => renderPreview(lastMessage), + ); + // #endregion snippet + + try { + expect(renderPreview).toHaveBeenLastCalledWith( + expect.objectContaining({ id: newestId(channel) }), + ); + const { message } = await channel.sendMessage({ message: { text: 'newest' } }); + await retry(() => { + expect(renderPreview).toHaveBeenLastCalledWith( + expect.objectContaining({ id: message.id }), + ); + expect(renderMessages).toHaveBeenLastCalledWith( + expect.arrayContaining([expect.objectContaining({ id: message.id })]), + ); + return Promise.resolve(); + }); + } finally { + unsubscribe(); + unsubscribeLastMessage(); + } + }); + + it('jumps to a message', async () => { + const channel = watchedChannel; + const messageId = messageIds[0]; + const scrollToMessage = vi.fn(); + + // #region snippet docs="chat/javascript/06-state/02-messages.md" heading="Jumping to a message" tab="JavaScript" index=1 + await channel.messagePaginator.jumpToMessage(messageId); + + const { signal } = channel.messagePaginator.messageFocusSignal.getLatestValue(); + scrollToMessage(signal?.messageId); + // #endregion snippet + + expect(scrollToMessage).toHaveBeenCalledWith(messageId); + expect(channel.messagePaginator.items?.map((message) => message.id)).toContain( + messageId, + ); + }); + + it('works the same for thread replies', async () => { + const channel = watchedChannel; + const { message: parent } = await channel.sendMessage({ + message: { text: 'thread parent' }, + }); + const replyIds: string[] = []; + for (let i = 0; i < 5; i++) { + const reply = await sendServerMessage(serverClient, cid, { + text: `reply ${i}`, + user_id: userId, + parent_id: parent.id, + }); + replyIds.push(reply.id); + } + // Loads the thread with its latest replies (3 by default) + const thread = await client.getThreadAndHydrate(parent.id, { watch: true }); + const releaseThread = thread.activate(); + const replyId = replyIds[0]; + + try { + await retry(() => { + expect(thread.messagePaginator.items?.length).toBeGreaterThan(0); + expect(thread.messagePaginator.isLoading).toBe(false); + return Promise.resolve(); + }); + + // #region snippet docs="chat/javascript/06-state/02-messages.md" heading="Thread replies" tab="JavaScript" index=1 + // Replies on screen, loading and pagination flags + const { + items: replies, + isLoading, + hasMoreTail, + } = thread.messagePaginator.state.getLatestValue(); + + // Load older replies + if (hasMoreTail) { + await thread.messagePaginator.toTail(); + } + + // Jump to a reply + await thread.messagePaginator.jumpToMessage(replyId); + // #endregion snippet + + expect(replies?.length).toBeGreaterThan(0); + expect(isLoading).toBe(false); + // Replies are in the message store too + expect(client.messageStore.get(replyId)?.parent_id).toBe(thread.id); + // Every reply is loaded, and the jump focused the target reply + expect(thread.messagePaginator.items?.map((reply) => reply.id).sort()).toEqual( + [...replyIds].sort(), + ); + expect( + thread.messagePaginator.messageFocusSignal.getLatestValue().signal?.messageId, + ).toBe(replyId); + } finally { + releaseThread(); + } + }); +}); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/03-channel.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/03-channel.test.ts new file mode 100644 index 000000000..e3ca67830 --- /dev/null +++ b/test/docs-snippets/pages/chat/javascript/06-state/03-channel.test.ts @@ -0,0 +1,222 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { Channel, LocalMessage, StreamChat } from '../../../../../../src'; +import { + disconnectClients, + getClientSideClient, + getServerClient, +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'chat/javascript/06-state/03-channel.md'; + +describe(DOCS, () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + const otherUserId = uniqueId('other'); + const channelId = uniqueId('channel'); + let client: StreamChat; + let otherClient: StreamChat; + let watchedChannel: Channel; + + beforeAll(async () => { + cleanup.users.push(userId, otherUserId); + client = await getClientSideClient({ id: userId }); + otherClient = await getClientSideClient({ id: otherUserId }); + await serverClient.chat.channel('messaging', channelId).getOrCreate({ + data: { + created_by_id: userId, + members: [{ user_id: userId }, { user_id: otherUserId }], + }, + }); + cleanup.channels.push(`messaging:${channelId}`); + }); + + afterAll(async () => { + await disconnectClients(client, otherClient); + await cleanup.run(); + }); + + it('gets a channel', async () => { + // #region snippet docs="chat/javascript/06-state/03-channel.md" heading="Initialization" tab="JavaScript" index=1 + const channel = client.channel('messaging', channelId); + await channel.watch(); + // #endregion snippet + + watchedChannel = channel; + expect(client.channel('messaging', channelId)).toBe(channel); + expect(channel.state.getLatestValue().memberCount).toBe(2); + }); + + it('queries channels', async () => { + // #region snippet docs="chat/javascript/06-state/03-channel.md" heading="Initialization" tab="JavaScript" index=2 + const channels = await client.queryChannelsAndHydrate({ + filter_conditions: { type: 'messaging', members: { $in: [userId] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 15, + }); + + // The same instances `client.channel()` returns + const [firstChannel] = channels; + // #endregion snippet + + expect(channels).toHaveLength(1); + expect(firstChannel).toBe(client.channel('messaging', channelId)); + expect(firstChannel.initialized).toBe(true); + expect(firstChannel.state.getLatestValue().memberCount).toBe(2); + }); + + it('reads channel state', () => { + const channel = watchedChannel; + + // #region snippet docs="chat/javascript/06-state/03-channel.md" heading="Reading" tab="JavaScript" index=1 + // Channel data, members, my membership and permissions, typing, read state, mute + const { + data, + members, + memberCount, + membership, + ownCapabilities, + typing, + read, + muteStatus, + } = channel.state.getLatestValue(); + + // My unread count (derived from `read`) + const unreadCount = channel.state.unreadCount; + + // Slow mode wait, in seconds + const { cooldownRemaining } = channel.cooldownTimer.state.getLatestValue(); + + // Channel config from the server + const config = channel.serverConfig; + // #endregion snippet + + expect(data?.id).toBe(channelId); + expect(Object.keys(members).sort()).toEqual([otherUserId, userId].sort()); + expect(memberCount).toBe(2); + expect(membership.user?.id).toBe(userId); + expect(ownCapabilities).toContain('send-message'); + expect(typing).toEqual({}); + expect(read[userId]).toBeDefined(); + expect(muteStatus.muted).toBe(false); + expect(unreadCount).toBe(0); + expect(cooldownRemaining).toBe(0); + expect(config?.name).toBe('messaging'); + }); + + it('subscribes to channel state', async () => { + const channel = watchedChannel; + const renderHeader = vi.fn(); + + // #region snippet docs="chat/javascript/06-state/03-channel.md" heading="Reading" tab="JavaScript" index=2 + const unsubscribe = channel.state.subscribeWithSelector( + ({ read, members }) => ({ read, members }), + ({ read, members }) => renderHeader(read, members), + ); + // #endregion snippet + + try { + expect(renderHeader).toHaveBeenCalledTimes(1); + // The other member sends a message: my unread count goes up + await otherClient.channel('messaging', channelId).sendMessage({ + message: { text: 'unread' }, + }); + await retry(() => { + expect(renderHeader.mock.calls.length).toBeGreaterThan(1); + expect(channel.state.unreadCount).toBe(1); + return Promise.resolve(); + }); + } finally { + unsubscribe(); + } + }); + + it('reads read receipts', async () => { + const channel = watchedChannel; + const { message: sent } = await channel.sendMessage({ + message: { text: 'read me' }, + }); + await retry(() => { + expect(channel.messagePaginator.getItem(sent.id)).toBeDefined(); + return Promise.resolve(); + }); + const message = channel.messagePaginator.getItem(sent.id) as LocalMessage; + const renderReceipts = vi.fn(); + + // #region snippet docs="chat/javascript/06-state/03-channel.md" heading="Read receipts" tab="JavaScript" index=1 + const readers = channel.messageReceiptsTracker.readersForMessage({ + msgId: message.id, + timestamp: message.created_at, + }); + + const unsubscribeReceipts = channel.messageReceiptsTracker.snapshotStore.subscribe( + () => { + renderReceipts( + channel.messageReceiptsTracker.readersForMessage({ + msgId: message.id, + timestamp: message.created_at, + }), + ); + }, + ); + // #endregion snippet + + try { + expect(readers.map((user) => user.id)).not.toContain(otherUserId); + // The other member reads the channel + const otherChannel = otherClient.channel('messaging', channelId); + await otherChannel.watch(); + await otherChannel.markRead(); + await retry(() => { + expect(renderReceipts).toHaveBeenLastCalledWith( + expect.arrayContaining([expect.objectContaining({ id: otherUserId })]), + ); + return Promise.resolve(); + }); + } finally { + unsubscribeReceipts(); + } + }); + + it('refreshes active channels after a reconnect', async () => { + const channel = watchedChannel; + + // #region snippet docs="chat/javascript/06-state/03-channel.md" heading="Active channel" tab="JavaScript" index=1 + // While the channel is on screen + const release = channel.activate(); + + // When the channel leaves the screen + release(); + // #endregion snippet + + expect(channel.active).toBe(false); + + // An active channel is reloaded after a reconnect, an inactive one isn't + const reloadSpy = vi.spyOn(channel, 'reload'); + const reconnect = async () => { + const recovered = new Promise((resolve) => { + const { unsubscribe } = client.on('connection.recovered', () => { + unsubscribe(); + resolve(); + }); + }); + await client.closeConnection(); + await client.openConnection(); + await recovered; + }; + try { + await reconnect(); + expect(reloadSpy).not.toHaveBeenCalled(); + + const releaseAgain = channel.activate(); + await reconnect(); + releaseAgain(); + expect(reloadSpy).toHaveBeenCalledTimes(1); + } finally { + reloadSpy.mockRestore(); + } + }); +}); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/04-thread.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/04-thread.test.ts new file mode 100644 index 000000000..3b25a31be --- /dev/null +++ b/test/docs-snippets/pages/chat/javascript/06-state/04-thread.test.ts @@ -0,0 +1,197 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { Channel, LocalMessage, StreamChat, Thread } from '../../../../../../src'; +import { + disconnectClients, + getClientSideClient, + getServerClient, +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'chat/javascript/06-state/04-thread.md'; + +describe(DOCS, () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + const channelId = uniqueId('thread'); + let client: StreamChat; + let channel: Channel; + let parentMessage: LocalMessage; + let openedThread: Thread; + + beforeAll(async () => { + cleanup.users.push(userId); + client = await getClientSideClient({ id: userId }); + channel = client.channel('messaging', channelId, { members: [{ user_id: userId }] }); + cleanup.channels.push(`messaging:${channelId}`); + await channel.watch(); + const { message: parent } = await channel.sendMessage({ + message: { text: 'parent' }, + }); + await channel.sendMessage({ message: { text: 'reply', parent_id: parent.id } }); + await retry(() => { + expect(channel.messagePaginator.getItem(parent.id)).toBeDefined(); + return Promise.resolve(); + }); + parentMessage = channel.messagePaginator.getItem(parent.id) as LocalMessage; + }); + + afterAll(async () => { + client.threads.unregisterSubscriptions(); + await disconnectClients(client); + await cleanup.run(); + }); + + it('opens a thread', async () => { + // #region snippet docs="chat/javascript/06-state/04-thread.md" heading="Initialization" tab="JavaScript" index=1 + client.threads.registerSubscriptions(); + + const thread = client.threads.ensure({ channel, parentMessage }); + + thread.activate(); + // #endregion snippet + + openedThread = thread; + await retry(() => { + expect(thread.messagePaginator.items?.map((reply) => reply.text)).toEqual([ + 'reply', + ]); + return Promise.resolve(); + }); + }); + + it('queries threads', async () => { + const parentMessageId = parentMessage.id; + + // #region snippet docs="chat/javascript/06-state/04-thread.md" heading="Initialization" tab="JavaScript" index=2 + // One thread, by its parent message id + const queriedThread = await client.getThreadAndHydrate(parentMessageId, { + watch: true, + }); + + // Several threads + const { threads } = await client.queryThreadsAndHydrate({ + watch: true, + limit: 10, + }); + // #endregion snippet + + // Both resolve to the thread already opened: one instance per thread + expect(queriedThread).toBe(openedThread); + expect(threads).toEqual([openedThread]); + expect(threads[0]).toBe(openedThread); + expect(queriedThread.state.getLatestValue().replyCount).toBe(1); + }); + + it('reads thread state', () => { + const thread = openedThread; + + // #region snippet docs="chat/javascript/06-state/04-thread.md" heading="Reading" tab="JavaScript" index=1 + // Parent message, participants, reply count + const { + parentMessage: parent, + participants, + replyCount, + } = thread.state.getLatestValue(); + + // Replies: the same API as channel messages + const { items: replies, hasMoreTail } = + thread.messagePaginator.state.getLatestValue(); + // #endregion snippet + + expect(parent.id).toBe(parentMessage.id); + expect(participants?.map((participant) => participant.user_id)).toEqual([userId]); + expect(replyCount).toBe(1); + expect(replies).toHaveLength(1); + // Only known once older replies were requested + expect(typeof hasMoreTail).toBe('boolean'); + }); + + it('subscribes to replies', async () => { + const thread = openedThread; + const renderReplies = vi.fn(); + + // #region snippet docs="chat/javascript/06-state/04-thread.md" heading="Reading" tab="JavaScript" index=2 + const unsubscribe = thread.messagePaginator.state.subscribeWithSelector( + ({ items }) => ({ items }), + ({ items }) => renderReplies(items), + ); + // #endregion snippet + + try { + const { message } = await channel.sendMessage({ + message: { text: 'another reply', parent_id: thread.id }, + }); + await retry(() => { + expect(renderReplies).toHaveBeenLastCalledWith( + expect.arrayContaining([expect.objectContaining({ id: message.id })]), + ); + return Promise.resolve(); + }); + } finally { + unsubscribe(); + } + }); + + it('refreshes active threads after a reconnect', async () => { + // A fresh thread: the one opened above stays active + const { message: parent } = await channel.sendMessage({ + message: { text: 'active parent' }, + }); + await channel.sendMessage({ + message: { text: 'active reply', parent_id: parent.id }, + }); + await retry(() => { + expect(channel.messagePaginator.getItem(parent.id)?.reply_count).toBe(1); + return Promise.resolve(); + }); + const thread = client.threads.ensure({ + channel, + parentMessage: channel.messagePaginator.getItem(parent.id) as LocalMessage, + }); + + // #region snippet docs="chat/javascript/06-state/04-thread.md" heading="Active thread" tab="JavaScript" index=1 + // While the thread is on screen + const release = thread.activate(); + + // When the thread leaves the screen + release(); + // #endregion snippet + + expect(thread.state.getLatestValue().active).toBe(false); + + const reloadSpy = vi.spyOn(thread, 'reload'); + const reconnect = async () => { + const recovered = new Promise((resolve) => { + const { unsubscribe } = client.on('connection.recovered', () => { + unsubscribe(); + resolve(); + }); + }); + await client.closeConnection(); + await client.openConnection(); + await recovered; + }; + try { + // An inactive thread isn't reloaded after a reconnect... + await reconnect(); + expect(reloadSpy).not.toHaveBeenCalled(); + + // ...but the next time it's activated + const releaseStale = thread.activate(); + await retry(() => { + expect(reloadSpy).toHaveBeenCalledTimes(1); + return Promise.resolve(); + }); + + // An active thread is reloaded after a reconnect + await reconnect(); + releaseStale(); + expect(reloadSpy).toHaveBeenCalledTimes(2); + } finally { + reloadSpy.mockRestore(); + } + }); +}); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/05-channel-list.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/05-channel-list.test.ts new file mode 100644 index 000000000..7977d6466 --- /dev/null +++ b/test/docs-snippets/pages/chat/javascript/06-state/05-channel-list.test.ts @@ -0,0 +1,231 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { StreamChat } from '../../../../../../src'; +import { ChannelPaginator, ignoreEventsForUnknownChannels } from '../../../../../../src'; +import { + disconnectClients, + getClientSideClient, + getServerClient, + upsertUsers, +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { sendServerMessage } from '../../../../helpers/server'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'chat/javascript/06-state/05-channel-list.md'; +// More than one page (10 channels by default) +const CHANNEL_COUNT = 12; + +describe(DOCS, () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + const channelIds = Array.from({ length: CHANNEL_COUNT }, () => + uniqueId('channel-list'), + ); + const cid = `messaging:${channelIds[0]}`; + // `team` channels for the WebSocket events: outside the `messaging` list above + const teamTopId = uniqueId('team-top'); + const teamIgnoredId = uniqueId('team-ignored'); + const teamFetchedId = uniqueId('team-fetched'); + let client: StreamChat; + let paginator: ChannelPaginator; + + const createChannel = async (type: string, id: string) => { + await serverClient.chat.channel(type, id).getOrCreate({ + data: { created_by_id: userId, members: [{ user_id: userId }] }, + }); + cleanup.channels.push(`${type}:${id}`); + }; + + beforeAll(async () => { + cleanup.users.push(userId); + // Created before the user connects, so the client hasn't loaded any of them + await upsertUsers(serverClient, [{ id: userId }]); + for (const id of channelIds) { + await createChannel('messaging', id); + await sendServerMessage(serverClient, `messaging:${id}`, { + text: 'hi', + user_id: userId, + }); + } + for (const id of [teamFetchedId, teamIgnoredId, teamTopId]) { + await createChannel('team', id); + await sendServerMessage(serverClient, `team:${id}`, { + text: 'hi', + user_id: userId, + }); + } + client = await getClientSideClient({ id: userId }); + }); + + afterAll(async () => { + client.channelManager.unregisterSubscriptions(); + await disconnectClients(client); + await cleanup.run(); + }); + + it('loads a channel list', async () => { + // #region snippet docs="chat/javascript/06-state/05-channel-list.md" heading="Initialization" tab="JavaScript" index=1 + const channelPaginator = new ChannelPaginator({ + client, + filters: { type: 'messaging', members: { $in: [userId] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + }); + + client.channelManager.registerSubscriptions(); + client.channelManager.insertPaginator({ paginator: channelPaginator }); + + await channelPaginator.toTail(); + // #endregion snippet + + paginator = channelPaginator; + // The first page, newest message first, holding the instances `client.channel()` returns + const lastId = channelIds[CHANNEL_COUNT - 1]; + expect(channelPaginator.items).toHaveLength(10); + expect(channelPaginator.items?.[0]).toBe(client.channel('messaging', lastId)); + }); + + it('reads the channel list', () => { + const channelPaginator = paginator; + + // #region snippet docs="chat/javascript/06-state/05-channel-list.md" heading="Reading" tab="JavaScript" index=1 + const { + items: channels, + isLoading, + hasMoreTail, + } = channelPaginator.state.getLatestValue(); + // #endregion snippet + + expect(channels).toHaveLength(10); + expect(isLoading).toBe(false); + expect(hasMoreTail).toBe(true); + }); + + it('loads more channels', async () => { + const channelPaginator = paginator; + + // #region snippet docs="chat/javascript/06-state/05-channel-list.md" heading="Loading more channels" tab="JavaScript" index=1 + const { hasMoreTail } = channelPaginator.state.getLatestValue(); + + if (hasMoreTail) { + await channelPaginator.toTail(); + } + // #endregion snippet + + expect(hasMoreTail).toBe(true); + expect(channelPaginator.items).toHaveLength(CHANNEL_COUNT); + expect(channelPaginator.state.getLatestValue().hasMoreTail).toBe(false); + }); + + it('subscribes to the channel list', async () => { + const channelPaginator = paginator; + const renderChannelList = vi.fn(); + + // #region snippet docs="chat/javascript/06-state/05-channel-list.md" heading="Reading" tab="JavaScript" index=2 + const unsubscribe = channelPaginator.state.subscribeWithSelector( + ({ items }) => ({ items }), + ({ items }) => renderChannelList(items), + ); + // #endregion snippet + + try { + expect(renderChannelList).toHaveBeenCalledTimes(1); + // `message.new` moves the oldest channel to the top + await client.channel('messaging', channelIds[0]).sendMessage({ + message: { text: 'bump' }, + }); + await retry(() => { + const [items] = renderChannelList.mock.lastCall ?? []; + expect(items?.[0]?.cid).toBe(cid); + return Promise.resolve(); + }); + } finally { + unsubscribe(); + } + }); + + it('adds and removes event handlers', () => { + // #region snippet docs="chat/javascript/06-state/05-channel-list.md" heading="WebSocket events" tab="JavaScript" index=1 + const removeHandler = client.channelManager.addEventHandler({ + eventType: 'notification.message_new', + handle: ignoreEventsForUnknownChannels, + index: 0, + }); + + // Back to the default behavior + removeHandler(); + // #endregion snippet + + expect(typeof removeHandler).toBe('function'); + }); + + it('fetches unknown channels unless they are ignored', async () => { + // A `team` list showing only its first channel: the other two aren't loaded + const teamPaginator = new ChannelPaginator({ + client, + filters: { type: 'team', members: { $in: [userId] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + paginatorOptions: { pageSize: 1 }, + }); + client.channelManager.insertPaginator({ paginator: teamPaginator }); + await teamPaginator.toTail(); + expect(teamPaginator.items?.map((channel) => channel.id)).toEqual([teamTopId]); + expect(client.channelManager.get(`team:${teamIgnoredId}`)).toBeUndefined(); + + const nextMessageNew = () => + new Promise((resolve) => { + const { unsubscribe } = client.on('notification.message_new', () => { + unsubscribe(); + resolve(); + }); + }); + + try { + // With `ignoreEventsForUnknownChannels`, the event leaves the list as it is + const removeHandler = client.channelManager.addEventHandler({ + eventType: 'notification.message_new', + handle: ignoreEventsForUnknownChannels, + index: 0, + }); + const ignoredEvent = nextMessageNew(); + await sendServerMessage(serverClient, `team:${teamIgnoredId}`, { + text: 'ignored', + user_id: userId, + }); + await ignoredEvent; + removeHandler(); + expect(teamPaginator.items?.map((channel) => channel.id)).toEqual([teamTopId]); + + // By default, the unknown channel is fetched and moved to its position + await sendServerMessage(serverClient, `team:${teamFetchedId}`, { + text: 'fetched', + user_id: userId, + }); + await retry(() => { + expect(teamPaginator.items?.[0]?.id).toBe(teamFetchedId); + return Promise.resolve(); + }); + // Events run in order: the ignored channel would have been added by now + expect(teamPaginator.items?.map((channel) => channel.id)).not.toContain( + teamIgnoredId, + ); + } finally { + client.channelManager.removePaginator(teamPaginator); + } + }); + + it('re-queries registered lists after a reconnect', async () => { + const toTailSpy = vi.spyOn(paginator, 'toTail'); + try { + await client.closeConnection(); + await client.openConnection(); + await retry(() => { + expect(toTailSpy).toHaveBeenCalled(); + return Promise.resolve(); + }); + } finally { + toTailSpy.mockRestore(); + } + }); +}); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/06-thread-list.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/06-thread-list.test.ts new file mode 100644 index 000000000..59880a311 --- /dev/null +++ b/test/docs-snippets/pages/chat/javascript/06-state/06-thread-list.test.ts @@ -0,0 +1,256 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { Channel, StreamChat } from '../../../../../../src'; +import { + disconnectClients, + getClientSideClient, + getServerClient, +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { sendServerMessage } from '../../../../helpers/server'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'chat/javascript/06-state/06-thread-list.md'; + +describe(DOCS, () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + const otherUserId = uniqueId('other'); + const channelId = uniqueId('thread-list'); + let client: StreamChat; + let otherClient: StreamChat; + let channel: Channel; + let parentId: string; + + /** The other member replies in the listed thread: a new reply the list learns about. */ + const replyFromOtherUser = async (text: string) => { + const otherChannel = otherClient.channel('messaging', channelId); + await otherChannel.watch(); + await otherChannel.sendMessage({ message: { text, parent_id: parentId } }); + }; + + const waitForStaleOrder = () => + retry(() => { + expect(client.threads.state.getLatestValue().isThreadOrderStale).toBe(true); + return Promise.resolve(); + }); + + beforeAll(async () => { + cleanup.users.push(userId, otherUserId); + client = await getClientSideClient({ id: userId }); + otherClient = await getClientSideClient({ id: otherUserId }); + channel = client.channel('messaging', channelId, { + members: [{ user_id: userId }, { user_id: otherUserId }], + }); + cleanup.channels.push(`messaging:${channelId}`); + await channel.watch(); + const { message: parent } = await channel.sendMessage({ + message: { text: 'parent' }, + }); + await channel.sendMessage({ message: { text: 'reply', parent_id: parent.id } }); + parentId = parent.id; + }); + + afterAll(async () => { + client.threads.deactivate(); + client.threads.unregisterSubscriptions(); + await disconnectClients(client, otherClient); + await cleanup.run(); + }); + + it('loads the thread list', async () => { + // #region snippet docs="chat/javascript/06-state/06-thread-list.md" heading="Initialization" tab="JavaScript" index=1 + client.threads.registerSubscriptions(); + + // When the list is on screen + client.threads.activate(); + + // When the list leaves the screen + client.threads.deactivate(); + // #endregion snippet + + await retry(() => { + expect(client.threads.paginator.items?.map((thread) => thread.id)).toEqual([ + parentId, + ]); + return Promise.resolve(); + }); + expect(client.threads.state.getLatestValue().active).toBe(false); + }); + + it('reloads on activate when replies arrived while inactive', async () => { + const reloadSpy = vi.spyOn(client.threads.paginator, 'reload'); + try { + // `deactivate()` doesn't stop updates: the new reply is still noticed + await replyFromOtherUser('while inactive'); + await waitForStaleOrder(); + expect(reloadSpy).not.toHaveBeenCalled(); + + client.threads.activate(); + await retry(() => { + expect(reloadSpy).toHaveBeenCalledTimes(1); + expect(client.threads.state.getLatestValue().isThreadOrderStale).toBe(false); + return Promise.resolve(); + }); + } finally { + client.threads.deactivate(); + reloadSpy.mockRestore(); + } + }); + + it('reads the thread list', () => { + // #region snippet docs="chat/javascript/06-state/06-thread-list.md" heading="Reading" tab="JavaScript" index=1 + // The thread list + const { items: threads, isLoading } = client.threads.paginator.state.getLatestValue(); + + // Unread threads + const { unreadThreadCount } = client.threads.state.getLatestValue(); + // #endregion snippet + + expect(threads?.map((thread) => thread.id)).toEqual([parentId]); + expect(isLoading).toBe(false); + expect(typeof unreadThreadCount).toBe('number'); + }); + + it('subscribes to the thread list', () => { + const renderThreadList = vi.fn(); + + // #region snippet docs="chat/javascript/06-state/06-thread-list.md" heading="Reading" tab="JavaScript" index=2 + const unsubscribe = client.threads.paginator.state.subscribeWithSelector( + ({ items }) => ({ items }), + ({ items }) => renderThreadList(items), + ); + // #endregion snippet + + try { + expect(renderThreadList).toHaveBeenCalledTimes(1); + expect(renderThreadList).toHaveBeenLastCalledWith([ + expect.objectContaining({ id: parentId }), + ]); + } finally { + unsubscribe(); + } + }); + + it('updates listed threads, and flags the list for a reload', async () => { + const [listedThread] = client.threads.paginator.items ?? []; + const repliesBefore = listedThread?.state.getLatestValue().replyCount ?? 0; + const renderReloadPrompt = vi.fn(); + + // A listed thread updates live from the reply... + await replyFromOtherUser('live'); + await retry(() => { + expect(listedThread?.state.getLatestValue().replyCount).toBe(repliesBefore + 1); + return Promise.resolve(); + }); + // ...while the list only learns that its order may be out of date + await waitForStaleOrder(); + + // #region snippet docs="chat/javascript/06-state/06-thread-list.md" heading="WebSocket events" tab="JavaScript" index=1 + const unsubscribeBanner = client.threads.state.subscribeWithSelector( + ({ isThreadOrderStale, unseenThreadIds }) => ({ + isThreadOrderStale, + unseenThreadIds, + }), + ({ isThreadOrderStale, unseenThreadIds }) => + renderReloadPrompt(isThreadOrderStale || unseenThreadIds.length > 0), + ); + + // When the user taps the prompt + await client.threads.reload(); + // #endregion snippet + + try { + // Shown while stale, hidden again by the reload + expect(renderReloadPrompt.mock.calls).toEqual([[true], [false]]); + expect(client.threads.paginator.items?.map((thread) => thread.id)).toEqual([ + parentId, + ]); + } finally { + unsubscribeBanner(); + } + }); + + it('reloads the list after a reconnect', async () => { + const reloadSpy = vi.spyOn(client.threads, 'reload'); + try { + await client.closeConnection(); + await client.openConnection(); + await retry(() => { + expect(reloadSpy).toHaveBeenCalledWith({ force: true }); + return Promise.resolve(); + }); + } finally { + reloadSpy.mockRestore(); + } + }); +}); + +describe(`${DOCS} (pagination)`, () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + const channelId = uniqueId('thread-pages'); + const cid = `messaging:${channelId}`; + // One page (25 threads) plus two + const THREAD_COUNT = 27; + let client: StreamChat; + + beforeAll(async () => { + cleanup.users.push(userId); + client = await getClientSideClient({ id: userId }); + await serverClient.chat.channel('messaging', channelId).getOrCreate({ + data: { created_by_id: userId, members: [{ user_id: userId }] }, + }); + cleanup.channels.push(cid); + // Parent + reply by the user, in parallel batches + for (let batch = 0; batch < THREAD_COUNT; batch += 9) { + await Promise.all( + Array.from({ length: Math.min(9, THREAD_COUNT - batch) }, async (_, i) => { + const parent = await sendServerMessage(serverClient, cid, { + text: `parent ${batch + i}`, + user_id: userId, + }); + await sendServerMessage(serverClient, cid, { + text: `reply ${batch + i}`, + user_id: userId, + parent_id: parent.id, + }); + }), + ); + } + }); + + afterAll(async () => { + client.threads.deactivate(); + client.threads.unregisterSubscriptions(); + await disconnectClients(client); + await cleanup.run(); + }); + + it('loads more threads', async () => { + // Nothing happens before `activate()` loaded the first page + await client.threads.paginator.toTail(); + expect(client.threads.paginator.items).toBeUndefined(); + + client.threads.registerSubscriptions(); + client.threads.activate(); + await retry(() => { + expect(client.threads.paginator.items).toHaveLength(25); + return Promise.resolve(); + }); + + // #region snippet docs="chat/javascript/06-state/06-thread-list.md" heading="Loading more threads" tab="JavaScript" index=1 + const { hasMoreTail } = client.threads.paginator.state.getLatestValue(); + + if (hasMoreTail) { + await client.threads.paginator.toTail(); + } + // #endregion snippet + + expect(hasMoreTail).toBe(true); + expect(client.threads.paginator.items).toHaveLength(THREAD_COUNT); + expect(client.threads.paginator.state.getLatestValue().hasMoreTail).toBe(false); + }); +}); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/07-users.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/07-users.test.ts new file mode 100644 index 000000000..7b62ba81f --- /dev/null +++ b/test/docs-snippets/pages/chat/javascript/06-state/07-users.test.ts @@ -0,0 +1,87 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { StreamChat } from '../../../../../../src'; +import { + disconnectClients, + getClientSideClient, + getServerClient, + upsertUsers, +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; + +const DOCS = 'chat/javascript/06-state/07-users.md'; + +describe(DOCS, () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + const otherUserId = uniqueId('other'); + const channelId = uniqueId('users'); + let client: StreamChat; + + beforeAll(async () => { + cleanup.users.push(userId, otherUserId); + await upsertUsers(serverClient, [{ id: otherUserId, name: 'Other' }]); + // Mute and block before connecting: `connectUser` brings them in + const setupClient = await getClientSideClient({ id: userId, name: 'Me' }); + await setupClient.moderation.mute({ target_ids: [otherUserId] }); + await setupClient.blockUser(otherUserId); + await disconnectClients(setupClient); + + client = await getClientSideClient({ id: userId, name: 'Me' }); + // A channel with the other user, so the client has seen them + const channel = client.channel('messaging', channelId, { + members: [{ user_id: userId }, { user_id: otherUserId }], + }); + cleanup.channels.push(`messaging:${channelId}`); + await channel.watch(); + }); + + afterAll(async () => { + await disconnectClients(client); + await cleanup.run(); + }); + + it('reads users', () => { + // #region snippet docs="chat/javascript/06-state/07-users.md" heading="Reading" tab="JavaScript" index=1 + // The current user (not reactive, updated on `user.updated`) + const currentUser = client.user; + + // Any user the client has seen (not reactive) + const otherUser = client.state.users[otherUserId]; + + // Muted users + const { mutedUsers } = client.mutedUsersStore.getLatestValue(); + + // Blocked user ids + const { userIds } = client.blockedUsers.getLatestValue(); + // #endregion snippet + + expect(currentUser?.id).toBe(userId); + expect(otherUser?.name).toBe('Other'); + expect(mutedUsers.map((mute) => mute.target?.id)).toEqual([otherUserId]); + expect(userIds).toEqual([otherUserId]); + }); + + it('subscribes to muted and blocked users', () => { + const renderMutedUsers = vi.fn(); + const renderBlockedUsers = vi.fn(); + + // #region snippet docs="chat/javascript/06-state/07-users.md" heading="Reading" tab="JavaScript" index=2 + const unsubscribeMutes = client.mutedUsersStore.subscribe(({ mutedUsers }) => + renderMutedUsers(mutedUsers), + ); + + const unsubscribeBlocks = client.blockedUsers.subscribe(({ userIds }) => + renderBlockedUsers(userIds), + ); + // #endregion snippet + + unsubscribeMutes(); + unsubscribeBlocks(); + expect(renderMutedUsers).toHaveBeenCalledWith([ + expect.objectContaining({ target: expect.objectContaining({ id: otherUserId }) }), + ]); + expect(renderBlockedUsers).toHaveBeenCalledWith([otherUserId]); + }); +}); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/08-connection.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/08-connection.test.ts new file mode 100644 index 000000000..d59947cc5 --- /dev/null +++ b/test/docs-snippets/pages/chat/javascript/06-state/08-connection.test.ts @@ -0,0 +1,153 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { StreamChat } from '../../../../../../src'; +import { + disconnectClients, + getClientSideClient, + getServerClient, +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'chat/javascript/06-state/08-connection.md'; + +describe(DOCS, () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + let client: StreamChat; + + beforeAll(async () => { + cleanup.users.push(userId); + client = await getClientSideClient({ id: userId }); + }); + + afterAll(async () => { + await disconnectClients(client); + await cleanup.run(); + }); + + it('reads the network and socket status', () => { + // #region snippet docs="chat/javascript/06-state/08-connection.md" heading="Reading" tab="JavaScript" index=1 + // "You're offline": `undefined` means unknown, so check `=== false` + const { isOnline } = client.networkConnection.state.getLatestValue(); + const isOffline = isOnline === false; + + // "Reconnecting…" + const { isHealthy } = client.wsConnection.state.getLatestValue(); + // #endregion snippet + + expect(isOffline).toBe(false); + expect(isHealthy).toBe(true); + }); + + it('subscribes to the network and socket status', async () => { + const renderOfflineBanner = vi.fn(); + const renderReconnecting = vi.fn(); + + // #region snippet docs="chat/javascript/06-state/08-connection.md" heading="Reading" tab="JavaScript" index=2 + const unsubscribeNetwork = client.networkConnection.state.subscribeWithSelector( + (state) => ({ isOnline: state.isOnline }), + ({ isOnline }) => renderOfflineBanner(isOnline === false), + ); + + const unsubscribeSocket = client.wsConnection.state.subscribeWithSelector( + (state) => ({ isHealthy: state.isHealthy }), + ({ isHealthy }) => renderReconnecting(!isHealthy), + ); + // #endregion snippet + + try { + expect(renderOfflineBanner).toHaveBeenLastCalledWith(false); + expect(renderReconnecting).toHaveBeenLastCalledWith(false); + + // A dropped socket shows "Reconnecting…", a new one hides it again + await client.closeConnection(); + expect(renderReconnecting).toHaveBeenLastCalledWith(true); + await client.openConnection(); + await retry(() => { + expect(renderReconnecting).toHaveBeenLastCalledWith(false); + return Promise.resolve(); + }); + } finally { + unsubscribeNetwork(); + unsubscribeSocket(); + } + }); + + it('runs app refreshes after recovery', async () => { + const refreshAppData = vi.fn(); + const onSpy = vi.spyOn(client, 'on'); + + // #region snippet docs="chat/javascript/06-state/08-connection.md" heading="After a reconnect" tab="JavaScript" index=1 + client.on('connection.recovered', () => { + refreshAppData(); + }); + // #endregion snippet + + try { + expect(refreshAppData).not.toHaveBeenCalled(); + await client.closeConnection(); + await client.openConnection(); + await retry(() => { + expect(refreshAppData).toHaveBeenCalledTimes(1); + return Promise.resolve(); + }); + } finally { + onSpy.mock.results.forEach(({ value }) => value.unsubscribe()); + onSpy.mockRestore(); + } + }); + + it('registers a network status reporter', async () => { + // A dummy platform network API + let online = true; + const listeners = new Set<(isOnline: boolean) => void>(); + const removeListener = vi.fn(); + const myCustomNetworkMonitor = { + isOnline: () => online, + addListener: (listener: (isOnline: boolean) => void) => { + listeners.add(listener); + return () => { + removeListener(); + listeners.delete(listener); + }; + }, + }; + const setOnline = (value: boolean) => { + online = value; + listeners.forEach((listener) => listener(value)); + }; + + // #region snippet docs="chat/javascript/06-state/08-connection.md" heading="Network status reporter" tab="JavaScript" index=1 + client.networkConnection.setStatusReporter((onStatusChange) => { + // Report the current status right away, then on every change + onStatusChange(myCustomNetworkMonitor.isOnline()); + + // Return the function that removes the listener + return myCustomNetworkMonitor.addListener((isOnline) => onStatusChange(isOnline)); + }); + // #endregion snippet + + try { + expect(client.networkConnection.state.getLatestValue().isOnline).toBe(true); + + // The device goes offline: the socket closes right away + setOnline(false); + expect(client.networkConnection.state.getLatestValue().isOnline).toBe(false); + expect(client.wsConnection.state.getLatestValue().isHealthy).toBe(false); + + // Back online: the socket reconnects + setOnline(true); + expect(client.networkConnection.state.getLatestValue().isOnline).toBe(true); + await retry(() => { + expect(client.wsConnection.state.getLatestValue().isHealthy).toBe(true); + return Promise.resolve(); + }); + } finally { + // Replacing the reporter removes the listener + client.networkConnection.setStatusReporter(null); + expect(removeListener).toHaveBeenCalledTimes(1); + } + }); +}); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/09-composer.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/09-composer.test.ts new file mode 100644 index 000000000..fa8b83253 --- /dev/null +++ b/test/docs-snippets/pages/chat/javascript/06-state/09-composer.test.ts @@ -0,0 +1,112 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { Channel, StreamChat } from '../../../../../../src'; +import { + disconnectClients, + getClientSideClient, + getServerClient, +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'chat/javascript/06-state/09-composer.md'; + +describe(DOCS, () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + const channelId = uniqueId('composer'); + let client: StreamChat; + let channel: Channel; + + beforeAll(async () => { + cleanup.users.push(userId); + client = await getClientSideClient({ id: userId }); + channel = client.channel('messaging', channelId); + cleanup.channels.push(`messaging:${channelId}`); + await channel.watch(); + }); + + afterAll(async () => { + await disconnectClients(client); + await cleanup.run(); + }); + + it('reads the composer state', () => { + channel.messageComposer.textComposer.setText('Hello there'); + + // #region snippet docs="chat/javascript/06-state/09-composer.md" heading="Reading" tab="JavaScript" index=1 + const { messageComposer } = channel; + + // Typed text, mentions and the active command + const { text, mentions, command } = + messageComposer.textComposer.state.getLatestValue(); + + // Attachments + const { attachments } = messageComposer.attachmentManager.state.getLatestValue(); + + // Quoted or edited message + const { quotedMessage, editedMessage } = messageComposer.state.getLatestValue(); + // #endregion snippet + + expect(text).toBe('Hello there'); + expect(mentions ?? []).toEqual([]); + expect(command ?? null).toBeNull(); + expect(attachments).toEqual([]); + expect(quotedMessage).toBeNull(); + expect(editedMessage).toBeNull(); + }); + + it('subscribes to the composer state', () => { + const { messageComposer } = channel; + const renderInput = vi.fn(); + const renderAttachmentPreviews = vi.fn(); + + // #region snippet docs="chat/javascript/06-state/09-composer.md" heading="Reading" tab="JavaScript" index=2 + const unsubscribeText = messageComposer.textComposer.state.subscribeWithSelector( + (state) => ({ text: state.text }), + ({ text }) => renderInput(text), + ); + + const unsubscribeAttachments = + messageComposer.attachmentManager.state.subscribeWithSelector( + (state) => ({ attachments: state.attachments }), + ({ attachments }) => renderAttachmentPreviews(attachments), + ); + // #endregion snippet + + try { + expect(renderAttachmentPreviews).toHaveBeenLastCalledWith([]); + messageComposer.textComposer.setText('Typing more'); + expect(renderInput).toHaveBeenLastCalledWith('Typing more'); + } finally { + unsubscribeText(); + unsubscribeAttachments(); + } + }); + + it('sends the composed message', async () => { + const { messageComposer } = channel; + + // #region snippet docs="chat/javascript/06-state/09-composer.md" heading="Send message" tab="JavaScript" index=1 + // Usually called from your input's change handler + messageComposer.textComposer.setText('Hello!'); + + // "sent", "nothing-to-send" (the composer keeps its content) or "failed" + const result = await messageComposer.send(); + // #endregion snippet + + expect(result).toBe('sent'); + // The composer is cleared, and the message is in the channel + expect(messageComposer.textComposer.text).toBe(''); + await retry(() => { + expect(channel.messagePaginator.items?.map((message) => message.text)).toContain( + 'Hello!', + ); + return Promise.resolve(); + }); + + // Nothing left to send + expect(await messageComposer.send()).toBe('nothing-to-send'); + }); +}); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/10-polls.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/10-polls.test.ts new file mode 100644 index 000000000..bd249bd42 --- /dev/null +++ b/test/docs-snippets/pages/chat/javascript/06-state/10-polls.test.ts @@ -0,0 +1,127 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { StreamChat } from '../../../../../../src'; +import { + disconnectClients, + getClientSideClient, + getServerClient, +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { deletePollsCreatedBy } from '../../../../helpers/server'; +import { retry, waitForChannelTypePropagation } from '../../../../helpers/wait'; + +const DOCS = 'chat/javascript/06-state/10-polls.md'; + +describe(DOCS, () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + const channelId = uniqueId('polls'); + // Polls are off for `messaging` in the test app: use a channel type with polls enabled. + const channelType = uniqueId('polls'); + const pollName = uniqueId('poll'); + let client: StreamChat; + let pollId: string; + let messageId: string; + let optionId: string; + + beforeAll(async () => { + cleanup.users.push(userId); + cleanup.add(() => deletePollsCreatedBy(serverClient, userId)); + await serverClient.chat.createChannelType({ + name: channelType, + automod: 'disabled', + automod_behavior: 'flag', + max_message_length: 5000, + polls: true, + }); + cleanup.channelTypes.push(channelType); + await waitForChannelTypePropagation(); + + // Create the poll on another client, so this page's client hasn't loaded it + const setupClient = await getClientSideClient({ id: userId }); + const setupChannel = setupClient.channel(channelType, channelId, { + members: [{ user_id: userId }], + }); + cleanup.channels.push(`${channelType}:${channelId}`); + await setupChannel.watch(); + const { poll } = await setupClient.createPoll({ + name: pollName, + options: [{ text: 'A' }, { text: 'B' }], + }); + const { message } = await setupChannel.sendMessage({ + message: { text: 'Vote!', poll_id: poll.id }, + }); + pollId = poll.id; + messageId = message.id; + optionId = poll.options[0].id; + await disconnectClients(setupClient); + + client = await getClientSideClient({ id: userId }); + }); + + afterAll(async () => { + client.polls.unregisterSubscriptions(); + await disconnectClients(client); + await cleanup.run(); + }); + + it('fetches a poll', async () => { + expect(client.polls.fromState(pollId)).toBeUndefined(); + + // #region snippet docs="chat/javascript/06-state/10-polls.md" heading="Initialization" tab="JavaScript" index=1 + client.polls.registerSubscriptions(); + + // A poll that isn't loaded yet: fetches it + const poll = await client.polls.getPoll(pollId); + // #endregion snippet + + expect(poll?.id).toBe(pollId); + expect(client.polls.fromState(pollId)).toBe(poll); + }); + + it('reads a poll', () => { + // #region snippet docs="chat/javascript/06-state/10-polls.md" heading="Reading" tab="JavaScript" index=1 + // A loaded poll, `undefined` if it isn't loaded + const pollInstance = client.polls.fromState(pollId); + + const { name, options, vote_counts_by_option, ownVotesByOptionId, is_closed } = + pollInstance?.state.getLatestValue() ?? {}; + // #endregion snippet + + expect(name).toBe(pollName); + expect(options?.map((option) => option.text)).toEqual(['A', 'B']); + expect(vote_counts_by_option ?? {}).toEqual({}); + expect(ownVotesByOptionId).toEqual({}); + // Omitted by the API until the poll is closed + expect(is_closed ?? false).toBe(false); + }); + + it('subscribes to a poll', async () => { + // Watching the channel delivers its poll events + await client.channel(channelType, channelId).watch(); + const pollInstance = client.polls.fromState(pollId); + const renderVotes = vi.fn(); + + // #region snippet docs="chat/javascript/06-state/10-polls.md" heading="Reading" tab="JavaScript" index=2 + const unsubscribe = pollInstance?.state.subscribeWithSelector( + (state) => ({ vote_counts_by_option: state.vote_counts_by_option }), + ({ vote_counts_by_option }) => renderVotes(vote_counts_by_option), + ); + // #endregion snippet + + try { + expect(unsubscribe).toBeDefined(); + await client.castPollVote( + { message_id: messageId, poll_id: pollId }, + { vote: { option_id: optionId } }, + ); + await retry(() => { + expect(renderVotes).toHaveBeenLastCalledWith({ [optionId]: 1 }); + return Promise.resolve(); + }); + } finally { + unsubscribe?.(); + } + }); +}); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/11-reminders.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/11-reminders.test.ts new file mode 100644 index 000000000..7997e46e6 --- /dev/null +++ b/test/docs-snippets/pages/chat/javascript/06-state/11-reminders.test.ts @@ -0,0 +1,150 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { StreamChat } from '../../../../../../src'; +import { + disconnectClients, + getClientSideClient, + getServerClient, +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; +import { retry } from '../../../../helpers/wait'; + +const DOCS = 'chat/javascript/06-state/11-reminders.md'; + +describe(DOCS, () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + const channelId = uniqueId('reminders'); + let client: StreamChat; + let messageId: string; + let otherMessageId: string; + + beforeAll(async () => { + cleanup.users.push(userId); + client = await getClientSideClient({ id: userId }); + const channel = client.channel('messaging', channelId, { + members: [{ user_id: userId }], + }); + cleanup.channels.push(`messaging:${channelId}`); + await channel.create(); + // Reminders are off for `messaging` in the test app: enable them for this channel only. + await serverClient.chat.channel('messaging', channelId).updateChannelPartial({ + set: { config_overrides: { user_message_reminders: true } }, + }); + messageId = (await channel.sendMessage({ message: { text: 'remind me' } })).message + .id; + otherMessageId = (await channel.sendMessage({ message: { text: 'me too' } })).message + .id; + // Created before the manager listens: only the query can bring it in + await client.createReminder({ message_id: messageId }); + }); + + afterAll(async () => { + client.reminders.unregisterSubscriptions(); + await disconnectClients(client); + await cleanup.run(); + }); + + it('loads reminders', async () => { + expect(client.reminders.reminders.size).toBe(0); + + // #region snippet docs="chat/javascript/06-state/11-reminders.md" heading="Initialization" tab="JavaScript" index=1 + client.reminders.registerSubscriptions(); + + await client.reminders.queryNextReminders(); + // #endregion snippet + + expect(client.reminders.paginator.lastQueryError).toBeUndefined(); + expect([...client.reminders.reminders.keys()]).toEqual([messageId]); + }); + + it('reads reminders', () => { + // #region snippet docs="chat/javascript/06-state/11-reminders.md" heading="Reading" tab="JavaScript" index=1 + // Every known reminder, keyed by message id + const { reminders } = client.reminders.state.getLatestValue(); + + // The reminder list + const { items, isLoading, hasMoreTail } = + client.reminders.paginator.state.getLatestValue(); + // #endregion snippet + + expect(reminders.get(messageId)?.id).toBe(messageId); + expect(items?.map((item) => item.message_id)).toEqual([messageId]); + expect(isLoading).toBe(false); + expect(hasMoreTail).toBe(false); + }); + + it('subscribes to reminders', async () => { + const renderReminders = vi.fn(); + + // #region snippet docs="chat/javascript/06-state/11-reminders.md" heading="Reading" tab="JavaScript" index=2 + const unsubscribe = client.reminders.state.subscribeWithSelector( + (state) => ({ reminders: state.reminders }), + ({ reminders }) => renderReminders([...reminders.values()]), + ); + // #endregion snippet + + try { + expect(renderReminders).toHaveBeenLastCalledWith([ + expect.objectContaining({ id: messageId }), + ]); + // A reminder created elsewhere arrives with its event + await client.createReminder({ message_id: otherMessageId }); + await retry(() => { + expect(renderReminders).toHaveBeenLastCalledWith([ + expect.objectContaining({ id: messageId }), + expect.objectContaining({ id: otherMessageId }), + ]); + return Promise.resolve(); + }); + } finally { + unsubscribe(); + } + }); + + it('reads a single reminder', async () => { + const oneHour = 60 * 60 * 1000; + // Give the reminder a due date + await client.reminders.updateReminder( + { message_id: messageId }, + { remind_at: new Date(Date.now() + oneHour) }, + ); + const renderDueIn = vi.fn(); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + // #region snippet docs="chat/javascript/06-state/11-reminders.md" heading="Reading" tab="JavaScript" index=3 + const reminder = client.reminders.getFromState(messageId); + + // Due date (nanoseconds) and time left (milliseconds), `null` without a due date + const reminderState = reminder?.state.getLatestValue(); + console.log(reminderState?.remind_at, reminderState?.timeLeftMs); + + const unsubscribeReminder = reminder?.state.subscribeWithSelector( + ({ timeLeftMs }) => ({ timeLeftMs }), + ({ timeLeftMs }) => renderDueIn(timeLeftMs), + ); + // #endregion snippet + + try { + expect(reminder?.id).toBe(messageId); + const [remindAt, timeLeft] = logSpy.mock.calls[0] ?? []; + expect(typeof remindAt).toBe('number'); + expect(timeLeft).toBeGreaterThan(oneHour - 60000); + expect(timeLeft).toBeLessThanOrEqual(oneHour); + expect(renderDueIn).toHaveBeenLastCalledWith(timeLeft); + + // The countdown is reactive: each refresh publishes the new time left + await new Promise((resolve) => setTimeout(resolve, 5)); + reminder?.refreshTimeLeft(); + const [latest] = renderDueIn.mock.lastCall ?? []; + expect(latest).toBeLessThan(timeLeft); + } finally { + unsubscribeReminder?.(); + } + } finally { + logSpy.mockRestore(); + } + }); +}); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/12-notifications.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/12-notifications.test.ts new file mode 100644 index 000000000..b10c475c3 --- /dev/null +++ b/test/docs-snippets/pages/chat/javascript/06-state/12-notifications.test.ts @@ -0,0 +1,81 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { StreamChat } from '../../../../../../src'; +import { + disconnectClients, + getClientSideClient, + getServerClient, +} from '../../../../helpers/clients'; +import { Cleanup } from '../../../../helpers/cleanup'; +import { uniqueId } from '../../../../helpers/ids'; + +const DOCS = 'chat/javascript/06-state/12-notifications.md'; + +describe(DOCS, () => { + const serverClient = getServerClient(); + const cleanup = new Cleanup(serverClient); + const userId = uniqueId('user'); + let client: StreamChat; + + beforeAll(async () => { + cleanup.users.push(userId); + client = await getClientSideClient({ id: userId }); + }); + + afterAll(async () => { + await disconnectClients(client); + await cleanup.run(); + }); + + it('reads notifications', () => { + client.notifications.addError({ + message: 'Upload failed', + origin: { emitter: 'test' }, + }); + + // #region snippet docs="chat/javascript/06-state/12-notifications.md" heading="Reading" tab="JavaScript" index=1 + const { notifications } = client.notifications.store.getLatestValue(); + + // Filtered by severity + const errors = client.notifications.error; + // #endregion snippet + + expect(notifications.map((n) => n.message)).toEqual(['Upload failed']); + expect(errors.map((n) => n.message)).toEqual(['Upload failed']); + client.notifications.clear(); + }); + + it('subscribes, adds and removes notifications', () => { + const renderToasts = vi.fn(); + + // #region snippet docs="chat/javascript/06-state/12-notifications.md" heading="Reading" tab="JavaScript" index=2 + const unsubscribe = client.notifications.store.subscribeWithSelector( + (state) => ({ notifications: state.notifications }), + ({ notifications }) => renderToasts(notifications), + ); + // #endregion snippet + + try { + expect(renderToasts).toHaveBeenLastCalledWith([]); + + // #region snippet docs="chat/javascript/06-state/12-notifications.md" heading="Add and dismiss notifications" tab="JavaScript" index=1 + const notificationId = client.notifications.addSuccess({ + message: 'Settings saved', + origin: { emitter: 'SettingsPage' }, + }); + + client.notifications.remove(notificationId); + // #endregion snippet + + expect(renderToasts).toHaveBeenCalledWith([ + expect.objectContaining({ + id: notificationId, + message: 'Settings saved', + severity: 'success', + }), + ]); + expect(renderToasts).toHaveBeenLastCalledWith([]); + } finally { + unsubscribe(); + } + }); +}); From 950e9dd1bd34aecf3e895bfe682be50d5a6c7cbf Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 19 Oct 2026 11:36:56 -0500 Subject: [PATCH 2/3] chore: add pagination info to state docs --- .../javascript/06-state/01-overview.test.ts | 67 +++++++++++++++++++ .../javascript/06-state/02-messages.test.ts | 49 ++++++++++++++ .../06-state/05-channel-list.test.ts | 54 +++++++++++++++ .../06-state/06-thread-list.test.ts | 28 ++++++++ .../javascript/06-state/11-reminders.test.ts | 39 +++++++++++ 5 files changed, 237 insertions(+) diff --git a/test/docs-snippets/pages/chat/javascript/06-state/01-overview.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/01-overview.test.ts index 0db7bcf71..eef1b1cfe 100644 --- a/test/docs-snippets/pages/chat/javascript/06-state/01-overview.test.ts +++ b/test/docs-snippets/pages/chat/javascript/06-state/01-overview.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { Channel, StreamChat } from '../../../../../../src'; +import { ChannelPaginator } from '../../../../../../src'; import { disconnectClients, getClientSideClient, @@ -74,4 +75,70 @@ describe('javascript/06-state/01-overview.md', () => { expect(renderChannel).toHaveBeenCalledTimes(1); expect(renderHeader).toHaveBeenCalledTimes(1); }); + + it('paginates a list', async () => { + const pagedChannelId = uniqueId('state'); + const pagedChannel = client.channel('messaging', pagedChannelId); + cleanup.channels.push(`messaging:${pagedChannelId}`); + await pagedChannel.create(); + for (let i = 0; i < 3; i++) { + await pagedChannel.sendMessage({ message: { text: `message ${i}` } }); + } + // Two messages per page, so there is an older page to load + pagedChannel.messagePaginator.updateConfig({ pageSize: 2 }); + await pagedChannel.watch(); + const channel = pagedChannel; + const toTailSpy = vi.spyOn(channel.messagePaginator, 'toTail'); + + // #region snippet docs="chat/javascript/06-state/01-overview.md" heading="Pagination" tab="JavaScript" index=1 + const paginator = channel.messagePaginator; + + const { items, isLoading, hasMoreTail, lastQueryError } = + paginator.state.getLatestValue(); + + // Load the next page + if (paginator.hasMoreTail) { + await paginator.toTail(); + } + + // Load the first page again + await paginator.reload(); + // #endregion snippet + + expect(items).toHaveLength(2); + expect(isLoading).toBe(false); + expect(hasMoreTail).toBe(true); + expect(lastQueryError).toBeUndefined(); + // `toTail()` loaded the older page, `reload()` (itself a `toTail`) the first page again + expect(toTailSpy).toHaveBeenCalledTimes(2); + expect(paginator.items?.map((message) => message.text)).toEqual([ + 'message 1', + 'message 2', + ]); + expect(paginator.hasMoreTail).toBe(true); + }); + + it('reports a failed page in lastQueryError', async () => { + const paginator = new ChannelPaginator({ + client, + filters: { members: { $in: [userId] } }, + paginatorOptions: { retryCount: 0 }, + }); + const error = new Error('network down'); + const querySpy = vi + .spyOn(client, 'queryChannelsAndHydrate') + .mockRejectedValueOnce(error); + try { + // Resolves rather than rejects + await paginator.toTail(); + expect(paginator.lastQueryError).toBe(error); + + // Calling it again retries, and clears the error + await paginator.toTail(); + expect(paginator.lastQueryError).toBeUndefined(); + expect(querySpy).toHaveBeenCalledTimes(2); + } finally { + querySpy.mockRestore(); + } + }); }); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/02-messages.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/02-messages.test.ts index c37af952d..33c9c3059 100644 --- a/test/docs-snippets/pages/chat/javascript/06-state/02-messages.test.ts +++ b/test/docs-snippets/pages/chat/javascript/06-state/02-messages.test.ts @@ -240,4 +240,53 @@ describe(DOCS, () => { releaseThread(); } }); + + it('configures the page size', async () => { + const channel = watchedChannel; + const messageAt = (index: number) => { + const message = channel.messagePaginator.getItem(messageIds[index]); + if (!message) throw new Error('message not loaded'); + return message; + }; + const parentMessage = messageAt(1); + // The defaults + expect(channel.messagePaginator.pageSize).toBe(100); + expect(channel.pinnedMessagesPaginator.pageSize).toBe(100); + expect( + client.threads.ensure({ channel, parentMessage: messageAt(2) }).messagePaginator + .pageSize, + ).toBe(50); + + try { + // #region snippet docs="chat/javascript/06-state/02-messages.md" heading="Configure the query" tab="JavaScript" index=1 + client.config.set({ + // Every channel message list and thread reply list + messagePaginator: { pageSize: 50 }, + // Overrides for thread replies and pinned messages + thread: { messagePaginator: { pageSize: 25 } }, + channel: { pinnedMessagesPaginator: { pageSize: 20 } }, + }); + // #endregion snippet + + const thread = client.threads.ensure({ channel, parentMessage }); + expect(channel.messagePaginator.pageSize).toBe(50); + expect(thread.messagePaginator.pageSize).toBe(25); + expect(channel.pinnedMessagesPaginator.pageSize).toBe(20); + + // The first page `watch()` loads has the same size + const otherClient = await getClientSideClient({ id: userId }); + try { + otherClient.config.set({ messagePaginator: { pageSize: 15 } }); + const otherChannel = otherClient.channel('messaging', channelId); + await otherChannel.watch(); + expect(otherChannel.messagePaginator.items).toHaveLength(15); + expect(otherChannel.messagePaginator.hasMoreTail).toBe(true); + } finally { + await disconnectClients(otherClient); + } + } finally { + client.config.reset(); + } + expect(channel.messagePaginator.pageSize).toBe(100); + }); }); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/05-channel-list.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/05-channel-list.test.ts index 7977d6466..71a4385ee 100644 --- a/test/docs-snippets/pages/chat/javascript/06-state/05-channel-list.test.ts +++ b/test/docs-snippets/pages/chat/javascript/06-state/05-channel-list.test.ts @@ -228,4 +228,58 @@ describe(DOCS, () => { toTailSpy.mockRestore(); } }); + + it('configures the query', async () => { + const querySpy = vi.spyOn(client, 'queryChannelsAndHydrate'); + + // #region snippet docs="chat/javascript/06-state/05-channel-list.md" heading="Configure the query" tab="JavaScript" index=1 + const channelPaginator = new ChannelPaginator({ + client, + filters: { type: 'messaging', members: { $in: [userId] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + requestOptions: { message_limit: 25, member_limit: 10, presence: true }, + paginatorOptions: { pageSize: 20 }, + }); + // #endregion snippet + + await channelPaginator.toTail(); + expect(querySpy).toHaveBeenCalledWith( + expect.objectContaining({ + filter_conditions: { type: 'messaging', members: { $in: [userId] } }, + limit: 20, + member_limit: 10, + message_limit: 25, + presence: true, + }), + expect.anything(), + ); + // Every channel fits on one page of 20 + expect(channelPaginator.items).toHaveLength(CHANNEL_COUNT); + expect(channelPaginator.hasMoreTail).toBe(false); + }); + + it('changes the query of a loaded list', async () => { + const archivedId = channelIds[0]; + await client.channel('messaging', archivedId).archive(); + const channelPaginator = new ChannelPaginator({ + client, + filters: { type: 'messaging', members: { $in: [userId] } }, + sort: [{ field: 'last_message_at', direction: -1 }], + }); + await channelPaginator.toTail(); + expect(channelPaginator.items).toHaveLength(10); + + // #region snippet docs="chat/javascript/06-state/05-channel-list.md" heading="Configure the query" tab="JavaScript" index=2 + channelPaginator.staticFilters = { + type: 'messaging', + members: { $in: [userId] }, + archived: true, + }; + channelPaginator.sort = [{ field: 'created_at', direction: -1 }]; + + await channelPaginator.reload(); + // #endregion snippet + + expect(channelPaginator.items?.map((channel) => channel.id)).toEqual([archivedId]); + }); }); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/06-thread-list.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/06-thread-list.test.ts index 59880a311..d9ee05458 100644 --- a/test/docs-snippets/pages/chat/javascript/06-state/06-thread-list.test.ts +++ b/test/docs-snippets/pages/chat/javascript/06-state/06-thread-list.test.ts @@ -253,4 +253,32 @@ describe(`${DOCS} (pagination)`, () => { expect(client.threads.paginator.items).toHaveLength(THREAD_COUNT); expect(client.threads.paginator.state.getLatestValue().hasMoreTail).toBe(false); }); + + it('configures the page size', async () => { + // A client whose thread list hasn't loaded yet + const client = await getClientSideClient({ id: userId }); + const querySpy = vi.spyOn(client, 'queryThreadsAndHydrate'); + try { + expect(client.threads.paginator.pageSize).toBe(25); + + // #region snippet docs="chat/javascript/06-state/06-thread-list.md" heading="Configure the query" tab="JavaScript" index=1 + client.threads.paginator.updateConfig({ pageSize: 10 }); + // #endregion snippet + + client.threads.registerSubscriptions(); + client.threads.activate(); + await retry(() => { + expect(client.threads.paginator.items).toHaveLength(10); + return Promise.resolve(); + }); + expect(client.threads.paginator.hasMoreTail).toBe(true); + expect(querySpy).toHaveBeenCalledWith( + expect.objectContaining({ limit: 10, participant_limit: 10, reply_limit: 10 }), + ); + } finally { + client.threads.deactivate(); + client.threads.unregisterSubscriptions(); + await disconnectClients(client); + } + }); }); diff --git a/test/docs-snippets/pages/chat/javascript/06-state/11-reminders.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/11-reminders.test.ts index 7997e46e6..7f8120def 100644 --- a/test/docs-snippets/pages/chat/javascript/06-state/11-reminders.test.ts +++ b/test/docs-snippets/pages/chat/javascript/06-state/11-reminders.test.ts @@ -147,4 +147,43 @@ describe(DOCS, () => { logSpy.mockRestore(); } }); + + it('configures the query', async () => { + const otherChannelId = uniqueId('reminders'); + const otherChannel = client.channel('messaging', otherChannelId, { + members: [{ user_id: userId }], + }); + cleanup.channels.push(`messaging:${otherChannelId}`); + await otherChannel.create(); + await serverClient.chat.channel('messaging', otherChannelId).updateChannelPartial({ + set: { config_overrides: { user_message_reminders: true } }, + }); + const { message: otherChannelMessage } = await otherChannel.sendMessage({ + message: { text: 'elsewhere' }, + }); + await client.createReminder({ message_id: otherChannelMessage.id }); + const channel = client.channel('messaging', channelId); + const querySpy = vi.spyOn(client, 'queryReminders'); + + // #region snippet docs="chat/javascript/06-state/11-reminders.md" heading="Configure the query" tab="JavaScript" index=1 + client.reminders.paginator.filters = { channel_cid: { $eq: channel.cid } }; + client.reminders.paginator.sort = [{ field: 'remind_at', direction: 1 }]; + client.reminders.paginator.updateConfig({ pageSize: 25 }); + + await client.reminders.queryNextReminders(); + // #endregion snippet + + expect(client.reminders.paginator.lastQueryError).toBeUndefined(); + expect(querySpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + filter: { channel_cid: { $eq: channel.cid } }, + limit: 25, + sort: [{ field: 'remind_at', direction: 1 }], + }), + ); + // Only this channel's reminders, the other channel's is filtered out + const listed = client.reminders.paginator.items?.map((r) => r.message_id) ?? []; + expect(listed.length).toBeGreaterThan(0); + expect(listed).not.toContain(otherChannelMessage.id); + }); }); From e5db4b5c771b612758db31657d9de3780b18bc16 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 20 Oct 2026 09:25:52 -0500 Subject: [PATCH 3/3] chore: update tests with latest changes from release-v10 --- .../pages/chat/javascript/06-state/02-messages.test.ts | 5 ++++- .../pages/chat/javascript/06-state/04-thread.test.ts | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/test/docs-snippets/pages/chat/javascript/06-state/02-messages.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/02-messages.test.ts index 33c9c3059..696fee807 100644 --- a/test/docs-snippets/pages/chat/javascript/06-state/02-messages.test.ts +++ b/test/docs-snippets/pages/chat/javascript/06-state/02-messages.test.ts @@ -197,7 +197,10 @@ describe(DOCS, () => { replyIds.push(reply.id); } // Loads the thread with its latest replies (3 by default) - const thread = await client.getThreadAndHydrate(parent.id, { watch: true }); + const thread = await client.getThreadAndHydrate( + { message_id: parent.id }, + { watch: true }, + ); const releaseThread = thread.activate(); const replyId = replyIds[0]; diff --git a/test/docs-snippets/pages/chat/javascript/06-state/04-thread.test.ts b/test/docs-snippets/pages/chat/javascript/06-state/04-thread.test.ts index 3b25a31be..6d22306f7 100644 --- a/test/docs-snippets/pages/chat/javascript/06-state/04-thread.test.ts +++ b/test/docs-snippets/pages/chat/javascript/06-state/04-thread.test.ts @@ -67,9 +67,10 @@ describe(DOCS, () => { // #region snippet docs="chat/javascript/06-state/04-thread.md" heading="Initialization" tab="JavaScript" index=2 // One thread, by its parent message id - const queriedThread = await client.getThreadAndHydrate(parentMessageId, { - watch: true, - }); + const queriedThread = await client.getThreadAndHydrate( + { message_id: parentMessageId }, + { watch: true }, + ); // Several threads const { threads } = await client.queryThreadsAndHydrate({