diff --git a/AGENTS.md b/AGENTS.md index 30af291450..171d55930b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -280,7 +280,7 @@ const { // Manual setup when you need control over the API responses const client = await getTestClientWithUser({ id: 'test-user' }); useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); -const channel = client.channel('messaging', channelId); +const channel = client.channelManager.ensure({ id: channelId, type: 'messaging' }); await channel.watch(); ``` diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index e05cb03254..395019e7fb 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -327,12 +327,16 @@ dictionary: [`i18n-v15-migration.md`](./i18n-v15-migration.md). ```tsx import { getChannel } from 'stream-chat-react'; - const channel = client.channel('messaging', id, { members, custom }); + const channel = client.channelManager.ensure({ + data: { custom, members }, + id, + type: 'messaging', + }); if (!channel.initialized) await getChannel({ channel, client }); setChannel(channel); ``` - Two separate guards, both worth keeping. `client.channel()` returns the cached instance, which may already be loaded, so `initialized` skips a query that is not needed. And prefer `getChannel` over a bare `channel.watch()` when one _is_ needed: it de-duplicates concurrent calls for the same channel (keyed on the sorted member list while a channel has no id yet), so an effect that runs twice, or two components opening the same channel, still produce one query. That de-duplication used to live inside `Channel`. + Two separate guards, both worth keeping. `client.channelManager.ensure()` (which `client.channel()` calls in stream-chat v10) returns the stored instance, which may already be loaded, so `initialized` skips a query that is not needed. And prefer `getChannel` over a bare `channel.watch()` when one _is_ needed: it de-duplicates concurrent calls for the same channel (keyed on the sorted member list while a channel has no id yet), so an effect that runs twice, or two components opening the same channel, still produce one query. That de-duplication used to live inside `Channel`. - **A direct message identified by members** → `getChannel({ client, type: 'messaging', members })` builds, watches and returns the instance. - **`channelQueryOptions`** → pass them to the watch you now own: `getChannel({ channel, client, options })`. @@ -909,8 +913,9 @@ reads `muteStatus` instead of subscribing to `notification.channel_mutes_updated ### `` declares the channel active, and owns its message window -`` now calls `channel.activate()` on mount and `channel.deactivate()` on unmount (refcounted, -so several consumers can hold one instance), and calls `channel.reload()` on `connection.recovered`. +`` now calls `channel.activate()` on mount and the release function it returns on unmount +(refcounted, so several consumers can hold one instance), and calls `channel.reload()` on +`connection.recovered`. These two go together and **a custom channel surface must do both**. While a channel is active, the client deliberately skips re-seeding its message list on channel-list hydration and on reconnect — its @@ -919,8 +924,8 @@ Nothing in the client calls `reload()` for you: ```ts useEffect(() => { - channel.activate(); - return () => channel.deactivate(); + const release = channel.activate(); + return release; }, [channel]); client.on('connection.recovered', () => { diff --git a/ai-docs/instance-configuration.md b/ai-docs/instance-configuration.md index db7b881a9d..7e80615c82 100644 --- a/ai-docs/instance-configuration.md +++ b/ai-docs/instance-configuration.md @@ -45,7 +45,7 @@ export const App = () => ( ``` **Not in an effect.** Some configuration is read once when an instance is constructed, and channels -are constructed by `client.channel()` / `client.queryChannels()` — which an app typically calls before +are constructed by `client.channelManager.ensure()` / `client.queryChannels()` — which an app typically calls before or during the same commit that mounts ``. Registering from `useEffect` runs after that, so those values would arrive too late for instances that already exist. @@ -198,7 +198,7 @@ this cannot. If behaviour has to differ between channels, branch inside a single ```ts sendMessageRequest: async ({ localMessage, message, options }) => { - const channel = chatClient.channel(...); + const channel = chatClient.channelManager.ensure({ ... }); return isSupportChannel(localMessage.cid) ? { message: await sendViaProxy(message, options) } : { message: await sendNormally(message, options) }; diff --git a/examples/tutorial/src/3-core-component-setup/App.tsx b/examples/tutorial/src/3-core-component-setup/App.tsx index 1dea06b3a3..1f8bcb8471 100644 --- a/examples/tutorial/src/3-core-component-setup/App.tsx +++ b/examples/tutorial/src/3-core-component-setup/App.tsx @@ -69,19 +69,23 @@ const App = () => { if (!client) return; const initChannel = async () => { - const channel = client.channel('messaging', 'custom_channel_id', { - members: [userId], - // custom channel fields live under `custom` since v10 - custom: { - image: 'https://getstream.io/random_png/?name=react', - name: 'Talk about React', + const channel = client.channelManager.ensure({ + data: { + members: [userId], + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://getstream.io/random_png/?name=react', + name: 'Talk about React', + }, }, + id: 'custom_channel_id', + type: 'messaging', }); // `Channel` binds a channel to its subtree; it does not query one. Whoever supplies the // channel initializes it. // - // `client.channel()` returns the cached instance for this cid, so a re-run of this effect + // `client.channelManager.ensure()` returns the stored instance for this cid, so a re-run of this effect // can hand back a channel that is already loaded -- query only when it is not. When a query // is needed, `getChannel` de-duplicates concurrent calls for the same channel, so two // overlapping runs still produce a single request. diff --git a/examples/tutorial/src/6-custom-ui-components/App.tsx b/examples/tutorial/src/6-custom-ui-components/App.tsx index 960109fdf1..c3b2dd7196 100644 --- a/examples/tutorial/src/6-custom-ui-components/App.tsx +++ b/examples/tutorial/src/6-custom-ui-components/App.tsx @@ -215,13 +215,17 @@ const App = () => { if (!client) return; const initChannel = async () => { - const channel = client.channel('messaging', 'react-tutorial', { - members: [userId], - // custom channel fields live under `custom` since v10 - custom: { - image: 'https://getstream.io/random_png/?name=react-v14', - name: 'Talk about React', + const channel = client.channelManager.ensure({ + data: { + members: [userId], + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://getstream.io/random_png/?name=react-v14', + name: 'Talk about React', + }, }, + id: 'react-tutorial', + type: 'messaging', }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the diff --git a/examples/tutorial/src/7-emoji-picker/App.tsx b/examples/tutorial/src/7-emoji-picker/App.tsx index 2326ad5816..8c94a83442 100644 --- a/examples/tutorial/src/7-emoji-picker/App.tsx +++ b/examples/tutorial/src/7-emoji-picker/App.tsx @@ -139,13 +139,17 @@ const App = () => { if (!client) return; const initChannel = async () => { - const channel = client.channel('messaging', 'react-tutorial', { - members: [userId], - // custom channel fields live under `custom` since v10 - custom: { - image: 'https://getstream.io/random_png/?name=react-v14', - name: 'Talk about React', + const channel = client.channelManager.ensure({ + data: { + members: [userId], + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://getstream.io/random_png/?name=react-v14', + name: 'Talk about React', + }, }, + id: 'react-tutorial', + type: 'messaging', }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the diff --git a/examples/tutorial/src/optional-custom-attachment-type/App.tsx b/examples/tutorial/src/optional-custom-attachment-type/App.tsx index 248590fefc..0974494e02 100644 --- a/examples/tutorial/src/optional-custom-attachment-type/App.tsx +++ b/examples/tutorial/src/optional-custom-attachment-type/App.tsx @@ -182,12 +182,16 @@ const App = () => { if (!client) return; const initChannel = async () => { - const channel = client.channel('messaging', 'react-tutorial-products', { - members: [userId], - custom: { - image: 'https://getstream.io/random_png/?name=products', - name: 'Product recommendations', + const channel = client.channelManager.ensure({ + data: { + members: [userId], + custom: { + image: 'https://getstream.io/random_png/?name=products', + name: 'Product recommendations', + }, }, + id: 'react-tutorial-products', + type: 'messaging', }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the diff --git a/examples/tutorial/src/optional-livestream/App.tsx b/examples/tutorial/src/optional-livestream/App.tsx index bcbe396ff2..3c0886305f 100644 --- a/examples/tutorial/src/optional-livestream/App.tsx +++ b/examples/tutorial/src/optional-livestream/App.tsx @@ -42,12 +42,16 @@ const App = () => { if (!chatClient) return; const initChannel = async () => { - const spaceChannel = chatClient.channel('livestream', 'spacex', { - // custom channel fields live under `custom` since v10 - custom: { - image: 'https://goo.gl/Zefkbx', - name: 'SpaceX launch discussion', + const spaceChannel = chatClient.channelManager.ensure({ + data: { + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://goo.gl/Zefkbx', + name: 'SpaceX launch discussion', + }, }, + id: 'spacex', + type: 'livestream', }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the diff --git a/examples/vite/.gitignore b/examples/vite/.gitignore index fc5ae9f0cc..f337abe6ae 100644 --- a/examples/vite/.gitignore +++ b/examples/vite/.gitignore @@ -23,3 +23,6 @@ dist-ssr *.sln *.sw? .vercel + +# files stored by the mock CDN (mockCdnPlugin.ts) +.mock-cdn diff --git a/examples/vite/docs-playwright/screenshot-attachment-actions.ts b/examples/vite/docs-playwright/screenshot-attachment-actions.ts index 223210918e..0a7821a646 100644 --- a/examples/vite/docs-playwright/screenshot-attachment-actions.ts +++ b/examples/vite/docs-playwright/screenshot-attachment-actions.ts @@ -45,10 +45,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { + var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { name: 'Attachment Actions Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - }); + } }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/screenshot-misc.ts b/examples/vite/docs-playwright/screenshot-misc.ts index d64eb75f01..9167e969b7 100644 --- a/examples/vite/docs-playwright/screenshot-misc.ts +++ b/examples/vite/docs-playwright/screenshot-misc.ts @@ -46,10 +46,10 @@ async function createChannel( await page.evaluate(`(async () => { var client = window.client; - var ch = client.channel('messaging', ${JSON.stringify(channelId)}, { + var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(channelId)}, data: { name: 'Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - }); + } }); await ch.create(); })()`); diff --git a/examples/vite/docs-playwright/screenshot-reactions.ts b/examples/vite/docs-playwright/screenshot-reactions.ts index de3119ac29..e77359a4f8 100644 --- a/examples/vite/docs-playwright/screenshot-reactions.ts +++ b/examples/vite/docs-playwright/screenshot-reactions.ts @@ -46,10 +46,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { + var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { name: 'Reactions Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - }); + } }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/screenshot-system-message.ts b/examples/vite/docs-playwright/screenshot-system-message.ts index bf62dae5e0..acfb52c67b 100644 --- a/examples/vite/docs-playwright/screenshot-system-message.ts +++ b/examples/vite/docs-playwright/screenshot-system-message.ts @@ -41,10 +41,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { + var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { name: 'System Message Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - }); + } }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/screenshot-variants.ts b/examples/vite/docs-playwright/screenshot-variants.ts index cfd6098fa7..56c1867145 100644 --- a/examples/vite/docs-playwright/screenshot-variants.ts +++ b/examples/vite/docs-playwright/screenshot-variants.ts @@ -44,10 +44,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { + var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { name: 'Cookbook Screenshots', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - }); + } }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/screenshot-voice-recording.ts b/examples/vite/docs-playwright/screenshot-voice-recording.ts index be35fa5366..1b7b88b98d 100644 --- a/examples/vite/docs-playwright/screenshot-voice-recording.ts +++ b/examples/vite/docs-playwright/screenshot-voice-recording.ts @@ -48,10 +48,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { + var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { name: 'Voice Recording Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - }); + } }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs b/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs index ac771ff56b..98a21581f0 100644 --- a/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs +++ b/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs @@ -627,7 +627,8 @@ async function run() { const key = Object.keys(el).find((k) => k.startsWith('__reactFiber')); let fiber = el[key]; while (fiber) { - if (fiber.memoizedProps?.client?.channel) return fiber.memoizedProps.client; + if (fiber.memoizedProps?.client?.channelManager) + return fiber.memoizedProps.client; fiber = fiber.return; } return null; @@ -635,9 +636,13 @@ async function run() { const client = findClient(); if (!client) return { ok: false, reason: 'no client' }; try { - const ch = client.channel('messaging', channelId, { - members: [userA, userB], - name: 'Design redesign — v14', + const ch = client.channelManager.ensure({ + type: 'messaging', + id: channelId, + data: { + members: [userA, userB], + name: 'Design redesign — v14', + }, }); await ch.watch(); const existingCount = (ch.state.messages || []).length; @@ -713,7 +718,7 @@ async function run() { let fiber = el[key]; let client = null; while (fiber) { - if (fiber.memoizedProps?.client?.channel) { + if (fiber.memoizedProps?.client?.channelManager) { client = fiber.memoizedProps.client; break; } @@ -756,13 +761,19 @@ async function run() { const results = []; for (const [channelId, data] of Object.entries(channelImages)) { try { - const ch = client.channel('public', channelId); + const ch = client.channelManager.ensure({ + type: 'public', + id: channelId, + }); await ch.updatePartial({ set: { image: data.image } }); results.push(channelId + ': ok'); } catch (e) { // Try messaging type too try { - const ch = client.channel('messaging', channelId); + const ch = client.channelManager.ensure({ + type: 'messaging', + id: channelId, + }); await ch.updatePartial({ set: { image: data.image } }); results.push(channelId + ': ok (messaging)'); } catch (e2) { diff --git a/examples/vite/mockCdnPlugin.ts b/examples/vite/mockCdnPlugin.ts new file mode 100644 index 0000000000..3d06645d99 --- /dev/null +++ b/examples/vite/mockCdnPlugin.ts @@ -0,0 +1,117 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { extname, resolve } from 'node:path'; +import { Readable } from 'node:stream'; +import type { Connect, Plugin } from 'vite'; + +/** URL prefix the mock CDN is served under, on the dev (and preview) server's own origin. */ +export const MOCK_CDN_PATH = '/mock-cdn'; + +/** Stored file names: a UUID plus the uploaded file's extension, nothing a path could escape with. */ +const STORED_NAME = /^[0-9a-f-]{36}(\.[\w]{1,10})?$/i; + +type StoredFileMeta = { name: string; type: string }; + +const sendJson = (res: ServerResponse, status: number, body: unknown) => { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); +}; + +const readFormData = (req: IncomingMessage) => { + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === 'string') headers.set(key, value); + else if (Array.isArray(value)) value.forEach((item) => headers.append(key, item)); + } + return new Request(`http://mock-cdn${req.url ?? ''}`, { + body: Readable.toWeb(req) as ReadableStream, + // required by Node for a streamed request body + duplex: 'half', + headers, + method: 'POST', + } as RequestInit).formData(); +}; + +/** + * A stand-in CDN for trying `doUploadRequest` against storage Stream does not host. + * + * - `POST /mock-cdn/upload` takes `multipart/form-data` with the file in the `file` field, stores it + * under `storageDir` and answers `{ file: }` — the shape `doUploadRequest` resolves with. + * - `GET /mock-cdn/files/` serves a stored file back. + * + * Files live on disk, so they outlive a dev-server restart. The URLs point at this server, so only + * browsers that can reach it can display them. + */ +export const mockCdnPlugin = ({ storageDir }: { storageDir: string }): Plugin => { + const handle: Connect.NextHandleFunction = (req, res, next) => { + const path = (req.url ?? '').split('?')[0]; + if (!path.startsWith(`${MOCK_CDN_PATH}/`)) return next(); + + void (async () => { + try { + if (req.method === 'POST' && path === `${MOCK_CDN_PATH}/upload`) { + const file = (await readFormData(req)).get('file'); + if (!(file instanceof Blob)) { + return sendJson(res, 400, { + message: 'Expected a file in the "file" field.', + }); + } + const name = + 'name' in file && typeof file.name === 'string' ? file.name : 'file'; + const storedName = `${randomUUID()}${extname(name).slice(0, 11)}`; + await mkdir(storageDir, { recursive: true }); + await writeFile( + resolve(storageDir, storedName), + Buffer.from(await file.arrayBuffer()), + ); + const meta: StoredFileMeta = { + name, + type: file.type || 'application/octet-stream', + }; + await writeFile( + resolve(storageDir, `${storedName}.json`), + JSON.stringify(meta), + ); + return sendJson(res, 201, { + file: `http://${req.headers.host}${MOCK_CDN_PATH}/files/${storedName}`, + }); + } + + const fileMatch = path.match(new RegExp(`^${MOCK_CDN_PATH}/files/([^/]+)$`)); + if (req.method === 'GET' && fileMatch && STORED_NAME.test(fileMatch[1])) { + const storedName = fileMatch[1]; + const meta = JSON.parse( + await readFile(resolve(storageDir, `${storedName}.json`), 'utf8'), + ) as StoredFileMeta; + res.statusCode = 200; + res.setHeader('Content-Type', meta.type); + res.setHeader( + 'Content-Disposition', + `inline; filename="${encodeURIComponent(meta.name)}"`, + ); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + return res.end(await readFile(resolve(storageDir, storedName))); + } + + sendJson(res, 404, { message: 'Not found.' }); + } catch (error) { + const missing = (error as NodeJS.ErrnoException).code === 'ENOENT'; + sendJson(res, missing ? 404 : 500, { + message: missing ? 'Not found.' : String(error), + }); + } + })(); + }; + + return { + configurePreviewServer: (server) => { + server.middlewares.use(handle); + }, + configureServer: (server) => { + server.middlewares.use(handle); + }, + name: 'mock-cdn', + }; +}; diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index bd1a1d9f20..18feeef0b2 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -12,6 +12,7 @@ import type { LocalMessage, SortParamRequest, TextComposerMiddleware, + UploadRequestFn, } from 'stream-chat'; import { ChannelPaginator, @@ -40,7 +41,7 @@ import { useCreateChatClient, WithComponents, } from 'stream-chat-react'; -import { ChatView } from 'stream-chat-react/slot-layout'; +import { ChatView, DisposedChannelRelease } from 'stream-chat-react/slot-layout'; import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis'; import { init, SearchIndex } from 'emoji-mart'; import data from '@emoji-mart/data/sets/14/native.json'; @@ -97,7 +98,11 @@ import { InlineEditableMessage } from './InlineEditMessage'; import { SidebarToggle } from './Sidebar/SidebarToggle.tsx'; import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx'; import { StreamDebugHandles } from './Debug'; -import { installUploadHarness } from './SendWhilePendingUploads'; +import { + installUploadHarness, + MOCK_CDN_UPLOAD_URL, + uploadToCdn, +} from './SendWhilePendingUploads'; import { streamI18n } from './i18n'; import { DocumentTitleManager, @@ -287,7 +292,7 @@ const formatDocumentTitle = ({ const App = () => { const { tokenProvider, userId, userImage, userName } = useUser(); const chatView = useAppSettingsSelector((state) => state.chatView); - const { failUploads, sendMessagesWithPendingUploads, slowUploads } = + const { failUploads, sendMessagesWithPendingUploads, slowUploads, uploadDestination } = useAppSettingsSelector((state) => state.composer); // Project to a stable-shape object rather than returning `state.layout` directly. `layout` // starts as `{}`, and useStateStore only diffs the keys present in its *cached* selection — so @@ -395,10 +400,10 @@ const App = () => { targets.forEach(({ cid, messageId }) => { const separatorIndex = cid.indexOf(':'); - const channel = chatClient.channel( - cid.slice(0, separatorIndex), - cid.slice(separatorIndex + 1), - ); + const channel = chatClient.channelManager.ensure({ + id: cid.slice(separatorIndex + 1), + type: cid.slice(0, separatorIndex), + }); void (async () => { if (!channel.initialized) await getChannel({ channel, client: chatClient }); @@ -513,15 +518,26 @@ const App = () => { // Settings are read on every upload rather than captured here, so changing them in // Settings -> Composer takes effect without re-running setup - which matters because a // custom doUploadRequest cannot be un-set once installed. - if (slowUploads || failUploads !== 'off') { + if (slowUploads || failUploads !== 'off' || uploadDestination !== 'stream') { installUploadHarness(composer, () => { const { + customCdnUrl, failUploads: failureMode, slowUploadMs, slowUploads: slowArmed, + uploadDestination: destination, } = appSettingsStore.getLatestValue().composer; - - return { delayMs: slowArmed ? slowUploadMs : 0, failureMode }; + const upload: UploadRequestFn = + destination === 'stream' + ? composer.attachmentManager.doDefaultUploadRequest + : (fileLike, options) => + uploadToCdn( + destination === 'mock-cdn' ? MOCK_CDN_UPLOAD_URL : customCdnUrl, + fileLike, + options, + ); + + return { delayMs: slowArmed ? slowUploadMs : 0, failureMode, upload }; }); } @@ -563,7 +579,7 @@ const App = () => { location: { enabled: true }, }); }); - }, [chatClient, failUploads, slowUploads]); + }, [chatClient, failUploads, slowUploads, uploadDestination]); useEffect(() => { if (!chatClient) return; @@ -573,9 +589,12 @@ const App = () => { // subscriptions, and the latter is what mounting a channel does - so an open composer sees it // at once and the rest on their way in. chatClient.config.setConfig('messageComposer', { - attachments: { pendingUploadsEnabled: sendMessagesWithPendingUploads }, + attachments: { + customCdn: uploadDestination !== 'stream', + pendingUploadsEnabled: sendMessagesWithPendingUploads, + }, }); - }, [chatClient, sendMessagesWithPendingUploads]); + }, [chatClient, sendMessagesWithPendingUploads, uploadDestination]); const chatTheme = themeMode === 'dark' ? 'str-chat__theme-dark' : 'messaging light'; const initialAppLayoutStyle = useMemo( @@ -699,6 +718,7 @@ const App = () => { views={chatViews} > + diff --git a/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx b/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx index af274d9403..41f55bb166 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx +++ b/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx @@ -72,13 +72,19 @@ const ActionsMenuButton = ({ ); -import { ComposerStateDialog, useComposerStateDialog } from '../../Debug'; +import { + ChannelStoreDialog, + ComposerStateDialog, + useChannelStoreDialog, + useComposerStateDialog, +} from '../../Debug'; import { usePersistentDialog } from './usePersistentDialog'; export const ActionsMenu = ({ iconOnly = true }: { iconOnly?: boolean }) => { // Shared hook so the dialog is registered with closeOnClickOutside disabled regardless of // which of the two call sites reaches getOrCreate first. const { dialog: composerStateDialog } = useComposerStateDialog(); + const { dialog: channelStoreDialog } = useChannelStoreDialog(); const [menuButtonElement, setMenuButtonElement] = useState( null, ); @@ -118,6 +124,7 @@ export const ActionsMenu = ({ iconOnly = true }: { iconOnly?: boolean }) => { + {serverSideClientEnabled && ( )} @@ -126,6 +133,7 @@ export const ActionsMenu = ({ iconOnly = true }: { iconOnly?: boolean }) => { + {serverSideClientEnabled && ( )} @@ -202,3 +210,17 @@ function TriggerComposerStateInspectorAction({ onTrigger }: { onTrigger: () => v /> ); } + +function TriggerChannelStoreAction({ onTrigger }: { onTrigger: () => void }) { + const { closeMenu } = useContextMenuContext(); + + return ( + { + closeMenu(); + onTrigger(); + }} + /> + ); +} diff --git a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/ServerSideClientPromptDialog.tsx b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/ServerSideClientPromptDialog.tsx index 9d5205aa73..42fc58a004 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/ServerSideClientPromptDialog.tsx +++ b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/ServerSideClientPromptDialog.tsx @@ -100,12 +100,13 @@ export const ServerSideClientPromptDialog = ({ ); // Channels the client has loaded. Recomputed each time the dialog opens rather than subscribed - // to — `activeChannels` is a plain record with no change notification, and a debugging dialog + // to — the channel store sends no change notifications, and a debugging dialog // does not need it live. `allowCustomValue` covers anything not in the list. const channelOptions = useMemo[]>(() => { if (!dialogIsOpen) return []; - return Object.values(appClient.activeChannels) + return appClient.channelManager + .values() .map((activeChannel) => activeChannel.cid) .filter((activeChannelCid): activeChannelCid is string => !!activeChannelCid) .sort((left, right) => left.localeCompare(right)) @@ -118,7 +119,7 @@ export const ServerSideClientPromptDialog = ({ const localMembers = useMemo(() => { if (!dialogIsOpen || !cid) return []; - const members = appClient.activeChannels[cid]?.state?.members ?? {}; + const members = appClient.channelManager.get(cid)?.state?.members ?? {}; return Object.values(members) .map((member) => ({ diff --git a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts index fe3960e733..60a0fa2e70 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts +++ b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts @@ -16,7 +16,7 @@ import { StreamChat } from 'stream-chat'; * header. * * 2. The client constructor takes `(key, options)` only — there is no secret parameter — and - * `client.channel(...)` throws without a connected user. So callers must not build `Channel` + * `client.channelManager.ensure()` throws without a connected user. So callers must not build `Channel` * objects; they issue requests through `client.api.sendRequest` instead (see serverSideMethods). * * `crypto.subtle` requires a secure context — fine on localhost and https. diff --git a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideMethods.ts b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideMethods.ts index e1d37d7bdf..5c6a83bac4 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideMethods.ts +++ b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideMethods.ts @@ -7,7 +7,7 @@ import type { StreamChat } from 'stream-chat'; * member's channel-specific data, which a browser client cannot do. * * Everything goes through `client.api.sendRequest` rather than the generated helpers, for two - * reasons: `client.channel(...)` throws without a connected user, and the generated + * reasons: `client.channelManager.ensure()` throws without a connected user, and the generated * `updateMemberPartial` sends no `user_id`, so it can only ever write the caller's own * membership. `sendRequest` is the same primitive the generated APIs use internally and it * accepts query params, which is where `user_id` belongs. diff --git a/examples/vite/src/AppSettings/SearchableSelect.tsx b/examples/vite/src/AppSettings/SearchableSelect.tsx index 5e1714e572..64d683df57 100644 --- a/examples/vite/src/AppSettings/SearchableSelect.tsx +++ b/examples/vite/src/AppSettings/SearchableSelect.tsx @@ -61,6 +61,8 @@ const SearchableSelectOptionItem = ({ const SearchableSelectDropdownItems = ({ allowCustomValue, + emptyOptionsLabel, + filterOptions, onSearchChange, onSelect, options, @@ -69,6 +71,8 @@ const SearchableSelectDropdownItems = ({ selectedValue, }: { allowCustomValue: boolean; + emptyOptionsLabel: string; + filterOptions: boolean; onSearchChange: (value: string) => void; onSelect: (value: T) => void; options: SearchableSelectOption[]; @@ -78,9 +82,9 @@ const SearchableSelectDropdownItems = ({ }) => { const trimmedQuery = searchQuery.trim(); const normalizedQuery = trimmedQuery.toLowerCase(); - const filteredOptions = options.filter((option) => - option.label.toLowerCase().includes(normalizedQuery), - ); + const filteredOptions = filterOptions + ? options.filter((option) => option.label.toLowerCase().includes(normalizedQuery)) + : options; // Lets the caller target something the option list does not know about. Offered only when the // query cannot be satisfied from the list, or already looks fully qualified (`type:id`), so it // does not clutter ordinary searches that do match. @@ -124,7 +128,7 @@ const SearchableSelectDropdownItems = ({ /> ))} {filteredOptions.length === 0 && !customOption && ( -
No matching options
+
{emptyOptionsLabel}
)} ); @@ -133,7 +137,10 @@ const SearchableSelectDropdownItems = ({ export const SearchableSelect = ({ allowCustomValue = false, emptyLabel, + emptyOptionsLabel = 'No matching options', + filterOptions = true, onChange, + onSearchChange, options, searchPlaceholder, value, @@ -142,12 +149,25 @@ export const SearchableSelect = ({ allowCustomValue?: boolean; /** Trigger text when `value` matches no option. Defaults to the existing first-option fallback. */ emptyLabel?: string; + /** Shown in the dropdown when no option is listed. */ + emptyOptionsLabel?: string; + /** + * Filters `options` by the search text (the default). Turn it off when the options already are the + * search's results, e.g. from a server query run in `onSearchChange`. + */ + filterOptions?: boolean; onChange: (value: T) => void; + /** Called with the search text as it changes, and with `''` when the dropdown opens or closes. */ + onSearchChange?: (query: string) => void; options: SearchableSelectOption[]; searchPlaceholder: string; value: T; }) => { - const [searchQuery, setSearchQuery] = useState(''); + const [searchQuery, setSearchQueryState] = useState(''); + const setSearchQuery = (query: string) => { + setSearchQueryState(query); + onSearchChange?.(query); + }; const selectedOption = options.find((option) => option.value === value) ?? null; // With a free-text value the trigger must show what was typed even though it is not an option. // Falls back to the first option only when neither new prop is in play, preserving the previous @@ -196,6 +216,8 @@ export const SearchableSelect = ({ > void; }; @@ -14,7 +23,14 @@ type ComposerTabProps = { export const ComposerTab = ({ close }: ComposerTabProps) => { const { composer, - composer: { failUploads, sendMessagesWithPendingUploads, slowUploadMs, slowUploads }, + composer: { + customCdnUrl, + failUploads, + sendMessagesWithPendingUploads, + slowUploadMs, + slowUploads, + uploadDestination, + }, } = useAppSettingsState(); // NumericInput is a text input that also accepts '', which has no numeric equivalent, so the // typed value is held locally and only committed to the store once it parses. @@ -52,6 +68,52 @@ export const ComposerTab = ({ close }: ComposerTabProps) => { +
+
Upload destination
+
+ {uploadDestinations.map(({ label, value }) => ( + + ))} +
+ {uploadDestination === 'custom-url' && ( + + appSettingsStore.partialNext({ + composer: { ...composer, customCdnUrl: event.target.value.trim() }, + }) + } + placeholder='https://cdn.example.com/upload' + type='url' + value={customCdnUrl} + /> + )} +
+ Where attachments are uploaded. Mock CDN stores files on disk + in examples/vite/.mock-cdn and serves them back from this dev + server, so only browsers that reach it can display them; it exists only under{' '} + yarn start:vite and vite preview, not in the + deployed app. CDN at a URL posts each file as{' '} + multipart/form-data in the file field and expects + JSON {'{ file, thumb_url? }'} back; the CDN has to allow this + origin (CORS). Either CDN sets customCdn, so Stream's upload + permission no longer applies. Combines with the slow and failing switches + below; applies to the next upload. +
+
+
Slow uploads
({ paginators: state.paginators, }); -const threadsSelector = (state: ThreadManagerState) => ({ threads: state.threads }); +const threadsSelector = (state: PaginatorState) => ({ + threads: state.items ?? [], +}); /** * `client.config` keys its methods on the real key unions, so a segment parsed out of a dotted path @@ -66,7 +68,7 @@ const isSetupKey = (key: string): key is InstanceSetupKey => export const ConfigurationTab = ({ close }: ConfigurationTabProps) => { const { channelManager, client } = useChatContext(); const { paginators } = useStateStore(channelManager.state, paginatorsSelector); - const { threads } = useStateStore(client.threads.state, threadsSelector); + const { threads } = useStateStore(client.threads.paginator.state, threadsSelector); const [scope, setScope] = useState('all'); const [selectedType, setSelectedType] = useState(''); diff --git a/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx b/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx index dede5bda9e..da9ff76d6b 100644 --- a/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx +++ b/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx @@ -27,7 +27,7 @@ export const ReactionsTab = ({ close }: ReactionsTabProps) => { // `useUserRole` -> `useChannelCapabilities`, which subscribes to it as a StateStore. A plain // object shaped like channel state crashes there, which is what this preview used to pass. const previewChannel = useMemo( - () => client.channel('messaging', 'reactions-preview'), + () => client.channelManager.ensure({ id: 'reactions-preview', type: 'messaging' }), [client], ); diff --git a/examples/vite/src/ChatLayout/Panels.tsx b/examples/vite/src/ChatLayout/Panels.tsx index a9bbfa434a..b9d5634e84 100644 --- a/examples/vite/src/ChatLayout/Panels.tsx +++ b/examples/vite/src/ChatLayout/Panels.tsx @@ -19,7 +19,6 @@ import { ChannelHeader, ChannelListItem, MessageComposerUI as DefaultMessageComposerUI, - EmptyStateIndicator, MessageComposer, MessageList, type MessageListProps, @@ -36,6 +35,7 @@ import { } from 'stream-chat-react'; import { ChatView, + ChatViewEmptyPlaceholder, type ChatViewSelectorEntry, ThreadSlot, useActiveThread, @@ -485,6 +485,11 @@ const ResponsiveChannelPanels = ({ mainChannel }: { mainChannel?: StreamChannel {/* The resize handle belongs to the SLOT, not its contents: it's rendered once here and driven by whether the slot is open (base binding OR a layer such as the member profile), so a layer that covers the base — or is the only occupant — stays resizable. */} + {!mainChannel && !isSideOpen && ( +
+ +
+ )} {/* The base of the secondary slot (2nd channel or reply thread) is ALWAYS rendered at a stable position so it stays mounted — a member-profile layer covers it (below) rather @@ -679,9 +684,7 @@ export const ThreadsPanels = ({ })} > {!hasThread ? ( -
- -
+ ) : ( <> {mainThread && } diff --git a/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx b/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx index 5a6cec00d9..2a45588033 100644 --- a/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx +++ b/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx @@ -10,7 +10,7 @@ import { useChatViewContext, useChatViewNavigation, } from 'stream-chat-react/slot-layout'; -import { formatMessage, Thread as StreamThread } from 'stream-chat'; +import { formatMessage } from 'stream-chat'; import type { Channel, ChannelManager, @@ -183,18 +183,16 @@ const writeWorkspaceToUrl = (encoded: string, mode: 'push' | 'replace') => { // ---- resolve: token → live binding (async) ------------------------------------------------------ -// `client.channel(type, id)` returns the client's cached instance for that cid (the same one the -// channel-list query watches). When the caller has already waited for the list to settle (see -// `waitForChannelList`), that instance is `initialized`, so the bound `` skips its own watch -// — no duplicate `/query`. A channel absent from every loaded page is returned unwatched and -// `` watches it (the necessary, non-redundant fetch). +// `client.channelManager.ensure()` returns the stored instance for that cid (the same one the +// channel-list query watches), or builds one when none is stored. When the caller has already +// waited for the list to settle (see `waitForChannelList`), that instance is `initialized`, so the +// bound `` skips its own watch — no duplicate `/query`. A channel absent from every loaded +// page is returned unwatched and `` watches it (the necessary, non-redundant fetch). const resolveChannel = (client: StreamChat, cid: string): Channel | undefined => { - const existing = Object.values(client.activeChannels).find((c) => c.cid === cid); - if (existing) return existing; const colon = cid.indexOf(':'); const type = cid.slice(0, colon); const id = cid.slice(colon + 1); - return type && id ? client.channel(type, id) : undefined; + return type && id ? client.channelManager.ensure({ id, type }) : undefined; }; /** @@ -238,7 +236,7 @@ const resolveBinding = async ( } case 'thread': { // Paginator-first: a thread the thread-list already holds is reused as-is — no round-trip. - const listed = client.threads.threadsById[token.key]; + const listed = client.threads.get(token.key); if (listed) { return { binding: { key: listed.id ?? undefined, kind: 'thread', source: listed }, @@ -263,11 +261,13 @@ const resolveBinding = async ( // thread's channel config, members and read state are loaded when the panel renders. if (!channel.initialized) await channel.watch().catch(() => undefined); + // Through `ensure()`, so the thread is registered with the `ThreadManager` and receives + // events. It starts stale only when the parent has replies, and `` loads it then. return { binding: { key: token.key, kind: 'thread', - source: new StreamThread({ channel, client, parentMessage }), + source: client.threads.ensure({ channel, parentMessage }), }, channel, }; @@ -288,7 +288,7 @@ const resolveBinding = async ( // OWN `/query` watch and each thread need a `getThread` — duplicating what the list queries // (`/channels`, `/threads`) fetch a moment later. Waiting for the relevant list to settle first means // a listed channel is already `initialized` (so `` skips its watch) and a listed thread is -// already in `threadsById` (so no `getThread`). Entities genuinely absent from the loaded pages still +// already in `client.threads` (so no `getThread`). Entities genuinely absent from the loaded pages still // fall back to an explicit query. Warm Back/Forward keeps the paginators populated, so these waits // resolve on the first (immediate) subscribe callback — no added latency. @@ -335,9 +335,9 @@ const waitForChannelList = async (orchestrator: ChannelManager) => { await waitForState(paginator.state, (s) => s.items !== undefined); }; -/** Wait for the thread-list paginator to be ready (so listed threads are in `threadsById`). */ +/** Wait for the thread-list paginator to load its first page (so listed threads are in `client.threads`). */ const waitForThreadList = (client: StreamChat) => - waitForState(client.threads.state, (s) => s.ready); + waitForState(client.threads.paginator.state, (s) => s.items !== undefined); // ---- the sync component ------------------------------------------------------------------------- @@ -372,8 +372,8 @@ export const WorkspaceUrlSync = () => { // Resolve a parsed workspace by entity id and apply it to the controller in ONE atomic write. // - // Resolution is paginator-first (see `resolveBinding`): channels come from `client.activeChannels` - // and threads from `client.threads.threadsById`, both populated by the list paginators. Entities + // Resolution is paginator-first (see `resolveBinding`): channels come from `client.channelManager.ensure()` + // and threads from `client.threads.get()`, both populated by the list paginators. Entities // already paginated are reused with NO network round-trip — so navigating Back/Forward between // already-visited workspaces (which keeps those paginators warm, unlike a reload) fetches nothing. // @@ -384,17 +384,15 @@ export const WorkspaceUrlSync = () => { async (target: ParsedWorkspace) => { // Cold-load de-duplication: before binding, wait for the list paginators whose entities will // actually mount (only the *active* view's slots render). Then a listed channel is already - // watched and a listed thread already in `threadsById`, so resolution reuses them instead of + // watched and a listed thread already in `client.threads`, so resolution reuses them instead of // issuing a duplicate per-entity query. Warm Back/Forward resolves these waits immediately. // // The thread-list wait applies ONLY in the threads view: that is the only view whose // `ThreadList` mounts and activates `client.threads`, so it is the only case where waiting // de-duplicates against a list that is actually loading. A thread slot in the channels view is // a channel reply-thread that the thread list does NOT back — waiting there would just stall - // to the `waitForThreadList` timeout and then resolve anyway (and, if the list did load, reuse - // a manager instance whose replies aren't loaded, forcing a redundant `/replies`). So a - // channels-view thread skips the wait and resolves immediately via `getThreadAndHydrate` - // (fully hydrated, replies embedded → a single `/threads/` request). + // to the `waitForThreadList` timeout and then resolve anyway. So a channels-view thread skips + // the wait and resolves immediately from its parent message (see `resolveBinding`). const activeKinds = new Set( target.slots .filter((s) => s.view === target.activeView) diff --git a/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx b/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx index 06a69e4d71..50657ac4f7 100644 --- a/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx +++ b/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx @@ -90,6 +90,10 @@ export const ConnectionDevPanel = () => { if (!nextHealthy) { parkedConnectionId.current = client.connectionIdManager.connectionId; client.connectionIdManager.invalidate(); + // The server drops watches with the socket. A real drop marks them interrupted in + // `_setHealth(false)`, which this panel bypasses, so recovery would see nothing to + // restore and the channel store would treat dropped watches as live. + client.channelManager.markChannelsWatchInterrupted(); } else if (parkedConnectionId.current) { client.connectionIdManager.resolveConnectionId(parkedConnectionId.current); parkedConnectionId.current = undefined; diff --git a/examples/vite/src/Debug/ChannelStoreDialog.tsx b/examples/vite/src/Debug/ChannelStoreDialog.tsx new file mode 100644 index 0000000000..4b8edc1d6c --- /dev/null +++ b/examples/vite/src/Debug/ChannelStoreDialog.tsx @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { ChannelUsage, StreamChat } from 'stream-chat'; +import { ChannelWatchStatus } from 'stream-chat'; +import { Button, Prompt, useChatContext, useDialogIsOpen } from 'stream-chat-react'; + +import { DraggableDialog } from '../AppSettings/ActionsMenu/DraggableDialog'; +import { usePersistentDialog } from '../AppSettings/ActionsMenu/usePersistentDialog'; + +export const channelStoreDialogId = 'app-channel-store-dialog'; + +/** Same registration rule as the composer inspector: see `usePersistentDialog`. */ +export const useChannelStoreDialog = () => usePersistentDialog(channelStoreDialogId); + +// The channel store sends no change notifications, so the table re-reads it on this interval. +const REFRESH_INTERVAL_MS = 1000; + +const useChannelUsage = (client: StreamChat, enabled: boolean) => { + const [usage, setUsage] = useState([]); + const refresh = useCallback( + () => setUsage(client.channelManager.getChannelUsage()), + [client], + ); + + useEffect(() => { + if (!enabled) return; + refresh(); + const interval = setInterval(refresh, REFRESH_INTERVAL_MS); + return () => clearInterval(interval); + }, [enabled, refresh]); + + return { refresh, usage }; +}; + +const ChannelUsageRow = ({ + onChange, + usage: { channel, key, keptBy }, +}: { + onChange: () => void; + usage: ChannelUsage; +}) => { + const watched = channel.watchStatus !== ChannelWatchStatus.NotWatching; + + return ( + + +
{channel.id ?? '(no id yet)'}
+
{key}
+ + {channel.watchStatus} + + {keptBy.length ? ( +
+ {keptBy.map((reason) => ( + + {reason} + + ))} +
+ ) : ( + + released at next release + + )} + + + {watched ? ( + + ) : ( + + )} + + + ); +}; + +/** + * Lists every channel in `client.channelManager`'s store with what keeps it (its own state and the + * names of its holders), as reported by `channelManager.getChannelUsage()`. "Release now" calls + * `releaseUnusedChannels()`, which otherwise runs when the lists reload or the connection recovers; + * "Stop watching" makes a channel unwatched, which list channels never are on their own. + */ +export const ChannelStoreDialog = ({ + referenceElement, +}: { + referenceElement: HTMLElement | null; +}) => { + const { client } = useChatContext(); + const { dialog, dialogManager } = useChannelStoreDialog(); + const dialogIsOpen = useDialogIsOpen(channelStoreDialogId, dialogManager?.id); + const { refresh, usage } = useChannelUsage(client, dialogIsOpen); + + const closeDialog = useCallback(() => { + dialog.close(); + }, [dialog]); + + const released = usage.filter(({ keptBy }) => !keptBy.length).length; + + return ( + + +
+ + {usage.length} stored, {released} released at next release + + +
+ {usage.length ? ( + + + + + + + + + + {usage.map((entry) => ( + + ))} + +
ChannelWatch statusKept because +
+ ) : ( +
No stored channels.
+ )} +
+
+ ); +}; diff --git a/examples/vite/src/Debug/ComposerInspector.scss b/examples/vite/src/Debug/ComposerInspector.scss index ec903282b2..841f17dc0c 100644 --- a/examples/vite/src/Debug/ComposerInspector.scss +++ b/examples/vite/src/Debug/ComposerInspector.scss @@ -120,3 +120,37 @@ .app__composer-inspector__copy { align-self: flex-start; } + +/* Channel store dialog: wider than the composer inspector, for the table. */ +.app__channel-store-dialog__shell { + display: flex; + flex-direction: column; + width: min(720px, calc(100vw - 32px)); + max-height: min(70vh, 720px); +} + +.app__channel-store__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.app__channel-store__table { + width: 100%; + border-collapse: collapse; + + th, + td { + padding: 4px 6px; + border-bottom: 1px solid var(--str-chat__border-core-default, rgb(0 0 0 / 15%)); + text-align: left; + vertical-align: top; + } +} + +.app__channel-store__key { + opacity: 0.6; + font-family: ui-monospace, monospace; + word-break: break-all; +} diff --git a/examples/vite/src/Debug/StreamDebugHandles.tsx b/examples/vite/src/Debug/StreamDebugHandles.tsx index eedafa2aeb..d9bf21a0dc 100644 --- a/examples/vite/src/Debug/StreamDebugHandles.tsx +++ b/examples/vite/src/Debug/StreamDebugHandles.tsx @@ -12,7 +12,7 @@ import { listComposers } from './composerRegistry'; * a snapshot taken when the component last rendered. * * streamDebug.client // StreamChat - * streamDebug.channels // client.activeChannels, keyed by cid + * streamDebug.channels // loaded channels (client.channelManager.values()), keyed by cid * streamDebug.composers // [{ tag, label, composer }] incl. thread/edit composers * streamDebug.uploads // client.uploadManager.uploads, keyed by localMetadata.id * @@ -34,7 +34,12 @@ export const StreamDebugHandles = () => { const handles = { get channels() { - return client?.activeChannels; + return Object.fromEntries( + (client?.channelManager.values() ?? []).map((channel) => [ + channel.cid, + channel, + ]), + ); }, get client() { return client; diff --git a/examples/vite/src/Debug/composerRegistry.ts b/examples/vite/src/Debug/composerRegistry.ts index f6fda89c09..b6d1c7b0a2 100644 --- a/examples/vite/src/Debug/composerRegistry.ts +++ b/examples/vite/src/Debug/composerRegistry.ts @@ -46,7 +46,7 @@ export const listComposers = (client: StreamChat | undefined): ComposerEntry[] = const entries: ComposerEntry[] = []; - for (const channel of Object.values(client.activeChannels)) { + for (const channel of client.channelManager.values()) { if (!channel?.messageComposer) continue; entries.push({ composer: channel.messageComposer, diff --git a/examples/vite/src/Debug/index.ts b/examples/vite/src/Debug/index.ts index 4629bdfb90..7a9ef56812 100644 --- a/examples/vite/src/Debug/index.ts +++ b/examples/vite/src/Debug/index.ts @@ -1,3 +1,8 @@ +export { + ChannelStoreDialog, + channelStoreDialogId, + useChannelStoreDialog, +} from './ChannelStoreDialog'; export { ComposerStateDialog, composerStateDialogId, diff --git a/examples/vite/src/NewConversation/NewConversation.scss b/examples/vite/src/NewConversation/NewConversation.scss new file mode 100644 index 0000000000..01426d6940 --- /dev/null +++ b/examples/vite/src/NewConversation/NewConversation.scss @@ -0,0 +1,151 @@ +@layer stream-app-overrides { + // a fixed height, so the dialog doesn't resize as people are added or results come and go + .app__new-conversation { + width: min(440px, 92vw); + height: min(80dvh, 560px); + display: flex; + flex-direction: column; + background: var(--str-chat__background-core-elevation-2); + color: var(--str-chat__text-primary); + } + + // the form wraps body and footer, so it takes over the prompt's column layout + .app__new-conversation__form { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + } + + .app__new-conversation__body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: var(--str-chat__spacing-sm); + overflow-y: auto; + } + + .app__new-conversation__chips { + display: flex; + flex-wrap: wrap; + gap: var(--str-chat__spacing-xs); + margin: 0; + padding: 0; + list-style: none; + } + + .app__new-conversation__chip { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xxs, 4px); + padding: 2px 4px 2px 2px; + border-radius: 999px; + background: var(--str-chat__background-core-surface); + font: var(--str-chat__font-caption-default); + } + + .app__new-conversation__chip-remove { + display: flex; + padding: 2px; + border: none; + border-radius: 50%; + background: none; + color: var(--str-chat__text-secondary); + cursor: pointer; + + svg { + width: 14px; + height: 14px; + } + } + + .app__new-conversation__empty { + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-tertiary); + } + + .app__new-conversation__details, + .app__new-conversation__members { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--str-chat__spacing-xs); + } + + .app__new-conversation__members { + padding-top: var(--str-chat__spacing-sm); + } + + .app__new-conversation__image { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--str-chat__spacing-xxs, 4px); + } + + .app__new-conversation__image-button { + position: relative; + padding: 0; + border: none; + border-radius: 50%; + background: none; + cursor: pointer; + + &:disabled { + cursor: progress; + } + + &:focus-visible { + outline: 2px solid var(--str-chat__border-utility-selected); + outline-offset: 2px; + } + } + + // the camera icon over the image: shown on hover and focus, and while an upload is running + .app__new-conversation__image-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(0, 0, 0, 0.45); + color: #fff; + opacity: 0; + transition: opacity 120ms ease; + + svg { + width: 20px; + height: 20px; + } + } + + .app__new-conversation__image-button:hover .app__new-conversation__image-overlay, + .app__new-conversation__image-button:focus-visible + .app__new-conversation__image-overlay, + .app__new-conversation__image-button:disabled .app__new-conversation__image-overlay { + opacity: 1; + } + + .app__new-conversation__link-button { + padding: 0; + border: none; + background: none; + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); + text-decoration: underline; + cursor: pointer; + } + + .app__new-conversation__hint { + margin: 0; + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-secondary); + } + + .app__new-conversation__error { + font: var(--str-chat__font-caption-default); + color: var(--str-chat__accent-error); + } +} diff --git a/examples/vite/src/NewConversation/NewConversationButton.tsx b/examples/vite/src/NewConversation/NewConversationButton.tsx new file mode 100644 index 0000000000..83bd6945c3 --- /dev/null +++ b/examples/vite/src/NewConversation/NewConversationButton.tsx @@ -0,0 +1,28 @@ +import { useCallback, useState } from 'react'; +import { GlobalModal, IconPlus } from 'stream-chat-react'; +import { ChatViewSelectorButton } from 'stream-chat-react/slot-layout'; + +import { NewConversationDialog } from './NewConversationDialog'; + +/** Sidebar entry that opens the new conversation dialog. */ +export const NewConversationButton = ({ iconOnly = true }: { iconOnly?: boolean }) => { + const [open, setOpen] = useState(false); + const close = useCallback(() => setOpen(false), []); + + return ( + <> + setOpen(true)} + text='New conversation' + > + + + + {open && } + + + ); +}; diff --git a/examples/vite/src/NewConversation/NewConversationDialog.tsx b/examples/vite/src/NewConversation/NewConversationDialog.tsx new file mode 100644 index 0000000000..bfd7e9f4e7 --- /dev/null +++ b/examples/vite/src/NewConversation/NewConversationDialog.tsx @@ -0,0 +1,290 @@ +import { type ChangeEvent, type FormEvent, useEffect, useRef, useState } from 'react'; +import type { UserResponse } from 'stream-chat'; +import { + Avatar, + IconCamera, + IconLoading, + IconXmark, + Prompt, + SwitchField, + TextInput, + useChatContext, + useWorkspaceNavigation, +} from 'stream-chat-react'; + +import { SearchableSelect } from '../AppSettings/SearchableSelect'; +import { createGroup, openOneToOne } from './createConversation'; + +const SEARCH_DEBOUNCE_MS = 250; + +/** Users matching `query` by id or name, without the connected user. */ +const useUserSearch = (query: string) => { + const { client } = useChatContext(); + const [users, setUsers] = useState([]); + const [searching, setSearching] = useState(false); + + useEffect(() => { + const text = query.trim(); + if (!text) { + setUsers([]); + return; + } + let cancelled = false; + const timeout = setTimeout(async () => { + setSearching(true); + try { + const response = await client.queryUsers({ + payload: { + filter_conditions: { + $or: [{ id: { $autocomplete: text } }, { name: { $autocomplete: text } }], + id: { $ne: client.userID as string }, + }, + limit: 10, + sort: [{ direction: 1, field: 'id' }], + }, + }); + if (!cancelled) setUsers(response.users); + } finally { + if (!cancelled) setSearching(false); + } + }, SEARCH_DEBOUNCE_MS); + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [client, query]); + + return { searching, users }; +}; + +const displayName = (user: UserResponse) => user.name || user.id; + +const optionLabel = (user: UserResponse) => + user.name && user.name !== user.id ? `${user.name} (${user.id})` : user.id; + +const ImagePicker = ({ + image, + name, + onChange, +}: { + image?: string; + name: string; + onChange: (image?: string) => void; +}) => { + const { client } = useChatContext(); + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(); + + const onFileSelected = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + // cleared, so choosing the same file again still fires `change` + event.target.value = ''; + if (!file) return; + setUploading(true); + setError(undefined); + try { + const { file: url } = await client.uploadImage({ file }); + if (!url) throw new Error('The upload returned no image URL.'); + onChange(url); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setUploading(false); + } + }; + + return ( +
+ + + {image && ( + + )} + {error &&
{error}
} +
+ ); +}; + +/** + * Starts a conversation: pick the members, optionally name it and give it an image. One other member + * opens the 1:1 conversation with them, the existing one if there is one; a new 1:1 is created on the + * server when its first message is sent. More members create a new group on the server right away. + */ +export const NewConversationDialog = ({ onClose }: { onClose: () => void }) => { + const { client } = useChatContext(); + const { openChannel } = useWorkspaceNavigation(); + const [query, setQuery] = useState(''); + const [selected, setSelected] = useState([]); + const [name, setName] = useState(''); + const [image, setImage] = useState(); + const [submitting, setSubmitting] = useState(false); + const [lookUpExisting, setLookUpExisting] = useState(true); + const [error, setError] = useState(); + const { searching, users } = useUserSearch(query); + + const isOneToOne = selected.length === 1; + // the connected user, who creates the conversation and is always one of its members + const me = client.user as UserResponse | undefined; + const selectedIds = new Set(selected.map(({ id }) => id)); + const candidates = users.filter(({ id }) => !selectedIds.has(id)); + + const add = (userId: string) => { + const user = users.find(({ id }) => id === userId); + if (user) setSelected((current) => [...current, user]); + }; + const remove = (userId: string) => + setSelected((current) => current.filter(({ id }) => id !== userId)); + + const searchStatus = !query.trim() + ? 'Type a name or id' + : searching + ? 'Searching…' + : `No one else matches “${query.trim()}”`; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!selected.length) return; + setSubmitting(true); + setError(undefined); + try { + const details = { image, name }; + const { channel, existed } = isOneToOne + ? await openOneToOne(client, selected[0], details, { lookUpExisting }) + : await createGroup(client, selected, details); + if (existed && (name.trim() || image)) { + client.notifications.addInfo({ + message: + 'This conversation already exists, so its name and image were left unchanged.', + origin: { emitter: 'NewConversationDialog' }, + }); + } + openChannel(channel); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setSubmitting(false); + } + }; + + return ( + + +
+ +
+ + setName(event.target.value)} + placeholder={isOneToOne ? 'Name (optional)' : 'Group name (optional)'} + value={name} + /> +
+ +
+ + emptyLabel='Add people' + emptyOptionsLabel={searchStatus} + filterOptions={false} + onChange={add} + onSearchChange={setQuery} + options={candidates.map((user) => ({ + label: optionLabel(user), + value: user.id, + }))} + searchPlaceholder='Search people by name or id' + value='' + /> + +
    + {me && ( +
  • + + {displayName(me)} (you) +
  • + )} + {selected.length ? ( + selected.map((user) => ( +
  • + + {displayName(user)} + +
  • + )) + ) : ( +
  • Add at least one person.
  • + )} +
+ +

+ {!selected.length + ? 'Add one person for a 1:1 conversation, or more for a group.' + : isOneToOne + ? 'Opens your conversation with this person. If it is new, it is created when you send the first message, with this name and image.' + : 'Creates a new group with these members, name and image.'} +

+ + {isOneToOne && ( + setLookUpExisting(!event.target.checked)} + title='Dev: open as a new local conversation even if one exists (tests the cid swap)' + /> + )} +
+ + {error &&
{error}
} +
+ + + + Cancel + + + {isOneToOne ? 'Open conversation' : 'Start group'} + + + +
+
+ ); +}; diff --git a/examples/vite/src/NewConversation/createConversation.ts b/examples/vite/src/NewConversation/createConversation.ts new file mode 100644 index 0000000000..34846aebf7 --- /dev/null +++ b/examples/vite/src/NewConversation/createConversation.ts @@ -0,0 +1,145 @@ +import { + type Channel, + type ChannelMemberResponse, + localMessageToNewMessagePayload, + type StreamChat, + type UserResponse, +} from 'stream-chat'; + +export type ConversationDetails = { image?: string; name?: string }; + +export type OpenedConversation = { + channel: Channel; + /** The conversation already existed on the server; the details typed in the dialog were not applied. */ + existed: boolean; +}; + +const CHANNEL_TYPE = 'messaging'; + +const withoutEmpty = ({ image, name }: ConversationDetails) => ({ + ...(image ? { image } : {}), + ...(name?.trim() ? { name: name.trim() } : {}), +}); + +const ownUser = (client: StreamChat) => client.user as unknown as UserResponse; + +/** + * Keeps a 1:1 that doesn't exist yet local until its first message: nothing is loaded for it (there + * is nothing on the server yet), and sending the first message creates it on the server (`watch()`) + * before sending. + * Its members are shown from the start, the connected user as the one creating it, so the channel + * reads as one the user is in; the server's members replace them once it is created. + */ +const createOnFirstSend = ( + channel: Channel, + client: StreamChat, + others: UserResponse[], +) => { + const creator = ownUser(client); + channel.state.members = Object.fromEntries( + [creator, ...others].map((user) => [ + user.id, + { + channel_role: user.id === creator.id ? 'owner' : 'channel_member', + user, + user_id: user.id, + } as ChannelMemberResponse, + ]), + ); + channel.messagePaginator.seedFirstPageSync([], channel.messagePaginator.pageSize); + const { requestHandlers } = channel.configState.getLatestValue(); + channel.configState.partialNext({ + requestHandlers: { + ...requestHandlers, + sendMessageRequest: async ({ localMessage, message, options }) => { + // without an id nothing but its query can be sent, and the query creates it on the server + if (channel.isProvisional) await channel.watch(); + // another instance may have been stored under the cid meanwhile and taken over from this one + const response = await (channel.supersededBy ?? channel).sendMessage({ + message: message ?? localMessageToNewMessagePayload(localMessage), + ...options, + }); + return { message: response.message }; + }, + }, + }); +}; + +/** + * A 1:1 conversation is a distinct channel: one per pair of users. One read-only query asks the + * server for it first, by its exact members, so an existing one opens with its history (it is then + * the stored instance). Only a conversation that doesn't exist yet is created locally, as a + * provisional channel: it stays local until the first message, and only then are the name and image + * applied. + */ +export const openOneToOne = async ( + client: StreamChat, + other: UserResponse, + details: ConversationDetails, + { + /** + * Dev switch: `false` skips the lookup, so an existing 1:1 that isn't loaded yet is opened as a + * local channel. Once that 1:1 is loaded meanwhile (by scrolling the channel list to it, or an + * event), the server answers the local channel's first query with its cid, and the loaded + * instance supersedes the local one. A 1:1 already loaded is returned by `ensure` itself. + */ + lookUpExisting = true, + }: { lookUpExisting?: boolean } = {}, +): Promise => { + const memberIds = [client.userID as string, other.id]; + const candidates = !lookUpExisting + ? [] + : await client.queryChannelsAndHydrate( + { + filter_conditions: { + member_count: 2, + members: { $eq: memberIds }, + type: CHANNEL_TYPE, + }, + limit: 10, + }, + {}, + ); + // a 2-member channel created with an id of its own is a group with those two, not their 1:1 + const existing = candidates.find((channel) => channel.id?.startsWith('!members-')); + if (existing) return { channel: existing, existed: true }; + + const channel = client.channelManager.ensure({ + data: { + custom: withoutEmpty(details), + members: memberIds.map((user_id) => ({ user_id })), + }, + type: CHANNEL_TYPE, + }); + // `ensure` returns a loaded 1:1 with these members rather than a new local channel + if (!channel.isProvisional) return { channel, existed: true }; + createOnFirstSend(channel, client, [other]); + return { channel, existed: false }; +}; + +/** + * A group gets an id of its own: the same people can share several groups ("Project X", "Lunch"), + * so the members can't identify it. It is created on the server right away, with its members, name + * and image: with an id, the SDK sends its requests (typing events, drafts) as for any channel, and + * the server would refuse them for a channel it doesn't have. + */ +export const createGroup = async ( + client: StreamChat, + others: UserResponse[], + details: ConversationDetails, +): Promise => { + const channel = client.channelManager.ensure({ + data: { + custom: withoutEmpty(details), + members: [client.userID as string, ...others.map(({ id }) => id)].map( + (user_id) => ({ + user_id, + }), + ), + }, + id: crypto.randomUUID(), + type: CHANNEL_TYPE, + }); + await channel.watch(); + return { channel, existed: false }; +}; diff --git a/examples/vite/src/NewConversation/index.ts b/examples/vite/src/NewConversation/index.ts new file mode 100644 index 0000000000..bf08ef0f92 --- /dev/null +++ b/examples/vite/src/NewConversation/index.ts @@ -0,0 +1 @@ +export * from './NewConversationButton'; diff --git a/examples/vite/src/SendWhilePendingUploads/cdnUpload.ts b/examples/vite/src/SendWhilePendingUploads/cdnUpload.ts new file mode 100644 index 0000000000..19badd734f --- /dev/null +++ b/examples/vite/src/SendWhilePendingUploads/cdnUpload.ts @@ -0,0 +1,66 @@ +import type { + FileLike, + FileReference, + MinimumUploadRequestResult, + UploadRequestOptions, +} from 'stream-chat'; + +/** Where uploads go: Stream's own storage, the dev server's mock CDN, or a CDN at a given URL. */ +export type UploadDestination = 'stream' | 'mock-cdn' | 'custom-url'; + +/** The upload endpoint `mockCdnPlugin.ts` serves on the dev server. */ +export const MOCK_CDN_UPLOAD_URL = `${window.location.origin}/mock-cdn/upload`; + +const abortError = () => new DOMException('Upload aborted', 'AbortError'); + +/** + * Uploads a file to a CDN that accepts `multipart/form-data` with the file in the `file` field and + * answers with JSON `{ file: , thumb_url?: }` — what `doUploadRequest` resolves + * with. Reports progress through `options.onProgress` and stops on `options.abortSignal`. + */ +export const uploadToCdn = ( + url: string, + fileLike: FileReference | FileLike, + options?: UploadRequestOptions, +) => + new Promise((resolve, reject) => { + if (!url) { + reject(new Error('No CDN URL set: enter one in Settings → Composer.')); + return; + } + if (!(fileLike instanceof Blob)) { + reject(new Error('Only browser files can be uploaded to the CDN.')); + return; + } + const signal = options?.abortSignal; + if (signal?.aborted) { + reject(abortError()); + return; + } + + const xhr = new XMLHttpRequest(); + xhr.open('POST', url); + xhr.responseType = 'json'; + xhr.upload.onprogress = (event) => { + options?.onProgress?.( + event.lengthComputable + ? Math.round((event.loaded / event.total) * 100) + : undefined, + ); + }; + xhr.onload = () => { + const response = xhr.response as Partial | null; + if (xhr.status >= 200 && xhr.status < 300 && typeof response?.file === 'string') { + resolve(response as MinimumUploadRequestResult); + } else { + reject(new Error(`CDN upload failed with status ${xhr.status}.`)); + } + }; + xhr.onerror = () => reject(new Error('CDN upload failed: network error.')); + xhr.onabort = () => reject(abortError()); + signal?.addEventListener('abort', () => xhr.abort(), { once: true }); + + const body = new FormData(); + body.append('file', fileLike, 'name' in fileLike ? fileLike.name : 'file'); + xhr.send(body); + }); diff --git a/examples/vite/src/SendWhilePendingUploads/index.ts b/examples/vite/src/SendWhilePendingUploads/index.ts index 0df6afc01e..c773cef96a 100644 --- a/examples/vite/src/SendWhilePendingUploads/index.ts +++ b/examples/vite/src/SendWhilePendingUploads/index.ts @@ -1,3 +1,4 @@ +export { MOCK_CDN_UPLOAD_URL, type UploadDestination, uploadToCdn } from './cdnUpload'; export { FAILING_FILE_NAME_PREFIX, installUploadHarness, diff --git a/examples/vite/src/SendWhilePendingUploads/uploadHarness.ts b/examples/vite/src/SendWhilePendingUploads/uploadHarness.ts index c6db22ef20..e848c8e999 100644 --- a/examples/vite/src/SendWhilePendingUploads/uploadHarness.ts +++ b/examples/vite/src/SendWhilePendingUploads/uploadHarness.ts @@ -1,5 +1,10 @@ -import type { MessageComposer, UploadRequestOptions } from 'stream-chat'; -import type { FileLike, FileReference } from 'stream-chat'; +import type { + FileLike, + FileReference, + MessageComposer, + UploadRequestFn, + UploadRequestOptions, +} from 'stream-chat'; /** Files whose name starts with this fail when the failure mode is `prefixed`. */ export const FAILING_FILE_NAME_PREFIX = 'fail-'; @@ -11,11 +16,13 @@ export type UploadHarnessSettings = { delayMs: number; /** Which uploads should reject instead of completing. */ failureMode: UploadFailureMode; + /** The request that stores the file: Stream's own, or one to a CDN. */ + upload: UploadRequestFn; }; /** - * Dev-only harness wrapping `doUploadRequest`, so uploads can be slowed down and made to fail on - * demand from **Settings → Composer**. + * Dev-only harness wrapping `doUploadRequest`, so uploads can be sent to a CDN, slowed down and made + * to fail on demand from **Settings → Composer**. * * Without the delay there is nothing to look at: this app's Stream project caps uploads at 3 MiB, * which lands in well under a second. Without the failure switch there is no way to reach the @@ -24,11 +31,12 @@ export type UploadHarnessSettings = { * Both live in one function because a custom `doUploadRequest` cannot be un-set — * `MessageComposer.updateConfig` merges via `mergeWith`, which skips `undefined` — and the * attachment manager holds only one. So the harness is installed once and reads the current - * settings on every upload; with the delay at `0` and failures off it is a pass-through. + * settings on every upload; with the delay at `0`, failures off and Stream as the destination it is + * a pass-through. * * Caveat: installing a custom `doUploadRequest` flips `hasCustomDoUploadRequest`, which slightly * changes the guard in `uploadFiles`. Irrelevant for the demo, but it is why the harness is - * installed only once one of the switches is armed rather than unconditionally. + * installed only once one of the switches is armed or a CDN is chosen rather than unconditionally. */ const PROGRESS_STEPS = 20; /** @@ -55,19 +63,19 @@ const shouldFail = (fileLike: FileReference | FileLike, mode: UploadFailureMode) export const installUploadHarness = ( composer: MessageComposer, /** - * Read at call time, not at install time, so changing either setting takes effect on the next + * Read at call time, not at install time, so changing any setting takes effect on the next * upload without re-running the composer setup. */ getSettings: () => UploadHarnessSettings, ) => { composer.attachmentManager.setCustomUploadFn( async (fileLike: FileReference | FileLike, options?: UploadRequestOptions) => { - const { delayMs, failureMode } = getSettings(); + const { delayMs, failureMode, upload } = getSettings(); const failing = shouldFail(fileLike, failureMode); if (delayMs <= 0) { if (failing) throw new Error('Simulated upload failure'); - return composer.attachmentManager.doDefaultUploadRequest(fileLike, options); + return upload(fileLike, options); } const rampMs = delayMs * RAMP_SHARE; @@ -91,7 +99,7 @@ export const installUploadHarness = ( } if (failing) throw new Error('Simulated upload failure'); - return composer.attachmentManager.doDefaultUploadRequest(fileLike, options); + return upload(fileLike, options); }, ); }; diff --git a/examples/vite/src/Sidebar/ChatViewSelectorItemSet.tsx b/examples/vite/src/Sidebar/ChatViewSelectorItemSet.tsx index bc4d60b48c..d8abb34530 100644 --- a/examples/vite/src/Sidebar/ChatViewSelectorItemSet.tsx +++ b/examples/vite/src/Sidebar/ChatViewSelectorItemSet.tsx @@ -3,8 +3,12 @@ import { defaultChatViewSelectorItemSet, } from 'stream-chat-react/slot-layout'; import { AppSettings } from '../AppSettings'; +import { NewConversationButton } from '../NewConversation'; +import { UserProfileButton } from '../UserProfile'; export const chatViewSelectorItemSet: ChatViewSelectorEntry[] = [ ...defaultChatViewSelectorItemSet, + { Component: NewConversationButton, type: 'new-conversation' }, { Component: AppSettings, type: 'settings' }, + { Component: UserProfileButton, type: 'profile' }, ]; diff --git a/examples/vite/src/SingleChannel/SingleChannelApp.tsx b/examples/vite/src/SingleChannel/SingleChannelApp.tsx index 86d947b6b1..1c576fa1bb 100644 --- a/examples/vite/src/SingleChannel/SingleChannelApp.tsx +++ b/examples/vite/src/SingleChannel/SingleChannelApp.tsx @@ -48,7 +48,7 @@ export const resolveSingleChannel = ({ separatorIndex === -1 ? ['messaging', channelKey] : [channelKey.slice(0, separatorIndex), channelKey.slice(separatorIndex + 1)]; - return client.channel(type, id); + return client.channelManager.ensure({ id, type }); } const loadedChannel = channelManager?.paginators.flatMap( @@ -56,7 +56,10 @@ export const resolveSingleChannel = ({ )[0]; if (loadedChannel) return loadedChannel; - return client.channel('messaging', SINGLE_CHANNEL_DEFAULT_ID); + return client.channelManager.ensure({ + id: SINGLE_CHANNEL_DEFAULT_ID, + type: 'messaging', + }); }; const channelDisplayName = (channel: StreamChannel) => diff --git a/examples/vite/src/UserProfile/UserDetailDialog.tsx b/examples/vite/src/UserProfile/UserDetailDialog.tsx new file mode 100644 index 0000000000..fef6c813d4 --- /dev/null +++ b/examples/vite/src/UserProfile/UserDetailDialog.tsx @@ -0,0 +1,434 @@ +import { + type ChangeEvent, + type FormEvent, + type KeyboardEvent, + useRef, + useState, +} from 'react'; +import { + Avatar, + Button, + IconCamera, + IconCheckmark, + IconDelete, + IconEdit, + IconLoading, + IconPlus, + IconXmark, + Prompt, + TextInput, + useChatContext, +} from 'stream-chat-react'; + +import { type ProfileUser, toProfileUser } from './useOwnUser'; + +/** + * Fields the API keeps outside `custom`. A custom property can't take one of these names: the + * server would write the built-in field (or reject the update) instead. + */ +const RESERVED_KEYS = new Set([ + 'banned', + 'created_at', + 'custom', + 'deactivated_at', + 'deleted_at', + 'id', + 'image', + 'invisible', + 'language', + 'last_active', + 'name', + 'online', + 'privacy_settings', + 'revoke_tokens_issued_before', + 'role', + 'teams', + 'teams_role', + 'updated_at', +]); + +/** Shown as typed for text; other JSON values (numbers, booleans, objects) as JSON. */ +const formatValue = (value: unknown) => + typeof value === 'string' ? value : JSON.stringify(value); + +/** Text that is valid JSON for a non-string value is stored as that value; anything else as text. */ +const parseValue = (text: string): unknown => { + try { + const parsed: unknown = JSON.parse(text); + return typeof parsed === 'string' ? text : parsed; + } catch { + return text; + } +}; + +type FieldRowProps = { + label: string; + onRemove?: () => Promise; + onSave?: (text: string) => Promise; + placeholder?: string; + value: string; +}; + +const FieldRow = ({ label, onRemove, onSave, placeholder, value }: FieldRowProps) => { + const [draft, setDraft] = useState(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const editing = draft !== undefined; + + const run = async (action: () => Promise) => { + setBusy(true); + setError(undefined); + try { + await action(); + setDraft(undefined); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const save = () => { + if (!onSave || draft === undefined) return; + if (draft === value) return setDraft(undefined); + void run(() => onSave(draft)); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') save(); + if (event.key === 'Escape') { + event.stopPropagation(); + setDraft(undefined); + setError(undefined); + } + }; + + return ( +
+
{label}
+ {editing ? ( + setDraft(event.target.value)} + // the whole value is selected, so typing replaces it + onFocus={(event) => event.currentTarget.select()} + onKeyDown={onKeyDown} + placeholder={placeholder} + trailing={ + <> + + + + } + value={draft} + variant='outline' + /> + ) : ( +
+
+ {value || Not set} +
+ {onSave && ( + + )} + {onRemove && ( + + )} +
+ )} + {!editing && error &&
{error}
} +
+ ); +}; + +const AddCustomProperty = ({ + existingKeys, + onAdd, +}: { + existingKeys: string[]; + onAdd: (key: string, value: unknown) => Promise; +}) => { + const [key, setKey] = useState(''); + const [value, setValue] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + + const trimmedKey = key.trim(); + const keyError = !trimmedKey + ? undefined + : RESERVED_KEYS.has(trimmedKey) + ? `"${trimmedKey}" is a built-in field` + : existingKeys.includes(trimmedKey) + ? `"${trimmedKey}" already exists; edit it above` + : undefined; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!trimmedKey || keyError) return; + setBusy(true); + setError(undefined); + try { + await onAdd(trimmedKey, parseValue(value)); + setKey(''); + setValue(''); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + return ( +
+
Add custom property
+
+ setKey(event.target.value)} + placeholder='Name, e.g. nickname' + value={key} + variant='outline' + /> + setValue(event.target.value)} + placeholder='Value' + value={value} + variant='outline' + /> + +
+ {(keyError || error) && ( +
{keyError ?? error}
+ )} +
+ Values that are valid JSON (numbers, booleans, objects) are stored as such; + anything else as text. +
+
+ ); +}; + +/** + * The user's avatar as a button: clicking it opens the OS file picker, and the chosen image is + * uploaded to the Stream CDN (`client.uploadImage()`) and its URL saved as the user's `image`. + */ +const AvatarPicker = ({ + onImageChange, + user, +}: { + onImageChange: (image: string | undefined) => Promise; + user: ProfileUser; +}) => { + const { client } = useChatContext(); + const inputRef = useRef(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + + const run = async (action: () => Promise) => { + setBusy(true); + setError(undefined); + try { + await action(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const onFileSelected = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + // cleared, so choosing the same file again still fires `change` + event.target.value = ''; + if (!file) return; + void run(async () => { + const { file: url } = await client.uploadImage({ file }); + if (!url) throw new Error('The upload returned no image URL.'); + await onImageChange(url); + }); + }; + + return ( +
+ + + {user.image && ( + + )} + {error &&
{error}
} +
+ ); +}; + +/** + * The connected user's profile: the avatar (click to upload a new image), the name, the custom + * properties, and a form to add one. Every change is saved right away with `client.updateUsersPartial()` under the user's own + * token, which lets a user edit their own name, image and custom data; built-in fields such as + * `role` need a server-side client, so they are shown read-only. + */ +export const UserDetailDialog = ({ + onClose, + onUserChange, + user, +}: { + onClose: () => void; + onUserChange: (user: ProfileUser) => void; + user: ProfileUser; +}) => { + const { client } = useChatContext(); + + const update = async ({ + set, + unset, + }: { + set?: Record; + unset?: string[]; + }) => { + const { users } = await client.updateUsersPartial({ + users: [{ id: user.id, set, unset }], + }); + const updated = users[user.id]; + if (updated) onUserChange(toProfileUser(updated)); + }; + + // by name: the server returns custom data in no stable order, so rows would move on every save + const customEntries = Object.entries(user.custom).sort(([a], [b]) => + a.localeCompare(b), + ); + + return ( + + + +
+ + image ? update({ set: { image } }) : update({ unset: ['image'] }) + } + user={user} + /> +
{user.id}
+
+ +
+ update({ set: { name: text } })} + placeholder='Display name' + value={user.name ?? ''} + /> + +
+ +
+
Custom properties
+ {customEntries.length ? ( + customEntries.map(([key, value]) => ( + update({ unset: [key] })} + onSave={(text) => update({ set: { [key]: parseValue(text) } })} + value={formatValue(value)} + /> + )) + ) : ( +
+ No custom properties yet. +
+ )} +
+ + key)} + onAdd={(key, value) => update({ set: { [key]: value } })} + /> +
+
+ ); +}; diff --git a/examples/vite/src/UserProfile/UserProfile.scss b/examples/vite/src/UserProfile/UserProfile.scss new file mode 100644 index 0000000000..5c43a8b4a3 --- /dev/null +++ b/examples/vite/src/UserProfile/UserProfile.scss @@ -0,0 +1,173 @@ +@layer stream-app-overrides { + .app__user-profile { + width: min(440px, 92vw); + max-height: min(80dvh, 720px); + display: flex; + flex-direction: column; + background: var(--str-chat__background-core-elevation-2); + color: var(--str-chat__text-primary); + } + + .app__user-profile__body { + display: flex; + flex-direction: column; + gap: var(--str-chat__spacing-md); + overflow-y: auto; + padding-bottom: var(--str-chat__spacing-xl); + } + + .app__user-profile__header { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--str-chat__spacing-xs); + } + + .app__user-profile__avatar-picker { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--str-chat__spacing-xs); + } + + .app__user-profile__avatar-button { + position: relative; + padding: 0; + border: none; + border-radius: 50%; + background: none; + cursor: pointer; + + &:disabled { + cursor: progress; + } + + &:focus-visible { + outline: 2px solid var(--str-chat__border-utility-selected); + outline-offset: 2px; + } + } + + // the camera icon over the avatar: shown on hover and focus, and while an upload is running + .app__user-profile__avatar-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(0, 0, 0, 0.45); + color: #fff; + opacity: 0; + transition: opacity 120ms ease; + + svg { + width: 24px; + height: 24px; + } + } + + .app__user-profile__avatar-button:hover .app__user-profile__avatar-overlay, + .app__user-profile__avatar-button:focus-visible .app__user-profile__avatar-overlay, + .app__user-profile__avatar-button:disabled .app__user-profile__avatar-overlay { + opacity: 1; + } + + .app__user-profile__id { + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-secondary); + } + + .app__user-profile__section { + display: flex; + flex-direction: column; + border-top: 1px solid var(--str-chat__border-core-default); + padding-top: var(--str-chat__spacing-sm); + } + + .app__user-profile__section-title { + font: var(--str-chat__font-caption-default); + font-weight: 600; + color: var(--str-chat__text-secondary); + padding-bottom: var(--str-chat__spacing-xs); + } + + // label above the value, both tight, so a row costs little vertical space + .app__user-profile__field { + display: flex; + flex-direction: column; + padding: var(--str-chat__spacing-xs) 0; + } + + .app__user-profile__field-label { + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-secondary); + } + + .app__user-profile__field-value-row { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xs); + min-height: 32px; + } + + .app__user-profile__field-value { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .app__user-profile__field-empty { + color: var(--str-chat__text-tertiary); + } + + .app__user-profile__add { + display: flex; + flex-direction: column; + gap: var(--str-chat__spacing-xs); + border-top: 1px solid var(--str-chat__border-core-default); + padding-top: var(--str-chat__spacing-sm); + } + + .app__user-profile__add-inputs { + display: grid; + // minmax(0, …) and min-width: 0 let the inputs shrink below their intrinsic width, so the + // button stays inside the dialog + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: var(--str-chat__spacing-xs); + align-items: center; + + > * { + min-width: 0; + } + } + + .app__user-profile__error { + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-utility-error); + } + + .app__user-profile__hint { + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-tertiary); + } + + // Below the settings group, with no padding: the avatar fills the whole button, the size the + // other selector buttons reach with their icon plus padding. + .str-chat__chat-view__selector .str-chat__button.app__user-profile-button { + --app-user-profile-button-size: calc( + var(--str-chat__icon-size-md) + 2 * var(--str-chat__spacing-xs) + ); + + padding: 0; + border-radius: 50%; + + .str-chat__avatar { + flex-shrink: 0; + width: var(--app-user-profile-button-size); + height: var(--app-user-profile-button-size); + } + } +} diff --git a/examples/vite/src/UserProfile/UserProfileButton.tsx b/examples/vite/src/UserProfile/UserProfileButton.tsx new file mode 100644 index 0000000000..151e60fab3 --- /dev/null +++ b/examples/vite/src/UserProfile/UserProfileButton.tsx @@ -0,0 +1,34 @@ +import { useCallback, useState } from 'react'; +import { Avatar, GlobalModal, useChatContext } from 'stream-chat-react'; +import { ChatViewSelectorButton } from 'stream-chat-react/slot-layout'; + +import { UserDetailDialog } from './UserDetailDialog'; +import { useOwnUser } from './useOwnUser'; + +/** Sidebar entry showing the connected user's avatar; opens their profile. */ +export const UserProfileButton = ({ iconOnly = true }: { iconOnly?: boolean }) => { + const { client } = useChatContext(); + const [user, setUser] = useOwnUser(client); + const [open, setOpen] = useState(false); + const close = useCallback(() => setOpen(false), []); + + if (!user) return null; + const displayName = user.name || user.id; + + return ( + <> + setOpen(true)} + text={displayName} + > + + + + + + + ); +}; diff --git a/examples/vite/src/UserProfile/index.ts b/examples/vite/src/UserProfile/index.ts new file mode 100644 index 0000000000..b93336c206 --- /dev/null +++ b/examples/vite/src/UserProfile/index.ts @@ -0,0 +1,3 @@ +export * from './UserProfileButton'; +export * from './UserDetailDialog'; +export * from './useOwnUser'; diff --git a/examples/vite/src/UserProfile/useOwnUser.ts b/examples/vite/src/UserProfile/useOwnUser.ts new file mode 100644 index 0000000000..8441d05d78 --- /dev/null +++ b/examples/vite/src/UserProfile/useOwnUser.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; +import type { CustomUserData, StreamChat } from 'stream-chat'; + +/** The connected user's fields the profile shows and edits. */ +export type ProfileUser = { + custom: Record; + id: string; + image?: string; + name?: string; + role?: string; +}; + +/** Both `client.user` and the users an update returns carry these fields. */ +type UserFields = { + custom?: CustomUserData; + id: string; + image?: string; + name?: string; + role?: string; +}; + +export const toProfileUser = (user: UserFields): ProfileUser => ({ + custom: Object.fromEntries(Object.entries(user.custom ?? {})), + id: user.id, + image: user.image, + name: user.name, + role: user.role, +}); + +/** + * The connected user, kept current with `user.updated` events for that user. `client.user` is not + * reactive, so the event is what tells the profile that it changed, e.g. from another device. The + * setter lets a save show the server's answer right away, before or without that event. + */ +export const useOwnUser = (client: StreamChat) => { + const [user, setUser] = useState(() => + client.user ? toProfileUser(client.user) : undefined, + ); + + useEffect(() => { + setUser(client.user ? toProfileUser(client.user) : undefined); + const { unsubscribe } = client.on('user.updated', (event) => { + if (event.user?.id !== client.userID || !client.user) return; + setUser(toProfileUser(client.user)); + }); + return unsubscribe; + }, [client]); + + return [user, setUser] as const; +}; diff --git a/examples/vite/src/i18n/de.ts b/examples/vite/src/i18n/de.ts index c63b3feaf3..1eaa1e0545 100644 --- a/examples/vite/src/i18n/de.ts +++ b/examples/vite/src/i18n/de.ts @@ -728,6 +728,8 @@ export const deTranslations = { 'search.sourceResults.searching.text': 'Suche nach {{ searchSourceType }}...', 'slotLayout.chatView.channels.text': 'Kanäle', 'slotLayout.chatView.chatViewControls.ariaLabel': 'Chat-Ansichtssteuerung', + 'slotLayout.chatView.empty.channels.text': 'Kein Chat ausgewählt', + 'slotLayout.chatView.empty.threads.text': 'Kein Thread ausgewählt', 'slotLayout.chatView.openChannelsView.ariaLabel': 'Kanalansicht öffnen', 'slotLayout.chatView.openThreadsView.ariaLabel': 'Thread-Ansicht öffnen', 'slotLayout.chatView.openThreadsViewUnread.ariaLabel_one': diff --git a/examples/vite/src/i18n/it.ts b/examples/vite/src/i18n/it.ts index a895f04a7d..4de66c0356 100644 --- a/examples/vite/src/i18n/it.ts +++ b/examples/vite/src/i18n/it.ts @@ -726,6 +726,8 @@ export const itTranslations = { 'search.sourceResults.searching.text': 'Ricerca di {{ searchSourceType }}...', 'slotLayout.chatView.channels.text': 'Canali', 'slotLayout.chatView.chatViewControls.ariaLabel': 'Controlli della vista chat', + 'slotLayout.chatView.empty.channels.text': 'Nessuna chat selezionata', + 'slotLayout.chatView.empty.threads.text': 'Nessun thread selezionato', 'slotLayout.chatView.openChannelsView.ariaLabel': 'Apri la vista dei canali', 'slotLayout.chatView.openThreadsView.ariaLabel': 'Apri la vista dei thread', 'slotLayout.chatView.openThreadsViewUnread.ariaLabel_one': diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index b9efbab3fc..185fa61778 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -16,6 +16,8 @@ layer(stream-app-overrides); @import url('./SingleChannel/SingleChannel.scss') layer(stream-app-overrides); @import url('./AppSettings/ActionsMenu/DraggableDialog.scss') layer(stream-app-overrides); @import url('./Debug/ComposerInspector.scss') layer(stream-app-overrides); +@import url('./UserProfile/UserProfile.scss') layer(stream-app-overrides); +@import url('./NewConversation/NewConversation.scss') layer(stream-app-overrides); @import url('stream-chat-react/dist/css/emoji-picker.css') layer(stream-new-plugins); @import url('stream-chat-react/dist/css/channel-detail.css') layer(stream-new-plugins); diff --git a/examples/vite/tsconfig.node.json b/examples/vite/tsconfig.node.json index 97ede7ee6f..5b459bab24 100644 --- a/examples/vite/tsconfig.node.json +++ b/examples/vite/tsconfig.node.json @@ -7,5 +7,5 @@ "allowSyntheticDefaultImports": true, "strict": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "mockCdnPlugin.ts"] } diff --git a/examples/vite/vite.config.ts b/examples/vite/vite.config.ts index ec08152c13..9dc992420c 100644 --- a/examples/vite/vite.config.ts +++ b/examples/vite/vite.config.ts @@ -4,6 +4,7 @@ import { dirname, resolve } from 'node:path'; import { defineConfig, loadEnv } from 'vite'; import babel from 'vite-plugin-babel'; import react from '@vitejs/plugin-react'; +import { mockCdnPlugin } from './mockCdnPlugin'; const require = createRequire(import.meta.url); @@ -89,6 +90,7 @@ export default defineConfig(({ mode }) => { }, ] : []), + mockCdnPlugin({ storageDir: resolve(rootDir, '.mock-cdn') }), react(), babel({ babelConfig: { diff --git a/src/components/Attachment/__tests__/Card.test.tsx b/src/components/Attachment/__tests__/Card.test.tsx index a766261f68..18fb99d892 100644 --- a/src/components/Attachment/__tests__/Card.test.tsx +++ b/src/components/Attachment/__tests__/Card.test.tsx @@ -62,7 +62,10 @@ describe('Card', () => { beforeAll(async () => { chatClient = await getTestClientWithUser({ id: user.id }); useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - channel = chatClient.channel('messaging', mockedChannel['id']); + channel = chatClient.channelManager.ensure({ + id: mockedChannel['id'], + type: 'messaging', + }); channel.query(); }); diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index c6d74f422d..3644c78467 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -53,13 +53,9 @@ export const Channel = (props: PropsWithChildren) => { // Claim the channel while mounted (refcounted, so several consumers are fine). The client skips // re-seeding an active channel's message list on hydration, leaving the larger loaded window to - // `channel.reload()`. - useEffect(() => { - channel.activate(); - return () => { - channel.deactivate(); - }; - }, [channel]); + // `channel.reload()`. Activating also keeps the channel in the client's channel store for the + // session; the returned function only ends this mount's claim. + useEffect(() => channel.activate(), [channel]); // Keyed on the instance, not the cid: children subscribe to *this* channel's stores. const channelInstanceContextValue = useMemo(() => ({ channel }), [channel]); diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index 7e2d99c250..1e6ab3934b 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -11,7 +11,6 @@ import type { StreamChat, UserResponse, } from 'stream-chat'; -import { localMessageToNewMessagePayload } from 'stream-chat'; import { act, render, screen, waitFor } from '@testing-library/react'; import type { RenderResult } from '@testing-library/react'; @@ -126,7 +125,10 @@ const initClient = async ({ const chatClient = await getTestClientWithUser(user); // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.channel.id); + const channel = chatClient.channelManager.ensure({ + id: mockedChannel.channel.id, + type: 'messaging', + }); // `Channel` does not query any more -- whoever supplies the channel initializes it. await channel.watch(); @@ -216,7 +218,10 @@ describe('Channel', () => { // to it, but it does not fetch. A channel that arrives unqueried stays that way, and its // children render whatever an empty channel renders. const { chatClient } = await setup(); - const unqueried = chatClient.channel('messaging', 'never-queried'); + const unqueried = chatClient.channelManager.ensure({ + id: 'never-queried', + type: 'messaging', + }); const watchSpy = vi.spyOn(unqueried, 'watch'); await renderComponent({ channel: unqueried, chatClient }); @@ -501,11 +506,11 @@ describe('Channel', () => { .catch(() => {}); }); await waitFor(() => - // v10: single request object - `client.deleteMessage({ id, ...options })`. - expect(clientDeleteMessageSpy).toHaveBeenCalledWith({ - id: message.id, - ...deleteMessageOptions, - }), + // v10: the message id is a path parameter - `client.deleteMessage({ id }, options)`. + expect(clientDeleteMessageSpy).toHaveBeenCalledWith( + { id: message.id }, + deleteMessageOptions, + ), ); }); @@ -557,13 +562,35 @@ describe('Channel', () => { .catch(() => {}); }); await waitFor(() => - // v10: single request object - `client.updateMessage({ id, message })`, where `message` is - // the LocalMessage projected onto the API payload shape. - expect(clientUpdateMessageSpy).toHaveBeenCalledWith({ - id: updatedMessage.id, - message: localMessageToNewMessagePayload(fromPartial(updatedMessage)), - }), + // `client.updateMessage({ id }, { message })`: the id is a path parameter, and `message` is + // the edited message projected onto the update payload: the edited content plus its pin state. + expect(clientUpdateMessageSpy).toHaveBeenCalledWith( + { id: updatedMessage.id }, + { + message: expect.objectContaining({ + attachments: updatedMessage.attachments, + cid: updatedMessage.cid, + id: updatedMessage.id, + mentioned_users: [], + pinned: false, + pinned_at: null, + text: newText, + }), + }, + ), ); + // Server-owned fields are left out; sending them makes the update fail. + const [, { message: payload }] = clientUpdateMessageSpy.mock.calls[0]; + for (const serverOwnedField of [ + '__html', + 'created_at', + 'html', + 'type', + 'updated_at', + 'user', + ]) { + expect(payload).not.toHaveProperty(serverOwnedField); + } }); it('uses a registered updateMessageRequest for the edit path', async () => { diff --git a/src/components/Channel/__tests__/channelInstanceAxis.test.tsx b/src/components/Channel/__tests__/channelInstanceAxis.test.tsx index 6767ed43d0..5674f25095 100644 --- a/src/components/Channel/__tests__/channelInstanceAxis.test.tsx +++ b/src/components/Channel/__tests__/channelInstanceAxis.test.tsx @@ -17,6 +17,7 @@ import { Chat } from '../../Chat'; import { MessageList } from '../../MessageList'; import { initClientWithChannels } from '../../../mock-builders'; +import { Channel as StreamChannel } from 'stream-chat'; import type { Channel as ChannelType, StreamChat } from 'stream-chat'; const renderChannel = (client: StreamChat, channel: ChannelType) => ( @@ -39,10 +40,9 @@ describe('a replacement Channel instance for the same cid', () => { channelsData: [{ channel: { id: 'channel-a', type: 'messaging' } }], }); - // Dropping the cache entry is how a genuinely new object for the same cid appears -- the same - // thing `disconnectUser` does to every channel. - delete client.activeChannels[first.cid]; - const second = client.channel('messaging', 'channel-a'); + // A second object for the same cid. The client creates one after it drops the first (a deletion, + // `disconnectUser`), which also tears the first down; built directly here, the first stays usable. + const second = new StreamChannel(client, 'messaging', 'channel-a', {}); return { client, first, second }; }; @@ -70,14 +70,13 @@ describe('a replacement Channel instance for the same cid', () => { it('is activated, and the previous instance released', async () => { const { client, first, second } = await setup(); - const activateSecond = vi.spyOn(second, 'activate'); - const deactivateFirst = vi.spyOn(first, 'deactivate'); const { rerender } = render(renderChannel(client, first)); + await waitFor(() => expect(first.active).toBe(true)); rerender(renderChannel(client, second)); - await waitFor(() => expect(activateSecond).toHaveBeenCalled()); - expect(deactivateFirst).toHaveBeenCalled(); + await waitFor(() => expect(second.active).toBe(true)); + expect(first.active).toBe(false); }); }); @@ -90,7 +89,6 @@ describe('the same Channel instance re-rendered', () => { channelsData: [{ channel: { id: 'channel-a', type: 'messaging' } }], }); const activate = vi.spyOn(channel, 'activate'); - const deactivate = vi.spyOn(channel, 'deactivate'); const { rerender } = render(renderChannel(client, channel)); await waitFor(() => expect(activate).toHaveBeenCalledTimes(1)); @@ -98,7 +96,7 @@ describe('the same Channel instance re-rendered', () => { rerender(renderChannel(client, channel)); // A remount would release the channel and claim it again; the same instance keeps the same key. - expect(deactivate).not.toHaveBeenCalled(); expect(activate).toHaveBeenCalledTimes(1); + expect(channel.active).toBe(true); }); }); diff --git a/src/components/Channel/__tests__/channelSwitchReset.test.tsx b/src/components/Channel/__tests__/channelSwitchReset.test.tsx index 8846bcfa42..9a350fa282 100644 --- a/src/components/Channel/__tests__/channelSwitchReset.test.tsx +++ b/src/components/Channel/__tests__/channelSwitchReset.test.tsx @@ -17,6 +17,7 @@ import { Chat } from '../../Chat'; import { MessageList } from '../../MessageList'; import { initClientWithChannels } from '../../../mock-builders'; +import { Channel as StreamChannel } from 'stream-chat'; import type { Channel as ChannelType, StreamChat } from 'stream-chat'; const renderChannel = (client: StreamChat, channel: ChannelType) => ( @@ -92,8 +93,8 @@ describe('switching channels', () => { channels: [first], client, } = await setupTwo(); - delete client.activeChannels[first.cid]; - const second = client.channel('messaging', 'channel-a'); + // a second object for the same cid, while the first stays usable + const second = new StreamChannel(client, 'messaging', 'channel-a', {}); const { rerender } = render(renderChannel(client, first)); const listBefore = messageListElement(); diff --git a/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx b/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx index a89d334c6f..c75dd059ea 100644 --- a/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx +++ b/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx @@ -88,7 +88,11 @@ async function renderComponent({ testChannel1 = generateChannel({ ...defaultChannelState, channel: channelData }); /* eslint-disable-next-line react-hooks/rules-of-hooks */ useMockedApis(client, [getOrCreateChannelApi(testChannel1)]); - const channel = client.channel(channelType, testChannel1.channel.id, channelData); + const channel = client.channelManager.ensure({ + data: channelData, + id: testChannel1.channel.id, + type: channelType, + }); await channel.query(); return renderComponentBase({ channel, client, props }); @@ -198,7 +202,10 @@ describe('ChannelHeader', () => { client = await getTestClientWithUser(user1); testChannel1 = generateChannel({ ...defaultChannelState }); useMockedApis(client, [getOrCreateChannelApi(testChannel1)]); - const channel = client.channel('messaging', testChannel1.channel.id); + const channel = client.channelManager.ensure({ + id: testChannel1.channel.id, + type: 'messaging', + }); await channel.query(); renderComponentBase({ @@ -222,7 +229,10 @@ describe('ChannelHeader', () => { testChannel1 = generateChannel({ ...defaultChannelState }); /* eslint-disable-next-line react-hooks/rules-of-hooks */ useMockedApis(client, [getOrCreateChannelApi(testChannel1)]); - const channel = client.channel('messaging', testChannel1.channel.id); + const channel = client.channelManager.ensure({ + id: testChannel1.channel.id, + type: 'messaging', + }); await channel.query(); render( diff --git a/src/components/ChannelList/__tests__/ChannelLists.test.tsx b/src/components/ChannelList/__tests__/ChannelLists.test.tsx index 1f8f20dc25..bc3201cafa 100644 --- a/src/components/ChannelList/__tests__/ChannelLists.test.tsx +++ b/src/components/ChannelList/__tests__/ChannelLists.test.tsx @@ -38,7 +38,9 @@ const seededPaginator = (client: StreamChat, id: string) => { const paginator = new ChannelPaginator({ client, id }); paginator.setItems({ isLastPage: true, - valueOrFactory: [client.channel('messaging', id.replace(':', '-'))], + valueOrFactory: [ + client.channelManager.ensure({ id: id.replace(':', '-'), type: 'messaging' }), + ], }); return paginator; }; diff --git a/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx index ca18968fbe..2ee3590ec6 100644 --- a/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx +++ b/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx @@ -88,7 +88,7 @@ describe('ChannelPreviewMessenger', () => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(chatClient, [getOrCreateChannelApi(c)]); - channel = chatClient.channel('messaging'); + channel = chatClient.channelManager.ensure({ type: 'messaging' }); await channel.watch(); }; diff --git a/src/components/ChannelListItem/__tests__/utils.test.ts b/src/components/ChannelListItem/__tests__/utils.test.ts index 5c8e52f4b4..354e824199 100644 --- a/src/components/ChannelListItem/__tests__/utils.test.ts +++ b/src/components/ChannelListItem/__tests__/utils.test.ts @@ -36,7 +36,7 @@ describe('ChannelPreview utils', () => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(chatClient, [getOrCreateChannelApi(c)]); - const channel = chatClient.channel('messaging'); + const channel = chatClient.channelManager.ensure({ type: 'messaging' }); await channel.watch(); diff --git a/src/components/ChannelListItem/channelDisplayState.ts b/src/components/ChannelListItem/channelDisplayState.ts new file mode 100644 index 0000000000..ffd492e51e --- /dev/null +++ b/src/components/ChannelListItem/channelDisplayState.ts @@ -0,0 +1,90 @@ +import type { + Channel, + ChannelDataState, + ChannelMemberResponse, + MembersState, +} from 'stream-chat'; + +import { extractDisplayInfo } from '../Avatar/utils'; +import type { GroupChannelDisplayInfo, GroupChannelDisplayInfoMember } from './utils'; + +/** The channel state a channel's display name, image and group info are derived from. */ +export type ChannelDisplayState = { + data: Channel['data']; + members: Record; +}; + +/** + * Selects {@link ChannelDisplayState} from `channel.state`. The client replaces a member when that + * user is updated, and `channel.data` changes on `channel.updated`, so a subscription with this + * selector re-derives the display info only for the channels that changed. + */ +export const channelDisplayStateSelector = ( + state: ChannelDataState & MembersState, +): ChannelDisplayState => ({ + data: state.data, + members: state.members, +}); + +/** + * 1. data.custom.name + * 2. DM (exactly 2 members): other member's name, then directMessageLabel + * 3. Group (3+ members): comma-separated list of 2 other members' names (no ellipsis) + * 4. undefined otherwise + */ +export const deriveChannelDisplayName = ( + { data, members }: ChannelDisplayState, + directMessageLabel: string, + currentUserId: string | undefined, +): string | undefined => { + const name = data?.custom?.name; + if (name && typeof name === 'string') return name; + + const memberList = Object.values(members); + const otherMembers = memberList.filter((m) => m.user?.id !== currentUserId); + + if (memberList.length === 2 && otherMembers.length === 1) { + return otherMembers[0].user?.name || directMessageLabel; + } + if (otherMembers.length >= 2) { + const names = otherMembers + .map((m) => m.user?.name) + .filter(Boolean) + .slice(0, 2) as string[]; + if (names.length > 0) return names.join(', '); + } + return undefined; +}; + +/** data.custom.image, or for a DM (2 members) the other member's user.image. */ +export const deriveChannelDisplayImage = ( + { data, members }: ChannelDisplayState, + currentUserId?: string, +): string | undefined => { + const image = data?.custom?.image; + if (image && typeof image === 'string') return image; + + const memberList = Object.values(members); + if (memberList.length === 2) { + const other = memberList.find((m) => m.user?.id !== currentUserId); + const otherImage = other?.user?.image; + if (otherImage && typeof otherImage === 'string') return otherImage; + } + return undefined; +}; + +/** The members shown for a group channel (3+ members); undefined for a DM or an empty channel. */ +export const deriveGroupChannelDisplayInfo = ({ + members, +}: ChannelDisplayState): GroupChannelDisplayInfo | undefined => { + const memberList = Object.values(members); + if (memberList.length <= 2) return; + + const displayMembers: GroupChannelDisplayInfoMember[] = []; + for (const member of memberList) { + const { user } = member; + if (!user?.name && !user?.image) continue; + displayMembers.push(extractDisplayInfo(member)); + } + return { members: displayMembers }; +}; diff --git a/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx index fee3264699..bf4e2a1008 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx @@ -32,7 +32,10 @@ const getClientAndChannel = async (channelOverrides = {}) => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channel('messaging', mockedChannel.channel.id); + const channel = client.channelManager.ensure({ + id: mockedChannel.channel.id, + type: 'messaging', + }); await channel.watch(); return { channel, client }; @@ -135,6 +138,38 @@ describe('useChannelPreviewInfo', () => { ).toBe(true); }); + it('does not re-render a group channel when a user outside it is updated', async () => { + const { channel, client } = await getClientAndChannel({ + members: [ + generateMember({ user: generateUser({ image: 'a.jpg', name: 'A' }) }), + generateMember({ user: generateUser({ image: 'b.jpg', name: 'B' }) }), + generateMember({ user: clientUser }), + ], + }); + let renders = 0; + const { result } = renderHook( + () => { + renders += 1; + return useChannelPreviewInfo({ channel }); + }, + { wrapper: createWrapper(client) }, + ); + const before = result.current; + const rendersBefore = renders; + + act(() => { + client.dispatchEvent( + fromPartial({ + type: 'user.updated', + user: generateUser({ id: 'outsider', name: 'Outsider' }), + }), + ); + }); + + expect(renders).toBe(rendersBefore); + expect(result.current).toBe(before); + }); + it('uses overrideTitle over channel display title', async () => { const { channel, client } = await getClientAndChannel({ channel: { custom: { name: 'Channel Name' } }, @@ -161,74 +196,92 @@ describe('useChannelPreviewInfo', () => { expect(result.current.displayImage).toBe('https://override.jpg'); }); - it('subscribes to user.updated and updates displayImage and groupChannelDisplayInfo', async () => { - const imageUrl = 'https://initial.jpg'; + it('shows an updated member in a DM title and image', async () => { + const other = generateUser({ id: 'other', image: 'before.jpg', name: 'Before' }); const { channel, client } = await getClientAndChannel({ - channel: { custom: { image: imageUrl } }, + members: [generateMember({ user: clientUser }), generateMember({ user: other })], }); - - const onSpy = vi.spyOn(client, 'on'); - const { result } = renderHook(() => useChannelPreviewInfo({ channel }), { wrapper: createWrapper(client), }); - - expect(result.current.displayImage).toBe(imageUrl); - expect(onSpy).toHaveBeenCalledWith('user.updated', expect.any(Function)); - - const updateInfo = onSpy.mock.calls.find((c) => c[0] === 'user.updated')?.[1]; - expect(updateInfo).toBeDefined(); + expect(result.current.displayTitle).toBe('Before'); + expect(result.current.displayImage).toBe('before.jpg'); act(() => { - updateInfo(fromPartial({})); + client.dispatchEvent( + fromPartial({ + type: 'user.updated', + user: { ...other, image: 'after.jpg', name: 'After' }, + }), + ); }); - expect(result.current.displayImage).toBe(imageUrl); - - onSpy.mockRestore(); + expect(result.current.displayTitle).toBe('After'); + expect(result.current.displayImage).toBe('after.jpg'); }); - it('does not subscribe to user.updated for image when overrideImage is set', async () => { + it('shows an updated member in the group members', async () => { + const a = generateUser({ id: 'a', image: 'a.jpg', name: 'A' }); const { channel, client } = await getClientAndChannel({ - channel: { custom: { image: 'https://channel.jpg' } }, + members: [ + generateMember({ user: a }), + generateMember({ user: generateUser({ image: 'b.jpg', name: 'B' }) }), + generateMember({ user: clientUser }), + ], + }); + const { result } = renderHook(() => useChannelPreviewInfo({ channel }), { + wrapper: createWrapper(client), + }); + + act(() => { + client.dispatchEvent( + fromPartial({ + type: 'user.updated', + user: { ...a, image: 'a2.jpg', name: 'A2' }, + }), + ); }); - const onSpy = vi.spyOn(client, 'on'); + expect(result.current.groupChannelDisplayInfo.members).toContainEqual( + expect.objectContaining({ imageUrl: 'a2.jpg', userName: 'A2' }), + ); + }); - renderHook( + it('keeps overrideImage when a member is updated', async () => { + const other = generateUser({ id: 'other', image: 'before.jpg' }); + const { channel, client } = await getClientAndChannel({ + members: [generateMember({ user: clientUser }), generateMember({ user: other })], + }); + const { result } = renderHook( () => useChannelPreviewInfo({ channel, overrideImage: 'https://override.jpg' }), { wrapper: createWrapper(client) }, ); - // useChannelDisplayName always subscribes to user.updated (updateDisplayName), - // but the image-related subscription (updateInfo) from useChannelPreviewInfo should not be present. - const userUpdatedCalls = onSpy.mock.calls.filter((c) => c[0] === 'user.updated'); - // Only the useChannelDisplayName subscription should be present - expect(userUpdatedCalls).toHaveLength(1); - expect(userUpdatedCalls[0][1].name).toBe('updateDisplayName'); + act(() => { + client.dispatchEvent( + fromPartial({ + type: 'user.updated', + user: { ...other, image: 'after.jpg' }, + }), + ); + }); - onSpy.mockRestore(); + expect(result.current.displayImage).toBe('https://override.jpg'); }); - it('unsubscribes from user.updated on unmount', async () => { - const { channel, client } = await getClientAndChannel(); - - const onSpy = vi.spyOn(client, 'on'); - const offSpy = vi.spyOn(client, 'off'); - - const { unmount } = renderHook(() => useChannelPreviewInfo({ channel }), { + it('follows channel.data, e.g. after channel.updated', async () => { + const { channel, client } = await getClientAndChannel({ + channel: { custom: { image: 'https://before.jpg' } }, + }); + const { result } = renderHook(() => useChannelPreviewInfo({ channel }), { wrapper: createWrapper(client), }); - expect(onSpy).toHaveBeenCalledWith('user.updated', expect.any(Function)); - const updateInfo = onSpy.mock.calls.find((c) => c[0] === 'user.updated')?.[1]; - - unmount(); - - expect(offSpy).toHaveBeenCalledWith('user.updated', updateInfo); + act(() => { + channel.data = { ...channel.data, custom: { image: 'https://after.jpg' } }; + }); - onSpy.mockRestore(); - offSpy.mockRestore(); + expect(result.current.displayImage).toBe('https://after.jpg'); }); }); }); diff --git a/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx index 4f27b9f91a..d1673b9a53 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx @@ -33,7 +33,10 @@ describe('useIsChannelMuted', () => { // channel.muteStatus() this hook used to call throws `_checkInitialized` on such a // channel and crashed the app when it was rendered in the ChannelList (issue #2474). // Reading the reactive `muteStatus` slice is safe instead. - const channel = client.channel('messaging', 'never-watched-channel'); + const channel = client.channelManager.ensure({ + id: 'never-watched-channel', + type: 'messaging', + }); expect(channel.initialized).toBe(false); @@ -48,7 +51,10 @@ describe('useIsChannelMuted', () => { const client = await getTestClientWithUser(clientUser); const mockedChannel = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channel('messaging', mockedChannel.channel.id); + const channel = client.channelManager.ensure({ + id: mockedChannel.channel.id, + type: 'messaging', + }); await channel.watch(); const { result } = renderHook(() => useIsChannelMuted(channel), { @@ -62,7 +68,10 @@ describe('useIsChannelMuted', () => { const client = await getTestClientWithUser(clientUser); const mockedChannel = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channel('messaging', mockedChannel.channel.id); + const channel = client.channelManager.ensure({ + id: mockedChannel.channel.id, + type: 'messaging', + }); await channel.watch(); expect(channel.initialized).toBe(true); @@ -83,7 +92,10 @@ describe('useIsChannelMuted', () => { const client = await getTestClientWithUser(clientUser); const mockedChannel = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channel('messaging', mockedChannel.channel.id); + const channel = client.channelManager.ensure({ + id: mockedChannel.channel.id, + type: 'messaging', + }); await channel.watch(); const { result } = renderHook(() => useIsChannelMuted(channel), { diff --git a/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx index 1928f9eb9b..c06615eb87 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx @@ -41,7 +41,10 @@ const getClientAndChannel = async (channelData = {}, user = ownUser) => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channel('messaging', mockedChannel.channel.id); + const channel = client.channelManager.ensure({ + id: mockedChannel.channel.id, + type: 'messaging', + }); await channel.watch(); return { diff --git a/src/components/ChannelListItem/hooks/useChannelDisplayName.ts b/src/components/ChannelListItem/hooks/useChannelDisplayName.ts index c08470a59e..9f32fa4855 100644 --- a/src/components/ChannelListItem/hooks/useChannelDisplayName.ts +++ b/src/components/ChannelListItem/hooks/useChannelDisplayName.ts @@ -1,39 +1,13 @@ -import { useEffect, useState } from 'react'; +import { useMemo } from 'react'; import type { Channel } from 'stream-chat'; import { useChatContext } from '../../../context'; import { useTranslationContext } from '../../../context/TranslationContext'; - -/** - * 1. channel.data.custom.name - * 2. DM (exactly 2 members): other member's name, then directMessageLabel - * 3. Group (3+ members): comma-separated list of 2 other members' names (no ellipsis) - * 4. undefined otherwise - */ -function computeChannelDisplayName( - channel: Channel, - directMessageLabel: string, - currentUserId: string | undefined, -): string | undefined { - const name = channel.data?.custom?.name; - if (name && typeof name === 'string') return name; - - const memberList = Object.values(channel.state.members); - const otherMembers = memberList.filter((m) => m.user?.id !== currentUserId); - - if (memberList.length === 2 && otherMembers.length === 1) { - const name = otherMembers[0].user?.name; - return name || directMessageLabel; - } - if (otherMembers.length >= 2) { - const names = otherMembers - .map((m) => m.user?.name) - .filter(Boolean) - .slice(0, 2) as string[]; - if (names.length > 0) return names.join(', '); - } - return undefined; -} +import { useStateStore } from '../../../store'; +import { + channelDisplayStateSelector, + deriveChannelDisplayName, +} from '../channelDisplayState'; /** * Channel display name with translation context. @@ -41,6 +15,9 @@ function computeChannelDisplayName( * 2. DM (exactly 2 members): other member's name, then translated "Direct message" * 3. Group (3+ members): comma-separated list of 2 other members' names (no ellipsis) * 4. undefined otherwise + * + * Re-derived from the channel's `data` and `members`, so it changes only when this channel does: + * an updated user reaches it through the member the client replaces. */ export const useChannelDisplayName = ( channel: Channel | undefined, @@ -51,32 +28,14 @@ export const useChannelDisplayName = ( 'channelListItem.channelDisplayName.directMessage.label', 'Direct message', ); - - const [displayName, setDisplayName] = useState(() => - channel - ? computeChannelDisplayName(channel, directMessageLabel, client.userID ?? undefined) - : undefined, + const displayState = useStateStore(channel?.state, channelDisplayStateSelector); + const currentUserId = client.userID ?? undefined; + + return useMemo( + () => + displayState + ? deriveChannelDisplayName(displayState, directMessageLabel, currentUserId) + : undefined, + [currentUserId, directMessageLabel, displayState], ); - - useEffect(() => { - if (!channel) { - setDisplayName(undefined); - return; - } - const updateDisplayName = () => - setDisplayName( - computeChannelDisplayName( - channel, - directMessageLabel, - client.userID ?? undefined, - ), - ); - updateDisplayName(); - client.on('user.updated', updateDisplayName); - return () => { - client.off('user.updated', updateDisplayName); - }; - }, [channel, channel?.data, client, directMessageLabel]); - - return displayName; }; diff --git a/src/components/ChannelListItem/hooks/useChannelPreviewInfo.ts b/src/components/ChannelListItem/hooks/useChannelPreviewInfo.ts index 33e3234a5d..b7f2770521 100644 --- a/src/components/ChannelListItem/hooks/useChannelPreviewInfo.ts +++ b/src/components/ChannelListItem/hooks/useChannelPreviewInfo.ts @@ -1,12 +1,14 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import type { Channel } from 'stream-chat'; import { useChatContext } from '../../../context'; +import { useStateStore } from '../../../store'; import { - getChannelDisplayImage, - getGroupChannelDisplayInfo, - type GroupChannelDisplayInfo, -} from '../utils'; + channelDisplayStateSelector, + deriveChannelDisplayImage, + deriveGroupChannelDisplayInfo, +} from '../channelDisplayState'; +import type { GroupChannelDisplayInfo } from '../utils'; import { useChannelDisplayName } from './useChannelDisplayName'; const emptyGroupInfo: GroupChannelDisplayInfo = { @@ -23,6 +25,11 @@ export type ChannelPreviewInfoParams = { overrideTitle?: string; }; +/** + * The title, image and group members a channel preview shows. Derived from the channel's `data` + * and `members`, so a preview re-renders only when its own channel changes: an updated user reaches + * the channels that contain them through the member the client replaces. + */ export const useChannelPreviewInfo = (props: ChannelPreviewInfoParams) => { const { channel, overrideImage, overrideTitle } = props; const { client } = useChatContext(); @@ -30,43 +37,22 @@ export const useChannelPreviewInfo = (props: ChannelPreviewInfoParams) => { const channelDisplayName = useChannelDisplayName(channel); const displayTitle = overrideTitle ?? channelDisplayName; - const [displayImage, setDisplayImage] = useState(() => - channel - ? (overrideImage ?? getChannelDisplayImage(channel, client.userID ?? undefined)) - : undefined, - ); - const [groupChannelDisplayInfo, setGroupChannelDisplayInfo] = - useState(() => - channel ? (getGroupChannelDisplayInfo(channel) ?? emptyGroupInfo) : emptyGroupInfo, - ); - - useEffect(() => { - if (!channel) return; - if (overrideImage) return; - - const updateInfo = () => { - setDisplayImage(getChannelDisplayImage(channel, client.userID ?? undefined)); - setGroupChannelDisplayInfo(getGroupChannelDisplayInfo(channel) ?? emptyGroupInfo); - }; + const displayState = useStateStore(channel?.state, channelDisplayStateSelector); + const currentUserId = client.userID ?? undefined; - updateInfo(); - const { unsubscribe: unsubscribeChannelUpdated } = channel.on( - 'channel.updated', - updateInfo, - ); - const { unsubscribe: unsubscribeUserUpdated } = client.on('user.updated', updateInfo); - return () => { - unsubscribeChannelUpdated(); - unsubscribeUserUpdated(); - }; - }, [channel, channel?.data, client, overrideImage]); + const displayImage = useMemo( + () => + overrideImage ?? + (displayState ? deriveChannelDisplayImage(displayState, currentUserId) : undefined), + [currentUserId, displayState, overrideImage], + ); + const groupChannelDisplayInfo = useMemo( + () => (displayState && deriveGroupChannelDisplayInfo(displayState)) ?? emptyGroupInfo, + [displayState], + ); return useMemo( - () => ({ - displayImage: overrideImage ?? displayImage, - displayTitle, - groupChannelDisplayInfo, - }), - [displayImage, displayTitle, groupChannelDisplayInfo, overrideImage], + () => ({ displayImage, displayTitle, groupChannelDisplayInfo }), + [displayImage, displayTitle, groupChannelDisplayInfo], ); }; diff --git a/src/components/ChannelListItem/utils.tsx b/src/components/ChannelListItem/utils.tsx index 82b060ab3d..7204dd8027 100644 --- a/src/components/ChannelListItem/utils.tsx +++ b/src/components/ChannelListItem/utils.tsx @@ -13,7 +13,10 @@ import type { PluggableList } from 'unified'; import { htmlToTextPlugin, imageToLink, plusPlusToEmphasis } from '../Message'; import { isMessageDeleted } from '../Message/utils'; import remarkGfm from 'remark-gfm'; -import { extractDisplayInfo } from '../Avatar/utils'; +import { + deriveChannelDisplayImage, + deriveGroupChannelDisplayInfo, +} from './channelDisplayState'; const remarkPlugins: PluggableList = [ htmlToTextPlugin, @@ -358,34 +361,13 @@ export type GroupChannelDisplayInfo = { export const getChannelDisplayImage = ( channel: Channel, currentUserId?: string, -): string | undefined => { - const image = channel.data?.custom?.image; - if (image && typeof image === 'string') return image; - - const memberList = Object.values(channel.state.members); - if (memberList.length === 2) { - const other = memberList.find((m) => m.user?.id !== currentUserId); - const image = other?.user?.image; - if (image && typeof image === 'string') return image; - } - return undefined; -}; +): string | undefined => + deriveChannelDisplayImage( + { data: channel.data, members: channel.state.members }, + currentUserId, + ); export const getGroupChannelDisplayInfo = ( channel: Channel, -): GroupChannelDisplayInfo | undefined => { - const members = Object.values(channel.state.members); - if (members.length <= 2) return; - - const memberList: GroupChannelDisplayInfoMember[] = []; - for (const member of members) { - const { user } = member; - - if (!user?.name && !user?.image) continue; - - memberList.push(extractDisplayInfo(member)); - } - return { - members: memberList, - }; -}; +): GroupChannelDisplayInfo | undefined => + deriveGroupChannelDisplayInfo({ data: channel.data, members: channel.state.members }); diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index c31919daff..7ef870e022 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -1,5 +1,5 @@ import type { PropsWithChildren } from 'react'; -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import type { StreamChat } from 'stream-chat'; import { ChannelSearchSource, @@ -143,6 +143,16 @@ export const Chat = (props: PropsWithChildren) => { [client, customChannelSearchController], ); + // The controller created here is this component's to release: its configuration subscription, and + // a channel search's hold on its results in the channel store. One passed in belongs to the app. + // Disposing in the cleanup is safe under StrictMode, whose second mount of the same instance + // takes everything back through `registerSubscriptions()`. + useEffect(() => { + if (customChannelSearchController) return; + searchController.registerSubscriptions(); + return () => searchController.dispose(); + }, [customChannelSearchController, searchController]); + const chatContextValue = useCreateChatContext({ channelManager: client.channelManager, client, diff --git a/src/components/Chat/__tests__/Chat.test.tsx b/src/components/Chat/__tests__/Chat.test.tsx index 8f6a6836a0..f4108a827b 100644 --- a/src/components/Chat/__tests__/Chat.test.tsx +++ b/src/components/Chat/__tests__/Chat.test.tsx @@ -2,7 +2,7 @@ import React, { useContext } from 'react'; import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import type { OwnUserResponse, StreamChat } from 'stream-chat'; -import { ChannelPaginator } from 'stream-chat'; +import { ChannelPaginator, SearchController } from 'stream-chat'; import { Chat } from '..'; @@ -223,6 +223,64 @@ describe('Chat', () => { }); }); + describe('search controller', () => { + it('disposes the controller it created when it unmounts', async () => { + let controller: ChatContextValue['searchController'] | undefined; + const { unmount } = render( + + { + controller = ctx.searchController; + }} + /> + , + ); + await waitFor(() => expect(controller).toBeDefined()); + const dispose = vi.spyOn(controller as SearchController, 'dispose'); + + unmount(); + + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('leaves a controller passed in to the app', () => { + const controller = new SearchController({ client: chatClient }); + const dispose = vi.spyOn(controller, 'dispose'); + const registerSubscriptions = vi.spyOn(controller, 'registerSubscriptions'); + const { unmount } = render( + +
+ , + ); + + unmount(); + + expect(registerSubscriptions).not.toHaveBeenCalled(); + expect(dispose).not.toHaveBeenCalled(); + }); + + it('takes back what it released when StrictMode mounts it a second time', async () => { + let controller: ChatContextValue['searchController'] | undefined; + render( + + + { + controller = ctx.searchController; + }} + /> + + , + ); + await waitFor(() => expect(controller).toBeDefined()); + + // a disposed controller would stop hearing client.config + chatClient.config.set({ searchController: { keepSingleActiveSource: false } }); + expect(controller?.config.keepSingleActiveSource).toBe(false); + chatClient.config.reset('searchController'); + }); + }); + describe('channel manager', () => { it('exposes the client channel manager on the context', async () => { const client = getTestClient(); diff --git a/src/components/Chat/hooks/useCreateChatContext.ts b/src/components/Chat/hooks/useCreateChatContext.ts index f0f2819274..964eac907d 100644 --- a/src/components/Chat/hooks/useCreateChatContext.ts +++ b/src/components/Chat/hooks/useCreateChatContext.ts @@ -15,7 +15,7 @@ export const useCreateChatContext = (value: ChatContextValue) => { useImageFlagEmojisOnWindows, } = value; - const clientValues = `${client.clientID}${Object.keys(client.activeChannels).length}${ + const clientValues = `${client.clientID}${client.channelManager.values().length}${ Object.keys(client.listeners).length }${client.mutedChannels.length} ${client.user?.id}`; diff --git a/src/components/Message/__tests__/Message.test.tsx b/src/components/Message/__tests__/Message.test.tsx index 495d0f1013..16c8db6272 100644 --- a/src/components/Message/__tests__/Message.test.tsx +++ b/src/components/Message/__tests__/Message.test.tsx @@ -117,7 +117,7 @@ async function renderComponent({ }); // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(channelData)]); - channel = client.channel(type, channelData.channel.id); + channel = client.channelManager.ensure({ id: channelData.channel.id, type }); await channel.watch(); client.channelServerConfigsStore.partialNext({ configs: { ...client.channelServerConfigs, [channel.cid]: config as never }, @@ -261,13 +261,15 @@ describe(' component', () => { }); await context.handleReaction(reaction.type); - expect(sendReaction).toHaveBeenCalledWith({ - id: message.id, - reaction: { - emoji_code: '❤️', - type: reaction.type, + expect(sendReaction).toHaveBeenCalledWith( + { id: message.id }, + { + reaction: { + emoji_code: '❤️', + type: reaction.type, + }, }, - }); + ); }); // MERGE-RECONCILE (test migration): the reaction handler no longer gates on the diff --git a/src/components/Message/__tests__/MessageUI.test.tsx b/src/components/Message/__tests__/MessageUI.test.tsx index 88972940f8..4659075773 100644 --- a/src/components/Message/__tests__/MessageUI.test.tsx +++ b/src/components/Message/__tests__/MessageUI.test.tsx @@ -721,8 +721,8 @@ describe('', () => { // MERGE-RECONCILE (test migration): the also-sent-in-channel "View" navigation moved from the // ChannelActionContext `openThread` handler to useMessageAlsoSentInChannelNavigation, which - // resolves the parent thread via `client.getThreadAndHydrate` (v10 rename of `getThread`) and - // then navigates through ChatView `open`. + // resolves the parent thread through `client.threads.get()`, fetching it with + // `client.getThreadAndHydrate` when it is not registered, and then navigates through ChatView `open`. it('should open thread when View button is clicked and parent thread is resolved', async () => { const parentMessage = generateMessage({ id: 'x' }); const message = generateAliceMessage({ diff --git a/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx b/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx index f4c15a4d85..4129e5087d 100644 --- a/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx @@ -53,7 +53,10 @@ describe('useDeleteHandler custom hook', () => { client = await getTestClientWithUser(generateUser()); const channelData = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(channelData)]); - channel = client.channel('messaging', channelData.channel.id); + channel = client.channelManager.ensure({ + id: channelData.channel.id, + type: 'messaging', + }); }); afterEach(() => { diff --git a/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx b/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx index 3856cb5264..e8927d1eb7 100644 --- a/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx +++ b/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx @@ -4,6 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Shared spies + mutable per-test state. `vi.hoisted` runs before the `vi.mock` factories below, so // they can close over these. const mocks = vi.hoisted(() => ({ + addError: vi.fn(), + getRegisteredThread: vi.fn(), + getThreadAndHydrate: vi.fn(), ingestChannel: vi.fn(), jumpToMessage: vi.fn(() => Promise.resolve(true)), openChannel: vi.fn(), @@ -29,9 +32,9 @@ vi.mock('../../../../context', () => ({ useChatContext: () => ({ channelManager: { ingestChannel: mocks.ingestChannel }, client: { - getThread: vi.fn(), - notifications: { addError: vi.fn() }, - threads: { threadsById: {} }, + getThreadAndHydrate: mocks.getThreadAndHydrate, + notifications: { addError: mocks.addError }, + threads: { get: mocks.getRegisteredThread }, }, }), useMessageContext: () => ({ message: mocks.state.message }), @@ -137,4 +140,71 @@ describe('useMessageAlsoSentInChannelNavigation', () => { expect(mocks.jumpToMessage).toHaveBeenCalledWith('reply-1'); }); }); + + describe('viewReplyInThread', () => { + const makeThread = (id = 'parent-1') => ({ + id, + messagePaginator: { jumpToMessage: vi.fn(() => Promise.resolve(true)) }, + }); + + it('opens the registered thread without fetching it', async () => { + const registered = makeThread(); + mocks.getRegisteredThread.mockReturnValue(registered); + + const result = renderNavigation(); + await result.current.viewReplyInThread('reply-1', 'parent-1'); + + expect(mocks.getRegisteredThread).toHaveBeenCalledWith('parent-1'); + expect(mocks.getThreadAndHydrate).not.toHaveBeenCalled(); + expect(mocks.openThread).toHaveBeenCalledWith(registered); + expect(registered.messagePaginator.jumpToMessage).toHaveBeenCalledWith('reply-1'); + }); + + it('fetches a thread that is not registered', async () => { + const fetched = makeThread(); + mocks.getRegisteredThread.mockReturnValue(undefined); + mocks.getThreadAndHydrate.mockResolvedValue(fetched); + + const result = renderNavigation(); + await result.current.viewReplyInThread('reply-1', 'parent-1'); + + expect(mocks.getThreadAndHydrate).toHaveBeenCalledWith('parent-1', { watch: true }); + expect(mocks.openThread).toHaveBeenCalledWith(fetched); + expect(fetched.messagePaginator.jumpToMessage).toHaveBeenCalledWith('reply-1'); + }); + + it('reuses an instance registered while the fetch was in flight', async () => { + // E.g. a thread list query landing during the request: opening the fetched copy would leave + // two instances of one thread, and the second one gets no events. + const registeredMeanwhile = makeThread(); + const fetched = makeThread(); + mocks.getRegisteredThread.mockReturnValue(undefined); + mocks.getThreadAndHydrate.mockImplementation(() => { + mocks.getRegisteredThread.mockReturnValue(registeredMeanwhile); + return Promise.resolve(fetched); + }); + + const result = renderNavigation(); + await result.current.viewReplyInThread('reply-1', 'parent-1'); + + expect(mocks.openThread).toHaveBeenCalledWith(registeredMeanwhile); + expect(registeredMeanwhile.messagePaginator.jumpToMessage).toHaveBeenCalledWith( + 'reply-1', + ); + expect(fetched.messagePaginator.jumpToMessage).not.toHaveBeenCalled(); + }); + + it('reports a thread that cannot be fetched', async () => { + mocks.getRegisteredThread.mockReturnValue(undefined); + mocks.getThreadAndHydrate.mockRejectedValue(new Error('not found')); + + const result = renderNavigation(); + await result.current.viewReplyInThread('reply-1', 'parent-1'); + + expect(mocks.addError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Thread has not been found' }), + ); + expect(mocks.openThread).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx b/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx index bd8507544a..11d1f94f7a 100644 --- a/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx @@ -63,7 +63,10 @@ describe('useReactionHandler custom hook', () => { client = await getTestClientWithUser(alice); const channelData = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(channelData)]); - channel = client.channel('messaging', channelData.channel.id); + channel = client.channelManager.ensure({ + id: channelData.channel.id, + type: 'messaging', + }); }); afterEach(() => { @@ -114,13 +117,15 @@ describe('useReactionHandler custom hook', () => { const message = generateMessage({ own_reactions: [] }); const handleReaction = await renderUseReactionHandlerHook({ message }); await handleReaction('love'); - expect(sendReaction).toHaveBeenCalledWith({ - id: message.id, - reaction: { - emoji_code: '❤️', - type: 'love', + expect(sendReaction).toHaveBeenCalledWith( + { id: message.id }, + { + reaction: { + emoji_code: '❤️', + type: 'love', + }, }, - }); + ); }); it('should send reaction without emoji_code when the type has no unicode', async () => { @@ -130,12 +135,14 @@ describe('useReactionHandler custom hook', () => { const message = generateMessage({ own_reactions: [] }); const handleReaction = await renderUseReactionHandlerHook({ message }); await handleReaction('unsupported-reaction-type'); - expect(sendReaction).toHaveBeenCalledWith({ - id: message.id, - reaction: { - type: 'unsupported-reaction-type', + expect(sendReaction).toHaveBeenCalledWith( + { id: message.id }, + { + reaction: { + type: 'unsupported-reaction-type', + }, }, - }); + ); }); it('should derive emoji_code from custom reaction options provided via context', async () => { @@ -158,13 +165,15 @@ describe('useReactionHandler custom hook', () => { message, }); await handleReaction('rocket'); - expect(sendReaction).toHaveBeenCalledWith({ - id: message.id, - reaction: { - emoji_code: '🚀', - type: 'rocket', + expect(sendReaction).toHaveBeenCalledWith( + { id: message.id }, + { + reaction: { + emoji_code: '🚀', + type: 'rocket', + }, }, - }); + ); }); it('should stamp emoji_code on the optimistic reaction preview ingested into the paginator', async () => { diff --git a/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx b/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx index 4e82cee80b..1e23c671e2 100644 --- a/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx +++ b/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx @@ -52,13 +52,15 @@ describe('useReactionsFetcher', () => { }); expect(queryReactions).toHaveBeenCalledTimes(2); - // v10: `client.queryReactions` takes a single request object. - expect(queryReactions.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ id: message.id, limit: 25, next: undefined }), - ); - expect(queryReactions.mock.calls[1]?.[0]).toEqual( - expect.objectContaining({ id: message.id, limit: 25, next: 'page-2' }), - ); + // v10: the message id is a path parameter, separate from the request. + expect(queryReactions.mock.calls[0]?.slice(0, 2)).toEqual([ + { id: message.id }, + expect.objectContaining({ limit: 25, next: undefined }), + ]); + expect(queryReactions.mock.calls[1]?.slice(0, 2)).toEqual([ + { id: message.id }, + expect.objectContaining({ limit: 25, next: 'page-2' }), + ]); expect(reactions!).toHaveLength(2); }); }); diff --git a/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts b/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts index f6209df743..9435597631 100644 --- a/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts +++ b/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts @@ -71,11 +71,16 @@ export const useMessageAlsoSentInChannelNavigation = parentId = message?.parent_id, ) => { if (!replyId || !parentId) return; - let targetThread = client.threads.threadsById[parentId]; + let targetThread = client.threads.get(parentId); if (!targetThread) { try { - targetThread = await client.getThreadAndHydrate(parentId, { watch: true }); + const fetchedThread = await client.getThreadAndHydrate(parentId, { + watch: true, + }); + // An instance registered while the request was in flight (e.g. by a thread list query) + // wins, so there is only one live instance per thread. + targetThread = client.threads.get(parentId) ?? fetchedThread; } catch (error) { addThreadNotFoundNotification(error as Error); return; diff --git a/src/components/Message/hooks/useReactionHandler.ts b/src/components/Message/hooks/useReactionHandler.ts index 5794ca5f7b..aad97586e0 100644 --- a/src/components/Message/hooks/useReactionHandler.ts +++ b/src/components/Message/hooks/useReactionHandler.ts @@ -114,13 +114,15 @@ export const useReactionHandler = (message?: LocalMessage) => { try { updateMessage(tempMessage); const messageResponse = add - ? await channel.sendReaction({ - id, - reaction: { - type, - ...(emojiCode && { emoji_code: emojiCode }), - } as ReactionRequest, - }) + ? await channel.sendReaction( + { id }, + { + reaction: { + type, + ...(emojiCode && { emoji_code: emojiCode }), + } as ReactionRequest, + }, + ) : await channel.deleteReaction({ id, type }); // seems useless as we're expecting WS event to come in and replace this anyway diff --git a/src/components/Message/hooks/useReactionsFetcher.ts b/src/components/Message/hooks/useReactionsFetcher.ts index 03c0c064b6..437ead4247 100644 --- a/src/components/Message/hooks/useReactionsFetcher.ts +++ b/src/components/Message/hooks/useReactionsFetcher.ts @@ -30,13 +30,15 @@ async function fetchMessageReactions( let hasNext = true; while (hasNext && reactions.length < MAX_MESSAGE_REACTIONS_TO_FETCH) { - const response = await client.queryReactions({ - filter: reactionType ? { type: reactionType } : {}, - id: messageId, - limit, - next, - sort, - }); + const response = await client.queryReactions( + { id: messageId }, + { + filter: reactionType ? { type: reactionType } : {}, + limit, + next, + sort, + }, + ); reactions.push(...response.reactions); next = response.next; diff --git a/src/components/MessageActions/RemindMeSubmenu.tsx b/src/components/MessageActions/RemindMeSubmenu.tsx index f396008d67..2660b7a2e2 100644 --- a/src/components/MessageActions/RemindMeSubmenu.tsx +++ b/src/components/MessageActions/RemindMeSubmenu.tsx @@ -54,10 +54,10 @@ export const RemindMeSubmenu = () => { key={`reminder-offset-option--${offsetMs}`} onClick={async () => { try { - await client.reminders.upsertReminder({ - message_id: message.id, - remind_at: new Date(new Date().getTime() + offsetMs), - }); + await client.reminders.upsertReminder( + { message_id: message.id }, + { remind_at: new Date(new Date().getTime() + offsetMs) }, + ); addNotification({ context: { message, diff --git a/src/components/MessageComposer/__tests__/MessageInput.test.tsx b/src/components/MessageComposer/__tests__/MessageInput.test.tsx index 2780617a73..7bedfce449 100644 --- a/src/components/MessageComposer/__tests__/MessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageInput.test.tsx @@ -379,7 +379,6 @@ const setup = async ({ channelData }: { channelData?: GenerateChannelOptions } = }), ); customChannel.initialized = true; - customClient.activeChannels[customChannel.cid] = customChannel; return { customChannel, customClient, uploadFileSpy, uploadImageSpy }; }; @@ -397,7 +396,6 @@ const setupUploadRejected = async (error: unknown) => { const uploadFileSpy = vi .spyOn(customChannel, 'uploadFile') .mockRejectedValueOnce(error); - customClient.activeChannels[customChannel.cid] = customChannel; return { customChannel, customClient, uploadFileSpy, uploadImageSpy }; }; diff --git a/src/components/MessageComposer/__tests__/ThreadMessageInput.test.tsx b/src/components/MessageComposer/__tests__/ThreadMessageInput.test.tsx index cab022748f..04c9ee5323 100644 --- a/src/components/MessageComposer/__tests__/ThreadMessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/ThreadMessageInput.test.tsx @@ -103,7 +103,6 @@ const setup = async ({ channelData }: any = {}) => { .mockResolvedValue(fromPartial({ draft: { message: { id: 'x', text: '' } } })); vi.spyOn(customChannel, 'deleteDraft').mockResolvedValue(fromPartial({})); customChannel.initialized = true; - customClient.activeChannels[customChannel.cid] = customChannel; return { customChannel, customClient, getDraftSpy, uploadFileSpy, uploadImageSpy }; }; diff --git a/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx b/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx index ef894b95c4..a691849e8b 100644 --- a/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx +++ b/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx @@ -65,7 +65,10 @@ describe('useMessageComposerController', () => { client = await getTestClientWithUser({ id: 'test-user' }); const mockedChannelData = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); - channel = client.channel('messaging', mockedChannelData.channel.id); + channel = client.channelManager.ensure({ + id: mockedChannelData.channel.id, + type: 'messaging', + }); await channel.watch(); }); diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 009ca7ee7b..e80de0e448 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -224,7 +224,10 @@ describe('MessageList', () => { beforeEach(async () => { chatClient = await getTestClientWithUser({ id: 'vishal' }); useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannelData)]); - channel = chatClient.channel('messaging', mockedChannelData['id']); + channel = chatClient.channelManager.ensure({ + id: mockedChannelData['id'], + type: 'messaging', + }); await channel.watch(); markReadMock = vi @@ -1973,7 +1976,10 @@ describe('MessageList notification area', () => { it('stays on screen when the channel being opened is still bootstrapping', async () => { const { channelA, client } = await setup(); // Never watched: `Channel` renders it without querying, so the switch commits immediately. - const bootstrapping = client.channel('messaging', 'never-watched'); + const bootstrapping = client.channelManager.ensure({ + id: 'never-watched', + type: 'messaging', + }); const { rerender } = render(renderChannel(client, channelA)); raise(client); diff --git a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx index 0b39020b58..c24dff05a6 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx @@ -86,7 +86,10 @@ async function createChannel(empty = false) { }); const client = await getTestClientWithUser({ id: 'id' }); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); // eslint-disable-line react-hooks/rules-of-hooks - const channel = client.channel('messaging', mockedChannel.channel.id); + const channel = client.channelManager.ensure({ + id: mockedChannel.channel.id, + type: 'messaging', + }); await channel.watch(); return { channel, client }; diff --git a/src/components/MessageList/__tests__/messageSourceKey.test.ts b/src/components/MessageList/__tests__/messageSourceKey.test.ts index 613106379f..fc295d0d57 100644 --- a/src/components/MessageList/__tests__/messageSourceKey.test.ts +++ b/src/components/MessageList/__tests__/messageSourceKey.test.ts @@ -18,8 +18,8 @@ describe('getMessageSourceKey', () => { channelsData: [{ channel: { id: 'channel-a', type: 'messaging' } }], }); // A replacement instance for the same cid is a different object, and must key differently. - delete client.activeChannels[first.cid]; - const second = client.channel('messaging', 'channel-a'); + client.channelManager.removeChannel(first.cid); + const second = client.channelManager.ensure({ id: 'channel-a', type: 'messaging' }); expect(getMessageSourceKey({ channel: first })).toBe( getMessageSourceKey({ channel: first }), diff --git a/src/components/Modal/styling/Modal.scss b/src/components/Modal/styling/Modal.scss index f34f0c0998..c7db2ac719 100644 --- a/src/components/Modal/styling/Modal.scss +++ b/src/components/Modal/styling/Modal.scss @@ -45,6 +45,25 @@ pointer-events: auto; background-color: var(--str-chat__modal-overlay-color); backdrop-filter: var(--str-chat__modal-overlay-backdrop-filter); + // Scrolling over the modal stays in it rather than reaching the page behind. `overscroll-behavior` + // acts only on an element that scrolls, so the overlay scrolls by one pixel (`::before`), with no + // scrollbar shown. + overflow-y: auto; + overscroll-behavior: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + &::before { + content: ''; + position: absolute; + top: 0; + width: 1px; + height: calc(100% + 1px); + pointer-events: none; + } .str-chat__modal__floating-dialog-overlay { z-index: 1; diff --git a/src/components/Poll/PollActions/SuggestPollOptionPrompt.tsx b/src/components/Poll/PollActions/SuggestPollOptionPrompt.tsx index 3a6cc1453c..8581512d91 100644 --- a/src/components/Poll/PollActions/SuggestPollOptionPrompt.tsx +++ b/src/components/Poll/PollActions/SuggestPollOptionPrompt.tsx @@ -56,10 +56,7 @@ export const SuggestPollOptionPrompt = () => { const onSubmit = useCallback( async (formValue: { optionText: string }) => { - await client.createPollOption({ - poll_id: poll.id, - text: formValue.optionText, - }); + await client.createPollOption({ poll_id: poll.id }, { text: formValue.optionText }); close(); }, [client, poll, close], diff --git a/src/components/Poll/__tests__/SuggestPollOptionForm.test.tsx b/src/components/Poll/__tests__/SuggestPollOptionForm.test.tsx index 40fbd6b335..ebee06966e 100644 --- a/src/components/Poll/__tests__/SuggestPollOptionForm.test.tsx +++ b/src/components/Poll/__tests__/SuggestPollOptionForm.test.tsx @@ -64,10 +64,10 @@ describe('SuggestPollOptionPrompt', () => { await waitFor(() => { // v10 takes a single request object: `createPollOption({ poll_id, text })`. - expect(createPollOptionSpy).toHaveBeenCalledWith({ - poll_id: poll.id, - text: newlyTypedValue, - }); + expect(createPollOptionSpy).toHaveBeenCalledWith( + { poll_id: poll.id }, + { text: newlyTypedValue }, + ); }); }); }); diff --git a/src/components/Search/SearchResults/SearchResultItem.tsx b/src/components/Search/SearchResults/SearchResultItem.tsx index 985e40f780..8fdf99d243 100644 --- a/src/components/Search/SearchResults/SearchResultItem.tsx +++ b/src/components/Search/SearchResults/SearchResultItem.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo } from 'react'; import type { ComponentType } from 'react'; -import { convertTimestampToDate, formatMessage } from 'stream-chat'; +import { ChannelWatchStatus, convertTimestampToDate, formatMessage } from 'stream-chat'; import type { Channel, ChannelResponse, @@ -21,6 +21,7 @@ import { } from '../../../context'; import { Timestamp } from '../../../components/Message/Timestamp'; import { useStateStore } from '../../../store'; +import { getChannel } from '../../../utils/getChannel'; type SearchResultMessage = MessageResponse & { channel?: ChannelResponse }; @@ -40,7 +41,7 @@ export const ChannelSearchResultItem = ({ onSelect, }: ChannelSearchResultItemProps) => { const { openChannel } = useWorkspaceNavigation(); - const { channelManager } = useChatContext(); + const { channelManager, client } = useChatContext(); const handleSelect = useCallback( (event: React.MouseEvent) => { @@ -51,11 +52,16 @@ export const ChannelSearchResultItem = ({ // Default: open the channel in the workspace, forwarding the event so a consumer overriding // `openChannel` (e.g. via ChatView's `deriveWorkspaceNavigation`) can honor ⌘/ctrl-click. openChannel(item, { event }); + // Channel search doesn't watch its results, and `Channel` doesn't watch either, so the opened + // channel is watched here to receive its events. + if (item.watchStatus !== ChannelWatchStatus.Watching) { + void getChannel({ channel: item, client }).catch(() => undefined); + } // Route the channel into the list(s) that should own it (the channel manager dedupes by cid, // inserts in sort order, and honors ownership/filters) so it appears without a re-query. channelManager.ingestChannel(item); }, - [item, openChannel, channelManager, onSelect], + [item, openChannel, channelManager, client, onSelect], ); return ( @@ -85,7 +91,7 @@ export const MessageSearchResultItem = ({ const { channel: channelData } = item; const type = channelData?.type ?? 'unknown'; const id = channelData?.id ?? 'unknown'; - return client.channel(type, id); + return client.channelManager.ensure({ id, type }); }, [client, item]); const channelOpenInSlot = isChannelActive(channel?.cid ?? undefined); @@ -145,8 +151,11 @@ export const UserSearchResultItem = ({ item, onSelect }: UserSearchResultItemPro onSelect(event); return; } - const newChannel = client.channel(directMessagingChannelType, { - members: [{ user_id: client.userId as string }, { user_id: item.id }], + const newChannel = client.channelManager.ensure({ + data: { + members: [{ user_id: client.userId as string }, { user_id: item.id }], + }, + type: directMessagingChannelType, }); newChannel.watch(); // Default: open the DM channel in the workspace, forwarding the event so a consumer overriding diff --git a/src/components/Search/__tests__/SearchResultItem.test.tsx b/src/components/Search/__tests__/SearchResultItem.test.tsx index 6feefc1a2b..7ac556e0d1 100644 --- a/src/components/Search/__tests__/SearchResultItem.test.tsx +++ b/src/components/Search/__tests__/SearchResultItem.test.tsx @@ -1,6 +1,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { fromPartial } from '@total-typescript/shoehorn'; +import { ChannelWatchStatus } from 'stream-chat'; import { ChannelSearchResultItem, @@ -131,6 +132,38 @@ describe('SearchResultItem Components', () => { expect(mockIngestChannel).toHaveBeenCalledTimes(1); }); + it('watches the opened channel when the search did not', async () => { + const channelSearchData = generateChannel(); + const { client } = await renderComponent({ + channelSearchData, + SearchResultItemComponent, + }); + const channel = client.channelManager.get(channelSearchData.channel.cid); + if (!channel) throw new Error('the result channel is not stored'); + channel.watchStatus = ChannelWatchStatus.NotWatching; + const watch = vi.spyOn(channel, 'watch').mockResolvedValue(undefined as never); + + fireEvent.click(screen.getByTestId(CHANNEL_PREVIEW_BUTTON_TEST_ID)); + + expect(watch).toHaveBeenCalledTimes(1); + }); + + it('does not watch an opened channel that is already watched', async () => { + const channelSearchData = generateChannel(); + const { client } = await renderComponent({ + channelSearchData, + SearchResultItemComponent, + }); + const channel = client.channelManager.get(channelSearchData.channel.cid); + if (!channel) throw new Error('the result channel is not stored'); + channel.watchStatus = ChannelWatchStatus.Watching; + const watch = vi.spyOn(channel, 'watch'); + + fireEvent.click(screen.getByTestId(CHANNEL_PREVIEW_BUTTON_TEST_ID)); + + expect(watch).not.toHaveBeenCalled(); + }); + it('runs a custom onSelect instead of the default open', async () => { const channelSearchData = generateChannel(); const onSelect = vi.fn(); @@ -174,7 +207,10 @@ describe('SearchResultItem Components', () => { }); const { id, type } = messageResponseData.channel; const jumpToMessage = vi - .spyOn(client.channel(type, id).messagePaginator, 'jumpToMessage') + .spyOn( + client.channelManager.ensure({ id, type }).messagePaginator, + 'jumpToMessage', + ) .mockResolvedValue(true); await act(() => { diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx index 88bbef56d4..6d7d1cb493 100644 --- a/src/components/Thread/Thread.tsx +++ b/src/components/Thread/Thread.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useEffect } from 'react'; import type { PropsWithChildren } from 'react'; import { WithAudioPlayback } from '../AudioPlayback'; @@ -7,12 +7,7 @@ import { useChatContext } from '../../context'; import { ThreadProvider } from '../Threads'; import { useStateStore } from '../../store'; -import type { - LocalMessage, - Thread as StreamThread, - ThreadManagerState, - ThreadState, -} from 'stream-chat'; +import type { Thread as StreamThread, ThreadState } from 'stream-chat'; import type { ChannelConfig } from 'stream-chat'; const repliesStateSelector = ({ replies }: ChannelConfig) => ({ @@ -21,8 +16,10 @@ const repliesStateSelector = ({ replies }: ChannelConfig) => ({ export type ThreadProps = PropsWithChildren<{ /** - * The thread to render. Initialize it before passing it in; `Thread` does not query for it -- - * it loads the replies of a thread the `ThreadManager` does not already hold. + * The thread to render. Get it from `client.threads.ensure()` (or `client.threads.get()`) rather + * than constructing it, so it is registered with the `ThreadManager` and receives events. `Thread` + * loads it while its state is stale, which is how a thread built by `ensure()` for a parent with + * replies starts. */ thread: StreamThread; }>; @@ -35,24 +32,11 @@ const selector = ({ isStateStale, parentMessage, replyCount }: ThreadState) => ( parentMessage, }); -// Same reasoning: the effects below only ask whether the replies have loaded, never what they are. -const messagePaginatorSelector = ({ - isLoading, - items, - lastQueryError, -}: { - isLoading: boolean; - items: LocalMessage[] | undefined; - lastQueryError?: Error; -}) => ({ - hasLoadedReplies: items !== undefined, - isLoading, - lastQueryError, -}); - /** - * The container for a thread panel: it provides the thread to its subtree, loads it, registers it - * with the `ThreadManager`, scopes audio playback to it, and renders whatever you compose inside. + * The container for a thread panel: it provides the thread to its subtree, loads it while its state + * is stale, scopes audio playback to it, and renders whatever you compose inside. It does not + * register the thread with the `ThreadManager`; `client.threads.ensure()` or `thread.activate()` + * does. * * It renders no UI of its own, the way `Channel` does not -- put the parts you want in as * children, and their own props say how they behave: @@ -74,7 +58,7 @@ const messagePaginatorSelector = ({ * the replies it shows. */ export const Thread = ({ children, thread }: ThreadProps) => { - const { client, customClasses } = useChatContext(); + const { customClasses } = useChatContext(); const { repliesEnabled } = useStateStore( thread.channel.configState, repliesStateSelector, @@ -89,66 +73,18 @@ export const Thread = ({ children, thread }: ThreadProps) => { thread.state, selector, ); - const { hasLoadedReplies, isLoading, lastQueryError } = useStateStore( - thread.messagePaginator.state, - messagePaginatorSelector, - ); - - const isThreadManagedSelector = useCallback( - ({ threads }: ThreadManagerState) => ({ - isThreadManaged: threads.some((managedThread) => managedThread.id === thread.id), - }), - [thread.id], - ); - const { isThreadManaged } = useStateStore( - client.threads.state, - isThreadManagedSelector, - ); - // Only an unmanaged thread is loaded here. The `ThreadManager` already loads and refreshes the - // ones it holds; an instance from `getThreadAndHydrate()` is registered nowhere, so it has no - // other owner. - useEffect(() => { - if (isThreadManaged) return; - if (!hasServerSideThread) return; - if (hasLoadedReplies || isLoading) return; - void thread.reload(); - }, [hasLoadedReplies, hasServerSideThread, isLoading, isThreadManaged, thread]); - - // Deliberately a separate effect rather than a branch of the one above: catching up a stale - // thread depends on `isStateStale` alone, so it fires once per staleness episode. Merged in, it - // would also re-run whenever the load branch's inputs change -- registering the thread flips - // `isThreadManaged`, which would request a second reload while the first is still in flight. + // The only load trigger. A thread `client.threads.ensure()` builds for a parent with replies starts + // stale, so it loads here once when first opened; one for a parent without replies has nothing on + // the server yet and starts up to date. Listed threads and `getThreadAndHydrate()` instances arrive + // with their replies and are not stale. It fires once per staleness episode, and `thread.reload()` ignores a + // call while one is in flight. useEffect(() => { if (isStateStale && hasServerSideThread) { void thread.reload(); } }, [hasServerSideThread, isStateStale, thread]); - useEffect(() => { - if (isThreadManaged) return; - if (isLoading) return; - if (lastQueryError) return; - if (!hasLoadedReplies) return; - - client.threads.state.next((current) => { - if (current.threads.some((managedThread) => managedThread.id === thread.id)) { - return current; - } - return { - ...current, - threads: [thread, ...current.threads], - }; - }); - }, [ - client.threads.state, - hasLoadedReplies, - isLoading, - isThreadManaged, - lastQueryError, - thread, - ]); - if (!parentMessage || repliesEnabled === false) return null; // The thread owns its audio-player pool (rather than inheriting one from an ambient ) diff --git a/src/components/Thread/__tests__/Thread.test.tsx b/src/components/Thread/__tests__/Thread.test.tsx index 9fbbaa6880..ee5ace16f7 100644 --- a/src/components/Thread/__tests__/Thread.test.tsx +++ b/src/components/Thread/__tests__/Thread.test.tsx @@ -5,6 +5,7 @@ import { StateStore } from '@stream-io/state-store'; import type { ChannelConfig, LocalMessage, + Channel as StreamChannel, StreamChat, Thread as StreamThread, ThreadState, @@ -26,12 +27,13 @@ import type { ComponentContextValue } from '../../../context'; // MERGE-RECONCILE (test migration): PR #2909 / v14 rewrote Thread to read from a Thread instance // (not the deleted ChannelStateContext/ChannelActionContext). The parent message, reply pagination -// and loading live on `thread.state` / `thread.messagePaginator`, and the thread-manager list on -// `client.threads.state`. Obsolete assertions that referenced the removed MessageList props +// and loading live on `thread.state` / `thread.messagePaginator`, and opened threads resolve through +// `client.threads.get()`. Obsolete assertions that referenced the removed MessageList props // (`hasMore`/`loadMore`/`messages`/`threadList`) and the ChannelActionContext // `loadMoreThread`/`closeThread` handlers are updated to the current contract. let chatClient: StreamChat; +let channel: StreamChannel; const alice = generateUser({ id: 'alice', name: 'alice' }); const bob = generateUser({ id: 'bob', name: 'bob' }); const parentMessage = generateMessage({ reply_count: 2, user: alice }); @@ -102,7 +104,10 @@ const renderComponent = ({ describe('Thread', () => { beforeAll(async () => { - ({ client: chatClient } = await initClientWithChannels()); + ({ + channels: [channel], + client: chatClient, + } = await initClientWithChannels()); }); afterEach(() => { @@ -140,22 +145,87 @@ describe('Thread', () => { expect(getByTestId('probe')).toHaveAttribute('data-thread-id', thread.id); }); - it('should reload the thread on mount when replies have not been fetched yet', () => { - // Use a unique parent id so the thread is not already tracked in the shared - // client.threads manager state (which would short-circuit the reload effect). - const { reload, thread } = makeThread({ - items: undefined, - parentMessage: generateMessage({ - id: 'reload-parent', + /** A thread opened the way the SDK opens one, with its fetch stubbed to succeed. */ + const ensureThread = (parent: LocalMessage) => { + const thread = chatClient.threads.ensure({ channel, parentMessage: parent }); + const reload = vi.spyOn(thread, 'reload').mockImplementation(() => { + thread.state.partialNext({ isStateStale: false }); + return Promise.resolve(); + }); + return { reload, thread }; + }; + + it('should load a thread built by `client.threads.ensure()` exactly once when opened', () => { + const { reload, thread } = ensureThread( + generateMessage({ + cid: channel.cid, + id: 'ensure-parent', reply_count: 2, user: alice, }), + ); + const { rerender } = renderComponent({ threadInstance: thread }); + rerender( + + + +
+ + + , + ); + + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('should not fetch when reopening a thread that is still registered', () => { + const { reload, thread } = ensureThread( + generateMessage({ + cid: channel.cid, + id: 'reopen-parent', + reply_count: 2, + user: alice, + }), + ); + const { unmount } = renderComponent({ threadInstance: thread }); + unmount(); + + const reopened = chatClient.threads.ensure({ + channel, + parentMessage: thread.state.getLatestValue().parentMessage, }); - renderComponent({ threadInstance: thread }); + renderComponent({ threadInstance: reopened }); + expect(reopened).toBe(thread); expect(reload).toHaveBeenCalledTimes(1); }); + it('should leave the opened thread out of the thread list', () => { + // An opened thread stays live through the manager's store; `Thread` does not add it to the list. + const { thread } = ensureThread( + generateMessage({ + cid: channel.cid, + id: 'unlisted-parent', + reply_count: 2, + user: alice, + }), + ); + renderComponent({ threadInstance: thread }); + + expect(chatClient.threads.get(thread.id)).toBe(thread); + expect(chatClient.threads.paginator.getItem(thread.id)).toBeUndefined(); + }); + + it('should not reload a non-stale thread', () => { + // Listed threads and `getThreadAndHydrate()` instances arrive loaded, so opening one is free. + const { reload, thread } = makeThread({ + parentMessage: generateMessage({ id: 'fresh-parent', reply_count: 2, user: alice }), + }); + renderComponent({ threadInstance: thread }); + + expect(reload).not.toHaveBeenCalled(); + }); + it('should not reload a thread whose parent message has no replies yet', () => { // The thread does not exist server-side until its first reply, so `GET /threads/:id` can only // 404 here — opening a reply-less message to write the first reply must not query. @@ -172,25 +242,26 @@ describe('Thread', () => { expect(reload).not.toHaveBeenCalled(); }); - it('should reload once the parent message reports its first reply', () => { - // The skip is self-healing: `replyCount` follows the parent message, so the thread loads as - // soon as it exists server-side — without remounting the component. - const { reload, thread } = makeThread({ - items: undefined, - parentMessage: generateMessage({ + it('should not load a thread built by `ensure()` for a parent without replies', () => { + // There is no server-side thread to load yet (`getThread` would answer 404), so `ensure()` builds + // it up to date. It is registered, so its first reply reaches it as an event, not through a load. + const { reload, thread } = ensureThread( + generateMessage({ + cid: channel.cid, id: 'first-reply-parent', reply_count: 0, user: alice, }), - }); + ); renderComponent({ threadInstance: thread }); + expect(thread.state.getLatestValue().isStateStale).toBe(false); expect(reload).not.toHaveBeenCalled(); act(() => { thread.state.partialNext({ replyCount: 1 }); }); - expect(reload).toHaveBeenCalledTimes(1); + expect(reload).not.toHaveBeenCalled(); }); it('should defer a stale reload until the thread reports a reply', () => { @@ -200,8 +271,7 @@ describe('Thread', () => { // parent message reports a reply. const { reload, thread } = makeThread({ isStateStale: true, - // `[]`, not `undefined`: reopening runs on a disposed paginator, which is what makes the - // stale effect the only one that can still load this thread. + // `[]`: a reopened thread has replies loaded, so only its staleness can trigger the load. items: [], parentMessage: generateMessage({ id: 'stale-never-created-parent', diff --git a/src/components/Threads/ThreadList/ThreadList.tsx b/src/components/Threads/ThreadList/ThreadList.tsx index dabf3c5b03..ef11d7090a 100644 --- a/src/components/Threads/ThreadList/ThreadList.tsx +++ b/src/components/Threads/ThreadList/ThreadList.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useRef } from 'react'; import type { ComputeItemKey, VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso'; -import type { Thread, ThreadManagerState } from 'stream-chat'; +import type { PaginatorState, Thread } from 'stream-chat'; import { useVirtualizedListboxKeyboardNavigation } from '../../../a11y/hooks/useVirtualizedListboxKeyboardNavigation'; import { ThreadListItem as DefaultThreadListItem } from './ThreadListItem'; @@ -19,9 +19,11 @@ import { import { useStateStore } from '../../../store'; import { ThreadListHeader } from './ThreadListHeader'; -const selector = (nextValue: ThreadManagerState) => ({ - isLoading: nextValue.pagination.isLoading, - threads: nextValue.threads, +const noThreads: Thread[] = []; + +const selector = (nextValue: PaginatorState) => ({ + isLoading: nextValue.isLoading, + threads: nextValue.items ?? noThreads, }); const computeItemKey: ComputeItemKey = (_, item) => item.id; @@ -34,19 +36,7 @@ export const useThreadList = () => { const { client } = useChatContext(); useEffect(() => { - // Reset derived pagination inputs before initial reload so the first mount requests - // the default first page size, rather than a limit inferred from cached/unseen threads. - const { pagination } = client.threads.state.getLatestValue(); - client.threads.state.partialNext({ - isThreadOrderStale: false, - pagination: { - ...pagination, - nextCursor: null, - }, - ready: false, - threads: [], - unseenThreadIds: [], - }); + // Fresh list on every mount; the reload sizes its own page from the loaded and unseen threads. void client.threads.reload({ force: true }); const handleVisibilityChange = () => { @@ -78,7 +68,7 @@ export const ThreadList = ({ virtuosoProps }: ThreadListProps) => { ThreadListLoadingIndicator = DefaultThreadListLoadingIndicator, ThreadListUnseenThreadsBanner = DefaultThreadListUnseenThreadsBanner, } = useComponentContext(); - const { isLoading, threads } = useStateStore(client.threads.state, selector); + const { isLoading, threads } = useStateStore(client.threads.paginator.state, selector); const resetByThreadId = useThreadHighlighting(client.threads); @@ -103,6 +93,7 @@ export const ThreadList = ({ virtuosoProps }: ThreadListProps) => { useThreadList(); + // Only the first load shows the placeholder; a reload keeps the loaded threads until the new ones land. if (isLoading && !threads.length) { return (
@@ -121,7 +112,7 @@ export const ThreadList = ({ virtuosoProps }: ThreadListProps) => { atBottom && client.threads.loadNextPage()} + atBottomStateChange={(atBottom) => atBottom && client.threads.paginator.toTail()} className='str-chat__thread-list' components={{ EmptyPlaceholder: ThreadListEmptyPlaceholder, diff --git a/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx b/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx index 9896550c21..f2e198b36b 100644 --- a/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx +++ b/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx @@ -1,19 +1,20 @@ import React from 'react'; -import type { ThreadManagerState } from 'stream-chat'; +import type { PaginatorState, Thread } from 'stream-chat'; import { LoadingIndicator as DefaultLoadingIndicator } from '../../Loading'; import { useChatContext, useComponentContext } from '../../../context'; import { useStateStore } from '../../../store'; -const selector = (nextValue: ThreadManagerState) => ({ - isLoadingNext: nextValue.pagination.isLoadingNext, +// `isLoading` with threads already loaded means the next page is loading. +const selector = (nextValue: PaginatorState) => ({ + isLoadingNext: nextValue.isLoading && !!nextValue.items?.length, }); export const ThreadListLoadingIndicator = () => { const { LoadingIndicator = DefaultLoadingIndicator } = useComponentContext(); const { client } = useChatContext(); - const { isLoadingNext } = useStateStore(client.threads.state, selector); + const { isLoadingNext } = useStateStore(client.threads.paginator.state, selector); if (!isLoadingNext) return null; diff --git a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx index f03c5e3673..11609ff10e 100644 --- a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx +++ b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; import type { ThreadManagerState } from 'stream-chat'; @@ -12,7 +12,6 @@ import { useStateStore } from '../../../store'; import { LoadingIndicator } from '../../Loading'; const selector = (nextValue: ThreadManagerState) => ({ - isLoading: nextValue.pagination.isLoading, unseenThreadIds: nextValue.unseenThreadIds, }); @@ -20,7 +19,26 @@ export const ThreadListUnseenThreadsBanner = () => { const { IconRefresh } = useComponentContextIcons(); const { client } = useChatContext(); const { t } = useTranslationContext(); - const { isLoading, unseenThreadIds } = useStateStore(client.threads.state, selector); + const { unseenThreadIds } = useStateStore(client.threads.state, selector); + // A reload of a loaded list publishes no loading state, so the pending reload is tracked here. + const [isLoading, setIsLoading] = useState(false); + const isMounted = useRef(true); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + const reload = useCallback(async () => { + setIsLoading(true); + try { + await client.threads.reload(); + } finally { + if (isMounted.current) setIsLoading(false); + } + }, [client]); if (!unseenThreadIds.length) return null; @@ -30,7 +48,7 @@ export const ThreadListUnseenThreadsBanner = () => { 'str-chat__unseen-threads-banner--loading': isLoading, })} disabled={isLoading} - onClick={() => client.threads.reload()} + onClick={reload} > {!isLoading && ( <> diff --git a/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx index 3c5ebe442f..0541a61ea6 100644 --- a/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx +++ b/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import type { StreamChat } from 'stream-chat'; +import type { PaginatorState, StreamChat, Thread } from 'stream-chat'; import { ThreadList } from '../ThreadList'; import { initClientWithChannels } from '../../../../mock-builders'; @@ -84,11 +84,9 @@ vi.mock('../../../Notifications', () => ({ })); describe('ThreadList', () => { - // MERGE-RECONCILE (test migration): the ThreadList effects now call real ThreadManager APIs - // (`client.threads.state.getLatestValue()`, `partialNext`, `reload`). Use a real StreamChat - // client (via initClientWithChannels) so `client.threads.state` is a genuine StateStore rather - // than hand-mocking `client.threads`. `useStateStore` stays mocked to drive isLoading/threads, - // and `client.threads.reload` is stubbed to avoid a network call in the mount effect. + // A real StreamChat client (via initClientWithChannels), so `client.threads` and its paginator + // are genuine. `useStateStore` stays mocked to drive isLoading/threads, and + // `client.threads.reload` is stubbed to avoid a network call in the mount effect. let client: StreamChat; beforeEach(async () => { @@ -135,6 +133,60 @@ describe('ThreadList', () => { }); }); + it('reads the threads and the loading state from the thread paginator', () => { + render(); + + const [store, selector] = mockUseStateStore.mock.calls[0]; + expect(store).toBe(client.threads.paginator.state); + expect( + selector({ isLoading: true, items: undefined } as PaginatorState), + ).toEqual({ isLoading: true, threads: [] }); + const threads = [{ id: 'thread-1' }] as Thread[]; + expect( + selector({ isLoading: false, items: threads } as PaginatorState), + ).toEqual({ isLoading: false, threads }); + }); + + it('keeps the loaded threads on screen while the paginator is loading', () => { + // `isLoading` with threads loaded is the next page, not the first load. + mockUseStateStore.mockReturnValue({ isLoading: true, threads: [{ id: 'thread-1' }] }); + + render(); + + expect(screen.getByTestId('virtuoso')).toBeInTheDocument(); + expect(screen.queryByTestId('loading-channels')).not.toBeInTheDocument(); + }); + + it('reloads the list on mount without resetting the manager state', () => { + client.threads.state.partialNext({ unseenThreadIds: ['unseen-thread'] }); + + render(); + + expect(client.threads.reload).toHaveBeenCalledWith({ force: true }); + expect(client.threads.state.getLatestValue().unseenThreadIds).toEqual([ + 'unseen-thread', + ]); + }); + + it('loads the next page through the paginator when scrolled to the bottom', () => { + const toTail = vi + .spyOn(client.threads.paginator, 'toTail') + .mockResolvedValue(undefined); + mockUseStateStore.mockReturnValue({ + isLoading: false, + threads: [{ id: 'thread-1' }], + }); + + render(); + const { atBottomStateChange } = mockVirtuoso.mock.calls[0][0]; + + atBottomStateChange(false); + expect(toTail).not.toHaveBeenCalled(); + + atBottomStateChange(true); + expect(toTail).toHaveBeenCalledTimes(1); + }); + // 5 threads, but the mocked Virtuoso only renders the first MOCK_WINDOW_SIZE (3). const FIVE_THREADS = [ { id: 'thread-1' }, diff --git a/src/components/Threads/ThreadList/__tests__/ThreadListUnseenThreadsBanner.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadListUnseenThreadsBanner.test.tsx new file mode 100644 index 0000000000..470bc416fc --- /dev/null +++ b/src/components/Threads/ThreadList/__tests__/ThreadListUnseenThreadsBanner.test.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import type { Channel, StreamChat, Thread } from 'stream-chat'; + +import { ChatProvider, WithComponents } from '../../../../context'; +import { TranslationProvider } from '../../../../context/TranslationContext'; +import { + generateMessage, + initClientWithChannels, + mockChatContext, + mockTranslationContextValue, +} from '../../../../mock-builders'; +import { mockT } from '../../../../mock-builders/translator'; +import { ThreadListUnseenThreadsBanner } from '../ThreadListUnseenThreadsBanner'; +import { ThreadListLoadingIndicator } from '../ThreadListLoadingIndicator'; + +const LoadingIndicator = () =>
; + +let channel: Channel; +let client: StreamChat; + +const listedThread = (): Thread => + client.threads.ensure({ + channel, + parentMessage: generateMessage({ cid: channel.cid, reply_count: 1 }), + }); + +const renderWithClient = (ui: React.ReactElement) => + render( + + + {ui} + + , + ); + +const deferred = () => { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +}; + +beforeEach(async () => { + ({ + channels: [channel], + client, + } = await initClientWithChannels()); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('ThreadListUnseenThreadsBanner', () => { + it('renders nothing without unseen threads', () => { + const { container } = renderWithClient(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('shows the loading state while its reload is pending', async () => { + const pending = deferred(); + const reload = vi.spyOn(client.threads, 'reload').mockReturnValue(pending.promise); + client.threads.state.partialNext({ unseenThreadIds: ['unseen-thread'] }); + renderWithClient(); + + const banner = screen.getByRole('button'); + expect(banner).not.toBeDisabled(); + + fireEvent.click(banner); + + expect(reload).toHaveBeenCalledTimes(1); + expect(banner).toBeDisabled(); + expect(banner).toHaveClass('str-chat__unseen-threads-banner--loading'); + expect(banner).toHaveTextContent('Loading...'); + + await act(async () => { + // A successful reload clears `unseenThreadIds`. + client.threads.state.partialNext({ unseenThreadIds: [] }); + pending.resolve(); + await pending.promise; + }); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('keeps the banner after a failed reload', async () => { + // `reload()` logs a failure and leaves `unseenThreadIds` in place. + const pending = deferred(); + vi.spyOn(client.threads, 'reload').mockReturnValue(pending.promise); + client.threads.state.partialNext({ unseenThreadIds: ['unseen-thread'] }); + renderWithClient(); + + fireEvent.click(screen.getByRole('button')); + + await act(async () => { + pending.resolve(); + await pending.promise; + }); + + const banner = screen.getByRole('button'); + expect(banner).not.toBeDisabled(); + expect(banner).not.toHaveClass('str-chat__unseen-threads-banner--loading'); + expect(banner).not.toHaveTextContent('Loading...'); + }); +}); + +describe('ThreadListLoadingIndicator', () => { + it('does not show during the first load', () => { + client.threads.paginator.state.partialNext({ isLoading: true, items: undefined }); + + const { container } = renderWithClient(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('shows while the next page loads', () => { + client.threads.paginator.state.partialNext({ + isLoading: true, + items: [listedThread()], + }); + + renderWithClient(); + + expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); + }); + + it('hides once the page has loaded', () => { + client.threads.paginator.state.partialNext({ + isLoading: true, + items: [listedThread()], + }); + renderWithClient(); + + act(() => client.threads.paginator.state.partialNext({ isLoading: false })); + + expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Threads/ThreadList/__tests__/useThreadHighlighting.test.ts b/src/components/Threads/ThreadList/__tests__/useThreadHighlighting.test.ts index 0417e9aae4..cc12525de6 100644 --- a/src/components/Threads/ThreadList/__tests__/useThreadHighlighting.test.ts +++ b/src/components/Threads/ThreadList/__tests__/useThreadHighlighting.test.ts @@ -4,90 +4,95 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { useThreadHighlighting } from '../useThreadHighlighting'; -import type { Thread, ThreadManager, ThreadManagerState } from 'stream-chat'; +import type { + PaginatorState, + Thread, + ThreadManager, + ThreadManagerState, +} from 'stream-chat'; /** - * The flash means "a thread arrived while you were looking at the list". - * - * It used to be derived by diffing the threads array by reference, which called anything that - * merely appeared for the first time an arrival -- so the whole first page lit up on load, and - * each page did while scrolling. + * The flash means "a thread arrived while you were looking at the list". Threads that merely + * appear for the first time (the first page on load, each page while scrolling) are not arrivals. */ const thread = (id: string) => fromPartial({ id }); -const setup = (initial: Partial = {}) => { - const state = new StateStore( - fromPartial({ threads: [], unseenThreadIds: [], ...initial }), - ); - const threadManager = fromPartial({ state }); +const setup = ({ items }: { items?: Thread[] } = {}) => { + const state = new StateStore(fromPartial({ unseenThreadIds: [] })); + const listState = new StateStore>(fromPartial({ items })); + const threadManager = fromPartial({ + paginator: { state: listState }, + state, + }); const { result } = renderHook(() => useThreadHighlighting(threadManager)); - return { result, state }; + return { listState, result, state }; }; describe('useThreadHighlighting', () => { it('does not flash the first page arriving on a cold load', () => { - const { result, state } = setup(); + const { listState, result } = setup(); - act(() => state.partialNext({ threads: [thread('a'), thread('b'), thread('c')] })); + act(() => listState.partialNext({ items: [thread('a'), thread('b'), thread('c')] })); expect(Object.keys(result.current)).toEqual([]); }); it('does not flash a page loaded by pagination', () => { - const { result, state } = setup({ threads: [thread('a')] }); + const { listState, result } = setup({ items: [thread('a')] }); - act(() => state.partialNext({ threads: [thread('a'), thread('b'), thread('c')] })); + act(() => listState.partialNext({ items: [thread('a'), thread('b'), thread('c')] })); expect(Object.keys(result.current)).toEqual([]); }); it('flashes a thread the manager reported unseen, once it lands in the list', () => { - const { result, state } = setup({ threads: [thread('a')] }); + const { listState, result, state } = setup({ items: [thread('a')] }); // A message arrives for a thread the list does not hold. act(() => state.partialNext({ unseenThreadIds: ['new-one'] })); expect(Object.keys(result.current)).toEqual([]); - // `reload()` brings it in and clears `unseenThreadIds` in the same update -- which is why the - // id has to have been remembered when it was reported. - act(() => - state.partialNext({ - threads: [thread('new-one'), thread('a')], - unseenThreadIds: [], - }), - ); + // `reload()` brings it into the list, then clears `unseenThreadIds` -- which is why the id has + // to have been remembered when it was reported. + act(() => listState.partialNext({ items: [thread('new-one'), thread('a')] })); + act(() => state.partialNext({ unseenThreadIds: [] })); + + expect(Object.keys(result.current)).toEqual(['new-one']); + }); + + it('flashes the arrival when `unseenThreadIds` is cleared before the list update lands', () => { + const { listState, result, state } = setup({ items: [thread('a')] }); + + act(() => state.partialNext({ unseenThreadIds: ['new-one'] })); + act(() => state.partialNext({ unseenThreadIds: [] })); + act(() => listState.partialNext({ items: [thread('new-one'), thread('a')] })); expect(Object.keys(result.current)).toEqual(['new-one']); }); it('keeps an earlier flash alive when a second thread arrives', () => { - const { result, state } = setup({ threads: [thread('a')] }); + const { listState, result, state } = setup({ items: [thread('a')] }); act(() => state.partialNext({ unseenThreadIds: ['first'] })); - act(() => - state.partialNext({ threads: [thread('first'), thread('a')], unseenThreadIds: [] }), - ); + act(() => listState.partialNext({ items: [thread('first'), thread('a')] })); + act(() => state.partialNext({ unseenThreadIds: [] })); act(() => state.partialNext({ unseenThreadIds: ['second'] })); act(() => - state.partialNext({ - threads: [thread('second'), thread('first'), thread('a')], - unseenThreadIds: [], + listState.partialNext({ + items: [thread('second'), thread('first'), thread('a')], }), ); + act(() => state.partialNext({ unseenThreadIds: [] })); expect(Object.keys(result.current).sort()).toEqual(['first', 'second']); }); it('stops flashing a thread once its reset is called', () => { - const { result, state } = setup({ threads: [thread('a')] }); + const { listState, result, state } = setup({ items: [thread('a')] }); act(() => state.partialNext({ unseenThreadIds: ['new-one'] })); - act(() => - state.partialNext({ - threads: [thread('new-one'), thread('a')], - unseenThreadIds: [], - }), - ); + act(() => listState.partialNext({ items: [thread('new-one'), thread('a')] })); + act(() => state.partialNext({ unseenThreadIds: [] })); act(() => result.current['new-one']()); diff --git a/src/components/Threads/ThreadList/useThreadHighlighting.ts b/src/components/Threads/ThreadList/useThreadHighlighting.ts index 124f5d9f38..4f687f98cd 100644 --- a/src/components/Threads/ThreadList/useThreadHighlighting.ts +++ b/src/components/Threads/ThreadList/useThreadHighlighting.ts @@ -10,8 +10,9 @@ import type { ThreadManager } from 'stream-chat'; * each page while scrolling, neither of which is an arrival. * * The ids have to be remembered as they are reported, because `reload()` clears `unseenThreadIds` - * in the same update that puts those threads into the list: by the time they are on screen the - * manager no longer calls them unseen. + * once it has put those threads into the list: by the time they are on screen the manager no longer + * calls them unseen. The list (`paginator.state`) and `unseenThreadIds` (`state`) live in different + * stores, and remembering the ids makes the order their updates land in irrelevant. */ export const useThreadHighlighting = (threadManager: ThreadManager) => { const [threadsToHighlight, setThreadsToHighlight] = useState< @@ -27,8 +28,8 @@ export const useThreadHighlighting = (threadManager: ThreadManager) => { }, ); - const unsubscribeThreads = threadManager.state.subscribeWithSelector( - (state) => ({ threads: state.threads }), + const unsubscribeThreads = threadManager.paginator.state.subscribeWithSelector( + (state) => ({ threads: state.items ?? [] }), ({ threads }) => { if (!awaitingArrival.current.size) return; diff --git a/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx b/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx index d550c37416..9c695248ff 100644 --- a/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx +++ b/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx @@ -237,7 +237,7 @@ describe('TypingIndicator', () => { channel: { config: fromPartial({ typing_events: true }) }, }); useMockedApis(client, [getOrCreateChannelApi(ch)]); - channel = client.channel('messaging', ch.id); + channel = client.channelManager.ensure({ id: ch.id, type: 'messaging' }); await channel.watch(); }); diff --git a/src/i18n/__tests__/catalog.fixture.json b/src/i18n/__tests__/catalog.fixture.json index e59b721427..8546b3187e 100644 --- a/src/i18n/__tests__/catalog.fixture.json +++ b/src/i18n/__tests__/catalog.fixture.json @@ -534,6 +534,8 @@ "search.sourceResults.searching.text": "Searching for {{ searchSourceType }}...", "slotLayout.chatView.channels.text": "Channels", "slotLayout.chatView.chatViewControls.ariaLabel": "Chat view controls", + "slotLayout.chatView.empty.channels.text": "No chat selected", + "slotLayout.chatView.empty.threads.text": "No thread selected", "slotLayout.chatView.openChannelsView.ariaLabel": "Open channels view", "slotLayout.chatView.openThreadsView.ariaLabel": "Open threads view", "slotLayout.chatView.openThreadsViewUnread.ariaLabel_one": "Open threads view, {{ count }} unread thread", diff --git a/src/i18n/keys.ts b/src/i18n/keys.ts index 76cd2f34bc..fc70b118c5 100644 --- a/src/i18n/keys.ts +++ b/src/i18n/keys.ts @@ -545,6 +545,8 @@ export type TranslationCatalog = { 'search.sourceResults.searching.text': 'Searching for {{ searchSourceType }}...'; 'slotLayout.chatView.channels.text': 'Channels'; 'slotLayout.chatView.chatViewControls.ariaLabel': 'Chat view controls'; + 'slotLayout.chatView.empty.channels.text': 'No chat selected'; + 'slotLayout.chatView.empty.threads.text': 'No thread selected'; 'slotLayout.chatView.openChannelsView.ariaLabel': 'Open channels view'; 'slotLayout.chatView.openThreadsView.ariaLabel': 'Open threads view'; 'slotLayout.chatView.openThreadsViewUnread.ariaLabel_one': 'Open threads view, {{ count }} unread thread'; diff --git a/src/mock-builders/utils.ts b/src/mock-builders/utils.ts index 03b97113e2..93a22e672b 100644 --- a/src/mock-builders/utils.ts +++ b/src/mock-builders/utils.ts @@ -48,7 +48,10 @@ export async function createClientWithChannel({ const client = existingClient || (await getTestClientWithUser({ id: users[0].id })); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); // eslint-disable-line react-hooks/rules-of-hooks - const channel = client.channel('messaging', mockedChannel['id']); + const channel = client.channelManager.ensure({ + id: mockedChannel['id'], + type: 'messaging', + }); await channel.watch(); return { channel, client, users }; @@ -70,10 +73,10 @@ export const initChannelFromData = async ({ // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); - const channel = client.channel( - mockedChannelData.channel.type, - mockedChannelData.channel.id, - ); + const channel = client.channelManager.ensure({ + id: mockedChannelData.channel.id, + type: mockedChannelData.channel.type, + }); await channel.watch(); // Written into the client's store rather than stubbed onto the channel: `getConfig()` is gone, and // its replacement `serverConfig` is a getter reading this store. Going through the store also drives diff --git a/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx b/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx index c8f4e6962c..21deb451f7 100644 --- a/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx +++ b/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx'; import React, { useCallback, useState } from 'react'; +import type { ChannelStateData } from 'stream-chat'; import { useChannel, useComponentContext, useTranslationContext } from '../../context'; import { @@ -11,6 +12,7 @@ import { ChannelDetail as DefaultChannelDetail, } from './ChannelDetail'; import { GlobalModal } from '../../components/Modal'; +import { useStateStore } from '../../store'; export type AvatarWithChannelDetailProps = ChannelAvatarProps & { Avatar?: React.ComponentType; @@ -21,6 +23,15 @@ const avatarWithChannelDetailDialogRootProps = { className: 'str-chat__channel-detail-modal', }; +/** + * Whether the channel exists on the server: a query's response was applied to it, or it was restored + * from the offline database. The details are loaded from the server, so a channel created only + * locally (sent to the server with its first message) has none to show yet. + */ +const existsOnServerSelector = ({ initialized, offlineMode }: ChannelStateData) => ({ + existsOnServer: initialized || offlineMode, +}); + export const AvatarWithChannelDetail = ({ Avatar, ChannelDetail = DefaultChannelDetail, @@ -31,6 +42,7 @@ export const AvatarWithChannelDetail = ({ const channel = useChannel(); const { Avatar: ContextAvatar, Modal = GlobalModal } = useComponentContext(); const [isModalOpen, setIsModalOpen] = useState(false); + const { existsOnServer } = useStateStore(channel.state, existsOnServerSelector); const openModal = useCallback(() => setIsModalOpen(true), []); const closeModal = useCallback(() => setIsModalOpen(false), []); @@ -48,6 +60,7 @@ export const AvatarWithChannelDetail = ({ 'Open channel details', )} className='str-chat__avatar-with-channel-detail-button' + disabled={!existsOnServer} onClick={openModal} type='button' > diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx index 7c8eeeff3d..a40d8cd63c 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx @@ -232,8 +232,11 @@ const SendDirectMessageAction = () => { setIsSending(true); try { - const directMessageChannel = client.channel(channel.type, { - members: [client.userID, targetUserId].map((user_id) => ({ user_id })), + const directMessageChannel = client.channelManager.ensure({ + data: { + members: [client.userID, targetUserId].map((user_id) => ({ user_id })), + }, + type: channel.type, }); await directMessageChannel.watch(); // Selection is one navigation model: open the DM into a layout slot, then route it into diff --git a/src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx b/src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx new file mode 100644 index 0000000000..8b6de7bc03 --- /dev/null +++ b/src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import type { Channel, StreamChat } from 'stream-chat'; + +import { AvatarWithChannelDetail } from '../AvatarWithChannelDetail'; +import { + ChannelInstanceProvider, + ChatProvider, + ModalDialogManagerProvider, +} from '../../../context'; +import { getTestClientWithUser, mockChatContext } from '../../../mock-builders'; + +const ChannelDetailStub = () =>
; + +describe('AvatarWithChannelDetail', () => { + let client: StreamChat; + let channel: Channel; + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'alice' }); + // created locally, e.g. by a "new conversation" flow: the server doesn't have it yet + channel = client.channelManager.ensure({ id: 'local', type: 'messaging' }); + }); + + const renderAvatar = () => + render( + + + + + + + , + ); + + const openButton = () => screen.getByRole('button', { name: 'Open channel details' }); + + it('is disabled for a channel the server does not have yet', () => { + renderAvatar(); + + expect(openButton()).toBeDisabled(); + fireEvent.click(openButton()); + expect(screen.queryByTestId('channel-detail')).not.toBeInTheDocument(); + }); + + it('enables once the server has created the channel', () => { + renderAvatar(); + + act(() => { + channel.initialized = true; + }); + fireEvent.click(openButton()); + + expect(openButton()).toBeEnabled(); + expect(screen.getByTestId('channel-detail')).toBeInTheDocument(); + }); + + it('is enabled for a channel restored from the offline database', () => { + channel.offlineMode = true; + + renderAvatar(); + + expect(openButton()).toBeEnabled(); + }); +}); diff --git a/src/plugins/ChannelDetail/styling/AvatarWithChannelDetail.scss b/src/plugins/ChannelDetail/styling/AvatarWithChannelDetail.scss index f6441d360a..36b3663d3f 100644 --- a/src/plugins/ChannelDetail/styling/AvatarWithChannelDetail.scss +++ b/src/plugins/ChannelDetail/styling/AvatarWithChannelDetail.scss @@ -7,4 +7,9 @@ cursor: pointer; display: flex; padding: 0; + + // the channel doesn't exist on the server yet, so it has no details to show + &:disabled { + cursor: default; + } } diff --git a/src/plugins/SlotLayout/ChatView.tsx b/src/plugins/SlotLayout/ChatView.tsx index 8a63ce5a70..f860fb0e39 100644 --- a/src/plugins/SlotLayout/ChatView.tsx +++ b/src/plugins/SlotLayout/ChatView.tsx @@ -42,6 +42,7 @@ import { LayoutController as LayoutControllerClass, } from './layoutController/LayoutController'; import { createChatViewSlotBinding, getChatViewEntityBinding } from './slotBinding'; +import { SupersededChannelSwap } from './SupersededChannelSwap'; import { renderSlotFromRegistry, resolveSlotKindRegistry, @@ -119,7 +120,12 @@ export type ChatViewProps = PropsWithChildren<{ layoutController?: LayoutController; layouts?: LayoutDescriptor[]; resolveDuplicateEntity?: ResolveDuplicateEntity; + /** + * Rendered by the built-in workspace layout in a slot with nothing bound. Without one, such a slot + * is blank, and a layout whose slots are all blank shows {@link ChatViewEmptyPlaceholder}. + */ SlotFallback?: ComponentType; + /** Per-slot {@link ChatViewProps.SlotFallback}. */ slotFallbackComponents?: Partial< Record> >; @@ -185,12 +191,6 @@ const workspaceLayoutStateSelector = (state: ChatViewLayoutState) => ({ viewState: getLayoutViewState(state), }); -const DefaultSlotFallback = () => ( -
- Select a channel to start messaging -
-); - const resolveSlotFallbackComponent = ({ slot, SlotFallback, @@ -201,7 +201,7 @@ const resolveSlotFallbackComponent = ({ slotFallbackComponents?: Partial< Record> >; -}) => slotFallbackComponents?.[slot] ?? SlotFallback ?? DefaultSlotFallback; +}) => slotFallbackComponents?.[slot] ?? SlotFallback; const BUILTIN_WORKSPACE_LAYOUT: ChatViewBuiltinLayout = 'nav-rail-entity-list-workspace'; const DEFAULT_LIST_BINDING_KEY = 'list'; @@ -414,22 +414,20 @@ export const ChatView = ({ ) : layout === BUILTIN_WORKSPACE_LAYOUT ? ( (() => { + // an unbound slot with no fallback has no content; the layout decides what that shows const slots = viewState.availableSlots.map((slot) => { const content = renderSlotFromRegistry( getChatViewEntityBinding(viewState.slotBindings[slot]), slot, slotKindRegistry, ); + if (content != null) return { content, slot }; const Fallback = resolveSlotFallbackComponent({ slot, SlotFallback, slotFallbackComponents, }); - - return { - content: content ?? , - slot, - }; + return { content: Fallback ? : null, slot }; }); return } slots={slots} />; @@ -438,6 +436,12 @@ export const ChatView = ({ children ); + // every slot showing a channel, so one that gets superseded moves to the instance replacing it + const channelSlots = viewState.availableSlots.flatMap((slot) => { + const entity = getChatViewEntityBinding(viewState.slotBindings[slot]); + return entity?.kind === 'channel' ? [{ channel: entity.source, slot }] : []; + }); + return ( @@ -456,6 +460,14 @@ export const ChatView = ({ {content} + {channelSlots.map(({ channel, slot }) => ( + + ))}
diff --git a/src/plugins/SlotLayout/ChatViewEmptyPlaceholder.tsx b/src/plugins/SlotLayout/ChatViewEmptyPlaceholder.tsx new file mode 100644 index 0000000000..0c1d817d15 --- /dev/null +++ b/src/plugins/SlotLayout/ChatViewEmptyPlaceholder.tsx @@ -0,0 +1,27 @@ +import React, { useContext } from 'react'; + +import { ChatViewContext } from './ChatView'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; + +/** + * What a chat view shows while none of its slots has anything bound, worded for the active view + * (channels or threads). `ChatView`'s built-in workspace layout renders it; an app rendering its + * own views places it wherever its empty state goes. + */ +export const ChatViewEmptyPlaceholder = () => { + const { IconMessageBubble } = useComponentContextIcons(); + const { t } = useTranslationContext(); + // outside a ChatView there is no active view; the channels wording applies + const activeView = useContext(ChatViewContext)?.activeView ?? 'channels'; + + return ( +
+ +

+ {activeView === 'threads' + ? t('slotLayout.chatView.empty.threads.text', 'No thread selected') + : t('slotLayout.chatView.empty.channels.text', 'No chat selected')} +

+
+ ); +}; diff --git a/src/plugins/SlotLayout/ChatViewNavigationContext.tsx b/src/plugins/SlotLayout/ChatViewNavigationContext.tsx index a8f79f96f7..5e2c59dbc9 100644 --- a/src/plugins/SlotLayout/ChatViewNavigationContext.tsx +++ b/src/plugins/SlotLayout/ChatViewNavigationContext.tsx @@ -19,7 +19,6 @@ import type { StreamChat, Thread as StreamThread, } from 'stream-chat'; -import { Thread as StreamThreadClass } from 'stream-chat'; import type { ChatView, ChatViewEntityBinding, @@ -102,7 +101,7 @@ export type OpenThreadTarget = /** * Binding builder for the `thread` kind (D6). The `{ channel, message }` -> - * `Thread` construction (with dedupe against `client.threads`) lives here so + * `Thread` resolution (through `client.threads.ensure()`) lives here so * callers can drive the generic `open` with a ready binding instead of a * thread-specific navigation method. */ @@ -110,14 +109,12 @@ export const createThreadEntityBinding = ( client: StreamChat, target: { channel: StreamChannel; message: LocalMessage }, ): ChatViewEntityBinding => { - const existingThread = client.threads.threadsById[target.message.id]; - const thread = - existingThread ?? - new StreamThreadClass({ - channel: target.channel, - client, - parentMessage: target.message, - }); + // `ensure()` registers a thread it builds, so a thread opened beside another one stays live + // even though only the active slot's thread is activated. + const thread = client.threads.ensure({ + channel: target.channel, + parentMessage: target.message, + }); return { key: thread.id ?? undefined, kind: 'thread', source: thread }; }; diff --git a/src/plugins/SlotLayout/DisposedChannelRelease.tsx b/src/plugins/SlotLayout/DisposedChannelRelease.tsx new file mode 100644 index 0000000000..1a8193f62b --- /dev/null +++ b/src/plugins/SlotLayout/DisposedChannelRelease.tsx @@ -0,0 +1,52 @@ +import React, { useEffect } from 'react'; +import type { Channel, ChannelStateData } from 'stream-chat'; + +import { useStateStore } from '../../store'; +import { useChatViewContext } from './ChatView'; +import { useSlotChannels } from './hooks'; +import type { SlotName } from './layoutController/layoutControllerTypes'; +import { getChatViewEntityBinding } from './slotBinding'; + +const pendingDisposalSelector = ({ pendingDisposal }: ChannelStateData) => ({ + pendingDisposal, +}); + +/** + * Empties `slot` once stream-chat disposes of its channel (`channel.pendingDisposal`): the channel + * was deleted (`channel.deleted`, `notification.channel_deleted`) or the current user was removed + * from it (`notification.removed_from_channel`). It no longer receives events, so the slot shows its + * empty state rather than a channel that is gone. A hidden channel is not disposed of, so it stays. + */ +const ReleaseWhenDisposed = ({ channel, slot }: { channel: Channel; slot: SlotName }) => { + const { layoutController } = useChatViewContext(); + const { pendingDisposal } = useStateStore(channel.state, pendingDisposalSelector); + + useEffect(() => { + if (!pendingDisposal) return; + const { activeView, layouts } = layoutController.state.getLatestValue(); + const bound = getChatViewEntityBinding(layouts?.[activeView]?.slotBindings[slot]); + // the slot may already show something else + if (bound?.source !== channel) return; + layoutController.release(slot); + }, [channel, layoutController, pendingDisposal, slot]); + + return null; +}; + +/** + * Empties every slot whose channel stream-chat disposed of: deleted, or the current user removed + * from it. Opt-in: mount it once inside ``. + * + * ```tsx + * + * + * + * ``` + */ +export const DisposedChannelRelease = () => ( + <> + {useSlotChannels().map(({ channel, slot }) => ( + + ))} + +); diff --git a/src/plugins/SlotLayout/SupersededChannelSwap.tsx b/src/plugins/SlotLayout/SupersededChannelSwap.tsx new file mode 100644 index 0000000000..7ad8598cb4 --- /dev/null +++ b/src/plugins/SlotLayout/SupersededChannelSwap.tsx @@ -0,0 +1,59 @@ +import { useEffect } from 'react'; +import type { + ChannelStateData, + EditingAuditState, + Channel as StreamChannel, +} from 'stream-chat'; + +import { useStateStore } from '../../store'; +import { createChatViewSlotBinding } from './slotBinding'; +import type { + LayoutController, + SlotName, +} from './layoutController/layoutControllerTypes'; + +const supersededBySelector = ({ supersededBy }: ChannelStateData) => ({ supersededBy }); +const activeSelector = ({ active }: ChannelStateData) => ({ active }); +const editingAuditStateSelector = (state: EditingAuditState) => state; + +/** + * Moves a slot from a superseded channel to the instance that replaced it (`channel.supersededBy`): + * a channel created without an id whose cid, once the server answered, another instance already + * held. stream-chat has moved the conversation to that instance; this moves the screen. + * + * The swap happens right away, except when the replacing instance is open in another slot and this + * one's composer still holds something: stream-chat moves a composer only into one nobody is using, + * so the slot stays until its composer is empty, typically once its message is sent. + */ +export const SupersededChannelSwap = ({ + channel, + layoutController, + slot, +}: { + channel: StreamChannel; + layoutController: LayoutController; + slot: SlotName; +}) => { + // a slot source without channel state (a stand-in object) never swaps + const { supersededBy } = useStateStore(channel.state, supersededBySelector) ?? {}; + const successorActive = + useStateStore(supersededBy?.state, activeSelector)?.active ?? false; + // re-rendered on every composer change, so the emptiness read below stays current + useStateStore(channel.messageComposer?.editingAuditState, editingAuditStateSelector); + const composerIsEmpty = channel.messageComposer?.compositionIsEmpty ?? true; + + useEffect(() => { + if (!supersededBy) return; + if (successorActive && !composerIsEmpty) return; + layoutController.bind( + slot, + createChatViewSlotBinding({ + key: supersededBy.cid, + kind: 'channel', + source: supersededBy, + }), + ); + }, [composerIsEmpty, layoutController, slot, successorActive, supersededBy]); + + return null; +}; diff --git a/src/plugins/SlotLayout/__tests__/ChatView.test.tsx b/src/plugins/SlotLayout/__tests__/ChatView.test.tsx index 44bb4f8c3f..54a84c7c7d 100644 --- a/src/plugins/SlotLayout/__tests__/ChatView.test.tsx +++ b/src/plugins/SlotLayout/__tests__/ChatView.test.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; @@ -8,6 +9,8 @@ import { mockTranslationContextValue, } from '../../../mock-builders'; import { ChatView } from '../ChatView'; +import { LayoutController } from '../layoutController/LayoutController'; +import { createChatViewSlotBinding } from '../slotBinding'; const renderSelector = async (selectorProps?: any) => { const client = await getTestClientWithUser(); @@ -159,3 +162,108 @@ describe('ChatView.Selector', () => { expect(results).toHaveNoViolations(); }); }); + +describe('ChatView built-in workspace layout empty state', () => { + const renderWorkspace = async ({ + activeView, + bindChannel = false, + ...chatViewProps + }: Partial> & { + activeView?: 'channels' | 'threads'; + bindChannel?: boolean; + } = {}) => { + const client = await getTestClientWithUser(); + const layoutController = new LayoutController({ + initialState: { activeView, availableSlots: ['slot1', 'slot2'] }, + }); + if (bindChannel) { + const channel = client.channelManager.ensure({ id: 'general', type: 'messaging' }); + layoutController.bind( + 'slot1', + createChatViewSlotBinding({ key: channel.cid, kind: 'channel', source: channel }), + ); + } + + return render( + + + ( +
{source.cid}
+ ), + }} + {...chatViewProps} + /> +
+
, + ); + }; + + it('shows one placeholder instead of the slots while every slot is empty', async () => { + const { container } = await renderWorkspace(); + + const placeholders = container.querySelectorAll( + '.str-chat__chat-view__empty-placeholder', + ); + expect(placeholders).toHaveLength(1); + expect(placeholders[0]).toHaveTextContent('No chat selected'); + expect(placeholders[0].querySelector('svg')).toBeInTheDocument(); + expect( + container.querySelector('.str-chat__chat-view__workspace-layout-slot'), + ).not.toBeInTheDocument(); + }); + + it('words the placeholder for the threads view', async () => { + const { container } = await renderWorkspace({ activeView: 'threads' }); + + expect( + container.querySelector('.str-chat__chat-view__empty-placeholder'), + ).toHaveTextContent('No thread selected'); + }); + + it('shows no placeholder while a slot is in use, leaving the empty slot blank', async () => { + const { container } = await renderWorkspace({ bindChannel: true }); + + expect(screen.getByTestId('bound-channel')).toBeInTheDocument(); + expect( + container.querySelector('.str-chat__chat-view__empty-placeholder'), + ).not.toBeInTheDocument(); + expect( + container.querySelectorAll('.str-chat__chat-view__workspace-layout-slot'), + ).toHaveLength(2); + }); + + it('renders SlotFallback instead of the placeholder while every slot is empty', async () => { + const { container } = await renderWorkspace({ + SlotFallback: ({ slot }) =>
, + }); + + expect(screen.getByTestId('fallback-slot1')).toBeInTheDocument(); + expect(screen.getByTestId('fallback-slot2')).toBeInTheDocument(); + expect( + container.querySelector('.str-chat__chat-view__empty-placeholder'), + ).not.toBeInTheDocument(); + }); + + it('renders SlotFallback in an empty slot while another slot is in use', async () => { + await renderWorkspace({ + bindChannel: true, + SlotFallback: ({ slot }) =>
, + }); + + expect(screen.getByTestId('fallback-slot2')).toBeInTheDocument(); + expect(screen.queryByTestId('fallback-slot1')).not.toBeInTheDocument(); + }); +}); diff --git a/src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx b/src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx new file mode 100644 index 0000000000..57803f192f --- /dev/null +++ b/src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { act, render } from '@testing-library/react'; +import type { Channel, StreamChat } from 'stream-chat'; + +import { ChatView } from '../ChatView'; +import { DisposedChannelRelease } from '../DisposedChannelRelease'; +import { LayoutController } from '../layoutController/LayoutController'; +import { createChatViewSlotBinding, getChatViewEntityBinding } from '../slotBinding'; +import { ChatProvider } from '../../../context/ChatContext'; +import type { ChatContextValue } from '../../../context/ChatContext'; +import { TranslationProvider } from '../../../context/TranslationContext'; +import type { TranslationContextValue } from '../../../context/TranslationContext'; +import { getTestClientWithUser } from '../../../mock-builders'; +import { mockT } from '../../../mock-builders/translator'; + +vi.mock('../../../components/Channel/Channel', () => ({ + Channel: () => null, +})); + +describe('DisposedChannelRelease', () => { + let client: StreamChat; + let channel: Channel; + let layoutController: LayoutController; + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'ann' }); + channel = client.channelManager.ensure({ id: 'general', type: 'messaging' }); + layoutController = new LayoutController({ + initialState: { availableSlots: ['slot1'] }, + }); + layoutController.bind( + 'slot1', + createChatViewSlotBinding({ key: channel.cid, kind: 'channel', source: channel }), + ); + }); + + const boundSource = () => { + const state = layoutController.state.getLatestValue(); + const viewState = state.layouts?.[state.activeView]; + return getChatViewEntityBinding(viewState?.slotBindings.slot1)?.source; + }; + + const renderInChatView = () => + render( + + + + + + + , + ); + + it('leaves a live channel in its slot', () => { + renderInChatView(); + + expect(boundSource()).toBe(channel); + }); + + it('empties the slot when its channel is deleted', () => { + renderInChatView(); + + act(() => { + client.dispatchEvent({ + channel: { cid: channel.cid, id: 'general', type: 'messaging' }, + channel_id: 'general', + channel_type: 'messaging', + cid: channel.cid, + type: 'channel.deleted', + } as never); + }); + + expect(channel.pendingDisposal).toBe(true); + expect(boundSource()).toBeUndefined(); + }); + + it('empties the slot when the current user is removed from its channel', () => { + renderInChatView(); + + act(() => { + client.dispatchEvent({ + cid: channel.cid, + type: 'notification.removed_from_channel', + } as never); + }); + + expect(boundSource()).toBeUndefined(); + }); + + it('leaves a slot that shows another channel', () => { + const other = client.channelManager.ensure({ id: 'other', type: 'messaging' }); + renderInChatView(); + + act(() => { + layoutController.bind( + 'slot1', + createChatViewSlotBinding({ key: other.cid, kind: 'channel', source: other }), + ); + client.dispatchEvent({ + cid: channel.cid, + type: 'channel.deleted', + } as never); + }); + + expect(boundSource()).toBe(other); + }); +}); diff --git a/src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx b/src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx new file mode 100644 index 0000000000..ca18f9efa0 --- /dev/null +++ b/src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { act, render } from '@testing-library/react'; +import type { Channel, StreamChat } from 'stream-chat'; + +import { SupersededChannelSwap } from '../SupersededChannelSwap'; +import { LayoutController } from '../layoutController/LayoutController'; +import { createChatViewSlotBinding, getChatViewEntityBinding } from '../slotBinding'; +import { getTestClientWithUser } from '../../../mock-builders'; + +describe('SupersededChannelSwap', () => { + let client: StreamChat; + let previous: Channel; + let successor: Channel; + let layoutController: LayoutController; + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'ann' }); + previous = client.channelManager.ensure({ id: 'previous', type: 'messaging' }); + successor = client.channelManager.ensure({ id: 'successor', type: 'messaging' }); + layoutController = new LayoutController({ + initialState: { availableSlots: ['slot1'] }, + }); + layoutController.bind( + 'slot1', + createChatViewSlotBinding({ key: previous.cid, kind: 'channel', source: previous }), + ); + }); + + const boundChannel = () => { + const state = layoutController.state.getLatestValue(); + const viewState = state.layouts?.[state.activeView]; + return getChatViewEntityBinding(viewState?.slotBindings.slot1)?.source; + }; + + const renderSwap = () => + render( + , + ); + + it('leaves a channel that is not superseded in its slot', () => { + renderSwap(); + + expect(boundChannel()).toBe(previous); + }); + + it('moves the slot to the instance that superseded its channel', () => { + renderSwap(); + + act(() => { + previous.state.partialNext({ supersededBy: successor }); + }); + + expect(boundChannel()).toBe(successor); + }); + + it('waits while the successor is open elsewhere and this composer still holds something', () => { + successor.activate(); + previous.messageComposer.textComposer.setText('unsent'); + renderSwap(); + + act(() => { + previous.state.partialNext({ supersededBy: successor }); + }); + expect(boundChannel()).toBe(previous); + + act(() => { + previous.messageComposer.clear(); + }); + expect(boundChannel()).toBe(successor); + }); +}); diff --git a/src/plugins/SlotLayout/index.tsx b/src/plugins/SlotLayout/index.tsx index 1d3a17087f..c678e42cfa 100644 --- a/src/plugins/SlotLayout/index.tsx +++ b/src/plugins/SlotLayout/index.tsx @@ -1,10 +1,12 @@ export * from './ChatView'; +export * from './ChatViewEmptyPlaceholder'; export * from './ChatViewNavigationContext'; export * from './workspaceNavigationAdapter'; export * from './layoutController/layoutControllerTypes'; export * from './layoutController/serialization'; export * from './hooks'; export * from './ChannelSlot'; +export * from './DisposedChannelRelease'; export * from './ThreadSlot'; export * from './ThreadListSlot'; export * from './layout/Slot'; diff --git a/src/plugins/SlotLayout/layout/WorkspaceLayout.tsx b/src/plugins/SlotLayout/layout/WorkspaceLayout.tsx index 95038dbdeb..cfb2c63f9e 100644 --- a/src/plugins/SlotLayout/layout/WorkspaceLayout.tsx +++ b/src/plugins/SlotLayout/layout/WorkspaceLayout.tsx @@ -1,6 +1,7 @@ import React from 'react'; import clsx from 'clsx'; import { Slot } from './Slot'; +import { ChatViewEmptyPlaceholder } from '../ChatViewEmptyPlaceholder'; import type { ReactNode } from 'react'; @@ -11,28 +12,40 @@ export type WorkspaceLayoutSlot = { export type WorkspaceLayoutProps = { navRail?: ReactNode; + /** + * The slots, each with what it shows. While none has anything to show (no slots, or no content + * in any), {@link ChatViewEmptyPlaceholder} is shown in their place. + */ slots: WorkspaceLayoutSlot[]; }; -export const WorkspaceLayout = ({ navRail, slots }: WorkspaceLayoutProps) => ( -
- {navRail ? ( -
{navRail}
- ) : null} -
- {slots.map(({ content, slot }) => ( - - {content} - - ))} +export const WorkspaceLayout = ({ navRail, slots }: WorkspaceLayoutProps) => { + const isEmpty = slots.every(({ content }) => content == null); + + return ( +
+ {navRail ? ( +
{navRail}
+ ) : null} +
+ {isEmpty ? ( + + ) : ( + slots.map(({ content, slot }) => ( + + {content} + + )) + )} +
-
-); + ); +}; diff --git a/src/plugins/SlotLayout/styling/ChatView.scss b/src/plugins/SlotLayout/styling/ChatView.scss index fcc640c216..ce1eadab84 100644 --- a/src/plugins/SlotLayout/styling/ChatView.scss +++ b/src/plugins/SlotLayout/styling/ChatView.scss @@ -135,6 +135,35 @@ justify-content: center; } +.str-chat__chat-view__empty-placeholder { + --str-chat-icon-color: var(--str-chat__text-tertiary); + + display: flex; + flex: 1 1 auto; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--str-chat__spacing-sm); + height: 100%; + padding: var(--str-chat__spacing-3xl) var(--str-chat__spacing-xl); + background: var(--str-chat__background-core-app); + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); + text-align: center; + + svg { + width: var(--str-chat__icon-size-lg); + height: var(--str-chat__icon-size-lg); + color: var(--str-chat-icon-color); + } + + .str-chat__chat-view__empty-placeholder-text { + margin: 0; + max-width: 320px; + overflow-wrap: break-word; + } +} + .str-chat__chat-view__workspace-layout-slot { display: flex; flex: 1 1 0; diff --git a/src/utils/__tests__/getChannel.test.ts b/src/utils/__tests__/getChannel.test.ts index 896dd79185..d35c3f5f07 100644 --- a/src/utils/__tests__/getChannel.test.ts +++ b/src/utils/__tests__/getChannel.test.ts @@ -40,28 +40,37 @@ describe('getChannel', () => { }); it('throws an error if channel without with id and no channel members array are provided', async () => { - const channel = client.channel('type', undefined); + const channel = client.channelManager.ensure({ type: 'type' }); await expect(getChannel({ channel, client })).rejects.toThrow( 'Channel ID or channel members array have to be provided to query a channel.', ); }); it('throws an error if channel without with id but with channel members array are provided', async () => { - const channel = client.channel('messaging', { members: memberIds }); + const channel = client.channelManager.ensure({ + data: { members: memberIds }, + type: 'messaging', + }); await expect(getChannel({ channel, client })).rejects.toThrow( 'Channel ID or channel members array have to be provided to query a channel.', ); }); it('calls channel.watch for a given channel type and id if channel query not already in progress', async () => { - const channel = client.channel('messaging', channelData.channel.id); + const channel = client.channelManager.ensure({ + id: channelData.channel.id, + type: 'messaging', + }); vi.spyOn(channel, 'watch').mockResolvedValueOnce(undefined!); await getChannel({ client, id: channel.id, type: channel.type }); expect(channel.watch).toHaveBeenCalledTimes(1); }); it('does not call channel.watch for a given channel type and id if channel query already in progress', () => { - const channel = client.channel('messaging', channelData.channel.id); + const channel = client.channelManager.ensure({ + id: channelData.channel.id, + type: 'messaging', + }); vi.spyOn(channel, 'watch').mockResolvedValue(undefined!); getChannel({ client, id: channel.id, type: channel.type }); getChannel({ client, id: channel.id, type: channel.type }); @@ -69,14 +78,20 @@ describe('getChannel', () => { }); it('calls channel.watch for a given channel type and members array if channel query not already in progress', async () => { - const channel = client.channel('messaging', { members: memberIds }); + const channel = client.channelManager.ensure({ + data: { members: memberIds }, + type: 'messaging', + }); vi.spyOn(channel, 'watch').mockResolvedValueOnce(undefined!); await getChannel({ client, members: memberIds, type: channelData.channel.type }); expect(channel.watch).toHaveBeenCalledTimes(1); }); it('does not call channel.watch for a given channel type and members array if channel query already in progress', () => { - const channel = client.channel('messaging', { members: memberIds }); + const channel = client.channelManager.ensure({ + data: { members: memberIds }, + type: 'messaging', + }); vi.spyOn(channel, 'watch').mockResolvedValue(undefined!); getChannel({ client, members: memberIds, type: channelData.channel.type }); getChannel({ client, members: memberIds, type: channelData.channel.type }); @@ -84,14 +99,14 @@ describe('getChannel', () => { }); it('calls channel.watch for a given channel object with id and type if channel query not already in progress', async () => { - const channel = client.channel('messaging', 'id'); + const channel = client.channelManager.ensure({ id: 'id', type: 'messaging' }); vi.spyOn(channel, 'watch').mockResolvedValueOnce(undefined!); await getChannel({ channel, client }); expect(channel.watch).toHaveBeenCalledTimes(1); }); it('does not call channel.watch for a given channel object with id and type if channel query already in progress', () => { - const channel = client.channel('messaging', 'id'); + const channel = client.channelManager.ensure({ id: 'id', type: 'messaging' }); vi.spyOn(channel, 'watch').mockResolvedValue(undefined!); getChannel({ channel, client }); getChannel({ channel, client }); @@ -99,14 +114,14 @@ describe('getChannel', () => { }); it('calls channel.watch for a given channel object with type and members array if channel query not already in progress', async () => { - const channel = client.channel('messaging', undefined); + const channel = client.channelManager.ensure({ type: 'messaging' }); vi.spyOn(channel, 'watch').mockResolvedValueOnce(undefined!); await getChannel({ channel, client, members: memberIds }); expect(channel.watch).toHaveBeenCalledTimes(1); }); it('does not call channel.watch for a given channel object with type and members array if channel query already in progress', () => { - const channel = client.channel('messaging', undefined); + const channel = client.channelManager.ensure({ type: 'messaging' }); vi.spyOn(channel, 'watch').mockResolvedValue(undefined!); getChannel({ channel, client, members: memberIds }); getChannel({ channel, client, members: memberIds }); diff --git a/src/utils/getChannel.ts b/src/utils/getChannel.ts index 61c312421d..d708b5c96d 100644 --- a/src/utils/getChannel.ts +++ b/src/utils/getChannel.ts @@ -48,8 +48,12 @@ export const getChannel = async ({ if (!type) { throw new Error('Channel or channel type have to be provided to query a channel.'); } - theChannel = client.channel(type, id, { - members: members?.map((user_id) => ({ user_id })), + theChannel = client.channelManager.ensure({ + data: { + members: members?.map((user_id) => ({ user_id })), + }, + id, + type, }); }