From 9704393f9e5c0abece1b05ec9661066476864d04 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 28 Jul 2026 15:28:56 +0200 Subject: [PATCH 1/9] chore(tutorial): align the example app with the rewritten React chat tutorial (#3251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🎯 Goal `examples/tutorial` had drifted out of sync with the published [React chat tutorial](https://getstream.io/chat/sdk/react/tutorial/), which was restructured in GetStream/getstream.io#345 into numbered steps 0-7 plus two optional recipes. Folder numbering no longer lined up with the tutorial's step numbers, and there was no runnable counterpart for the theming step at all. Two things also made the example unusable as it stood: - It did not boot on a clean install. Two React instances, `Invalid hook call` on first render. Reproduces on `master`. - The preview panel clipped the chat UI at the edges and pushed the composer below the fold. The point of this example is that a reader can run the exact code the tutorial gave them, so a folder here needs to map 1:1 to a heading there. ### πŸ›  Implementation details Three commits, each independently revertable. **1. Step alignment** (`244f77697`) Folders renumbered to match the tutorial, and the two non-linear steps renamed to `optional-*`: | Folder | Tutorial section | | --- | --- | | `2-client-setup` | Step 2 - Connect the client | | `3-core-component-setup` | Step 3 - Get a working chat UI | | `4-channel-list` | Step 4 - Add a channel list | | `5-theming` | Step 5 - Theme it (**new**) | | `6-custom-ui-components` | Step 6 - Replace an SDK component | | `7-emoji-picker` | Step 7 - Enable the emoji picker and autocomplete | | `optional-custom-attachment-type` | Optional - add a custom attachment type | | `optional-livestream` | Optional - a livestream-style chat app | The tutorial's Step 0 (environment) and Step 1 (project + credentials) have no runnable counterpart, so numbering starts at 2. Also in this commit: - **Preview panel layout fix.** The chrome panes are sized in viewport units *and* padded, so under the default `content-box` the padding was added on top of `100vh` β€” 1312px of content in a 1272px viewport. Fixed with `border-box` on the six named chrome classes only, deliberately not `.tutorial-browser *`, so the SDK's own box-sizing is untouched. - **Entry cleanup.** Removed the 16 per-step `main.tsx` / `index.html` files. They date to #2697, when the step browser did not exist and booting a step's own HTML was the only way to run it. Since the browser landed they have been dead weight: `vite build` only ever emitted `dist/index.html`, nothing referenced them, and all eight `main.tsx` were byte-identical. Each step folder now holds exactly the files the tutorial tells you to create. **2. React dedupe** (`4b865aabc`) `stream-chat-react` is consumed as a workspace dependency, so Vite serves its built output from outside the app's root and resolves that copy's `react` import separately from the app's. The SDK and the app end up on two React instances and the first hook call throws. `resolve.dedupe: ['react', 'react-dom']` forces both onto a single copy. Split out on its own because it is the one change that is not tutorial content β€” it can be cherry-picked or reverted independently. **3. Step stylesheet cleanup** (`6a67e4447`) β€” no rendering change The step browser renders every step in one document, so all seven stylesheets are live at once. Steps 3 and 4 import the SDK stylesheet *unlayered* (as the tutorial has them, since Step 5 is where you are taught to move it into a layer), and unlayered CSS outranks every `@layer` regardless of specificity β€” so the tutorial's `@layer stream-overrides { .custom-theme { ... } }` silently does nothing here. The themed steps declare the tokens unlayered on `.str-chat.custom-theme` instead. `Channel.tsx:163` and `ChannelList.tsx:342` both do `clsx('str-chat', theme, ...)`, so the theme class lands on the same element as `str-chat`; at 0,2,0 this beats the SDK's own `.str-chat` (0,1,0) regardless of source order, and it only matches steps that actually pass `theme="custom-theme"`, so it cannot leak into the unthemed ones. README flags this as the one deliberate deviation, with a pointer to keep the tokens in the layer in your own app. That leaves `layout.css` with exactly **two distinct versions**, mirroring the tutorial's two, byte-identical within each group so drift shows up in a diff. Also: `html` / `body` / `#root` were previously declared *only* in the step stylesheets, so the chrome silently depended on a step's CSS for `body { margin: 0 }` and would pick up the UA margin if steps were ever loaded lazily or in isolation (`2-client-setup` has no `layout.css` at all). `tutorial-main.css` now declares them itself. Parts of each `layout.css` copy are inert inside the step browser and stay that way on purpose, since the file has to remain a faithful copy of what the tutorial has readers write. README documents each case: - the `custom-theme` tokens do nothing in `7-emoji-picker` and `optional-livestream`, which do not pass `theme="custom-theme"` β€” matching the tutorial, where the reader's single `layout.css` holds the tokens and leaves them unused for those same two examples - the `.str-chat__*` widths lose to `.tutorial-browser__step-shell .str-chat__*` in `tutorial-main.css` (0,2,0 against 0,1,0); the tutorial's widths assume the app owns the whole page, here it is sized to fit a preview card No bundle cost either way β€” Vite collapses the identical copies, so the built CSS contains one `width: 30%` and one `@layer stream`. ### 🎨 UI Changes No change to the SDK itself, and no change to any tutorial code block. The visual deltas are all in the example app: - **New `5-theming` step**, so the tutorial's theming milestone is now runnable. - **Preview panel no longer clips.** Before, the chat UI was cut off at the card's edges and the composer sat below the fold. Now every step reports `overflowX/Y: 0` with the preview card fully within the viewport. - **Step 2 corner padding.** `2-client-setup` renders bare text with no chat chrome, so it landed inside the card's 28px corner arc and the first glyph was clipped. Padded via the `step-client-setup` class. Verified across all eight steps by reading computed styles after switching: | Step | Theme class on `.str-chat` | `--str-chat__accent-primary` | | --- | --- | --- | | 3, 4 | `messaging light` | `#005fff` | | 5, 6, `optional-custom-attachment-type` | `custom-theme` | `#0d47a1` | | 7 | `messaging light` | `#005fff` | | `optional-livestream` | `str-chat__theme-dark` | `#4586ff` | Step 7 intentionally shows the default accent β€” the tutorial's emoji block renders `` without the `theme` prop, and [notes so explicitly](https://github.com/GetStream/getstream.io/pull/345). `tsc -b`, `vite build`, and gated prettier all pass. No console errors on any step. To see it: `yarn start:tutorial` from the repo root. --- Opened without a reviewer since I am not sure who owns this area β€” happy to add whoever should look at it. Companion PR: GetStream/getstream.io#345 ## Summary by CodeRabbit * **New Features** * Expanded the tutorial browser with updated step structure, channel list, theming, custom UI components, emoji picker, and livestream (including optional milestones). * **Documentation** * Refreshed the tutorial README with current folder/step mapping and step-browser rendering notes. * **Bug Fixes** * Improved tutorial preview/layout sizing to avoid viewport clipping/overflow. * Prevented issues from multiple React instances during development. * **Refactor** * Reworked step mounting and applied consistent theme overrides across steps. * **Chores** * Updated the example environment key name to `VITE_STREAM_API_KEY` (with legacy fallback). --- examples/tutorial/.env.example | 6 +- examples/tutorial/README.md | 94 ++++++++++++++++++- .../tutorial/src/1-client-setup/index.html | 13 --- examples/tutorial/src/1-client-setup/main.tsx | 9 -- .../App.tsx | 0 .../credentials.ts | 6 +- .../src/2-core-component-setup/index.html | 13 --- .../src/2-core-component-setup/main.tsx | 9 -- .../tutorial/src/3-channel-list/index.html | 13 --- .../tutorial/src/3-channel-list/layout.css | 53 ----------- examples/tutorial/src/3-channel-list/main.tsx | 9 -- .../App.tsx | 2 +- .../layout.css | 14 +-- .../stream-chat.d.ts | 0 examples/tutorial/src/4-channel-list/App.tsx | 57 +++++++++++ .../tutorial/src/4-channel-list/layout.css | 21 +++++ .../src/4-custom-ui-components/index.html | 13 --- .../src/4-custom-ui-components/layout.css | 53 ----------- .../src/4-custom-ui-components/main.tsx | 9 -- .../src/5-custom-attachment-type/index.html | 13 --- .../src/5-custom-attachment-type/layout.css | 53 ----------- .../src/5-custom-attachment-type/main.tsx | 9 -- .../src/{3-channel-list => 5-theming}/App.tsx | 2 +- examples/tutorial/src/5-theming/layout.css | 60 ++++++++++++ .../App.tsx | 4 +- .../src/6-custom-ui-components/layout.css | 60 ++++++++++++ .../tutorial/src/6-emoji-picker/index.html | 13 --- .../tutorial/src/6-emoji-picker/layout.css | 53 ----------- examples/tutorial/src/6-emoji-picker/main.tsx | 9 -- .../App.tsx | 2 +- .../tutorial/src/7-emoji-picker/layout.css | 60 ++++++++++++ examples/tutorial/src/7-livestream/index.html | 13 --- examples/tutorial/src/7-livestream/layout.css | 53 ----------- examples/tutorial/src/7-livestream/main.tsx | 9 -- examples/tutorial/src/App.tsx | 60 +++++++----- .../App.tsx | 6 +- .../layout.css | 60 ++++++++++++ .../stream-chat.d.ts | 0 .../App.tsx | 2 +- .../src/optional-livestream/layout.css | 60 ++++++++++++ examples/tutorial/src/tutorial-main.css | 51 ++++++++-- examples/tutorial/vite.config.ts | 7 ++ 42 files changed, 587 insertions(+), 466 deletions(-) delete mode 100644 examples/tutorial/src/1-client-setup/index.html delete mode 100644 examples/tutorial/src/1-client-setup/main.tsx rename examples/tutorial/src/{1-client-setup => 2-client-setup}/App.tsx (100%) rename examples/tutorial/src/{1-client-setup => 2-client-setup}/credentials.ts (85%) delete mode 100644 examples/tutorial/src/2-core-component-setup/index.html delete mode 100644 examples/tutorial/src/2-core-component-setup/main.tsx delete mode 100644 examples/tutorial/src/3-channel-list/index.html delete mode 100644 examples/tutorial/src/3-channel-list/layout.css delete mode 100644 examples/tutorial/src/3-channel-list/main.tsx rename examples/tutorial/src/{2-core-component-setup => 3-core-component-setup}/App.tsx (95%) rename examples/tutorial/src/{2-core-component-setup => 3-core-component-setup}/layout.css (52%) rename examples/tutorial/src/{2-core-component-setup => 3-core-component-setup}/stream-chat.d.ts (100%) create mode 100644 examples/tutorial/src/4-channel-list/App.tsx create mode 100644 examples/tutorial/src/4-channel-list/layout.css delete mode 100644 examples/tutorial/src/4-custom-ui-components/index.html delete mode 100644 examples/tutorial/src/4-custom-ui-components/layout.css delete mode 100644 examples/tutorial/src/4-custom-ui-components/main.tsx delete mode 100644 examples/tutorial/src/5-custom-attachment-type/index.html delete mode 100644 examples/tutorial/src/5-custom-attachment-type/layout.css delete mode 100644 examples/tutorial/src/5-custom-attachment-type/main.tsx rename examples/tutorial/src/{3-channel-list => 5-theming}/App.tsx (94%) create mode 100644 examples/tutorial/src/5-theming/layout.css rename examples/tutorial/src/{4-custom-ui-components => 6-custom-ui-components}/App.tsx (97%) create mode 100644 examples/tutorial/src/6-custom-ui-components/layout.css delete mode 100644 examples/tutorial/src/6-emoji-picker/index.html delete mode 100644 examples/tutorial/src/6-emoji-picker/layout.css delete mode 100644 examples/tutorial/src/6-emoji-picker/main.tsx rename examples/tutorial/src/{6-emoji-picker => 7-emoji-picker}/App.tsx (96%) create mode 100644 examples/tutorial/src/7-emoji-picker/layout.css delete mode 100644 examples/tutorial/src/7-livestream/index.html delete mode 100644 examples/tutorial/src/7-livestream/layout.css delete mode 100644 examples/tutorial/src/7-livestream/main.tsx rename examples/tutorial/src/{5-custom-attachment-type => optional-custom-attachment-type}/App.tsx (94%) create mode 100644 examples/tutorial/src/optional-custom-attachment-type/layout.css rename examples/tutorial/src/{5-custom-attachment-type => optional-custom-attachment-type}/stream-chat.d.ts (100%) rename examples/tutorial/src/{7-livestream => optional-livestream}/App.tsx (95%) create mode 100644 examples/tutorial/src/optional-livestream/layout.css diff --git a/examples/tutorial/.env.example b/examples/tutorial/.env.example index 46895730ee..470fb5bd6c 100644 --- a/examples/tutorial/.env.example +++ b/examples/tutorial/.env.example @@ -1,5 +1,7 @@ -# Required: your Stream app's public key. -VITE_API_KEY=REPLACE_WITH_API_KEY +# Required: your Stream app's public key. This is the variable name that +# `getstream env --target vite` writes, so you can generate it instead of +# pasting it by hand. (VITE_API_KEY is still read as a fallback.) +VITE_STREAM_API_KEY=REPLACE_WITH_API_KEY # Optional. If unset, the app defaults to user_id "react-tutorial" and # derives user_name from it. You can also override either value per-run diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index 90854e0949..15af1b34ca 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -1,4 +1,96 @@ -This folder contains the source code for [Chat React tutorial](https://github.com/GetStream/getstream.io-tutorials/blob/main/chat/tutorials/react-tutorial.mdx). It contains multiple versions of apps representing the tutorial steps. +This folder contains the source code for the [Chat React tutorial](https://getstream.io/chat/sdk/react/tutorial/). It contains multiple versions of apps representing the tutorial steps. + +The tutorial source lives in the website repo at [`content/pages/chat_sdk_react_tutorial.mdx`](https://github.com/GetStream/getstream.io/blob/main/content/pages/chat_sdk_react_tutorial.mdx). (It used to live in `GetStream/getstream.io-tutorials`, which is now archived.) + +## Step folders + +Folder names match the tutorial's step numbers, so `4-channel-list` is the tutorial's "Step 4 - Add a channel list". The tutorial's Step 0 (environment) and Step 1 (project + credentials) have no runnable counterpart, so the folders start at 2. The two `optional-*` folders are the tutorial's optional recipes, which sit after the numbered path. + +| Folder | Tutorial section | +| --------------------------------- | ------------------------------------------------- | +| `2-client-setup` | Step 2 - Connect the client | +| `3-core-component-setup` | Step 3 - Get a working chat UI | +| `4-channel-list` | Step 4 - Add a channel list | +| `5-theming` | Step 5 - Theme it | +| `6-custom-ui-components` | Step 6 - Replace an SDK component | +| `7-emoji-picker` | Step 7 - Enable the emoji picker and autocomplete | +| `optional-custom-attachment-type` | Optional - add a custom attachment type | +| `optional-livestream` | Optional - a livestream-style chat app | + +If you change a step's code here, update the matching code block in the tutorial too, and vice versa. + +### `layout.css` is duplicated on purpose + +The tutorial has the reader create a single `src/layout.css` in Step 3 and +rewrite it in Step 5. Each step folder here carries its own copy so the folder is +a self-contained snapshot of the app at that step, which means there are only two +distinct versions of the file: + +| Version | In | +| -------- | -------------------------------------------------------------------------- | +| Step 3's | `3-core-component-setup`, `4-channel-list` | +| Step 5's | `5-theming`, `6-custom-ui-components`, `7-emoji-picker`, both `optional-*` | + +Every file in a group is byte-identical, so any drift shows up in a diff. If you +edit one, edit the whole group. + +Parts of each copy are inert inside the step browser. That is expected, and none +of it should be "cleaned up" here, because the file has to stay a faithful copy of +what the tutorial tells the reader to write: + +- The `custom-theme` tokens do nothing in `7-emoji-picker` and + `optional-livestream`, which don't pass `theme="custom-theme"` to ``. The + reader's single `layout.css` holds the tokens and leaves them unused for those + same two examples. +- The `.str-chat__channel-list` / `__channel` / `__thread` widths are overridden + by `.tutorial-browser__step-shell .str-chat__*` in `tutorial-main.css`, which + wins on specificity (0,2,0 against 0,1,0). The tutorial's widths assume the app + owns the whole page; here it is sized to fit a preview card. +- The `html` / `body` / `#root` rules are real, but `tutorial-main.css` declares + them too, so the chrome does not depend on a step's stylesheet. + +None of this costs bundle size: Vite collapses the identical copies, so the built +CSS contains one `width: 30%` and one `@layer stream`. + +### One deliberate deviation: unlayered theme tokens + +The tutorial puts the custom theme tokens in a CSS layer: + +```css +@layer stream, stream-overrides; +@import 'stream-chat-react/dist/css/index.css' layer(stream); + +@layer stream-overrides { + .custom-theme { + /* tokens */ + } +} +``` + +The themed steps here declare them unlayered instead: + +```css +@layer stream; +@import 'stream-chat-react/dist/css/index.css' layer(stream); + +.str-chat.custom-theme { + /* tokens */ +} +``` + +Why: the step browser renders every step in a single document, so all seven +stylesheets are live at once. Steps 3 and 4 import the SDK stylesheet +_unlayered_ (as the tutorial has them, since Step 5 is where you're taught to +move it into a layer), and unlayered CSS outranks every `@layer` regardless of +specificity. A layered override would silently do nothing. + +`.str-chat.custom-theme` (specificity 0,2,0) also beats the SDK's own +`.str-chat` (0,1,0) regardless of source order, and it only matches the steps +that actually pass `theme="custom-theme"`, so the themed steps can't leak into +the unthemed ones. + +**This deviation exists only to make the step browser work. In your own app, +follow the tutorial and keep the tokens in the layer.** The tutorial app is a Yarn workspace (`@stream-io/stream-chat-react-tutorial`) under the repo's monorepo, so it consumes the local `stream-chat-react` SDK through `workspace:^` and shares its dependencies with the root install. diff --git a/examples/tutorial/src/1-client-setup/index.html b/examples/tutorial/src/1-client-setup/index.html deleted file mode 100644 index 7877092389..0000000000 --- a/examples/tutorial/src/1-client-setup/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + React + TS - - -
- - - diff --git a/examples/tutorial/src/1-client-setup/main.tsx b/examples/tutorial/src/1-client-setup/main.tsx deleted file mode 100644 index e17d50b103..0000000000 --- a/examples/tutorial/src/1-client-setup/main.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import App from './App.tsx'; - -createRoot(document.getElementById('root')!).render( - - - , -); diff --git a/examples/tutorial/src/1-client-setup/App.tsx b/examples/tutorial/src/2-client-setup/App.tsx similarity index 100% rename from examples/tutorial/src/1-client-setup/App.tsx rename to examples/tutorial/src/2-client-setup/App.tsx diff --git a/examples/tutorial/src/1-client-setup/credentials.ts b/examples/tutorial/src/2-client-setup/credentials.ts similarity index 85% rename from examples/tutorial/src/1-client-setup/credentials.ts rename to examples/tutorial/src/2-client-setup/credentials.ts index 0236916296..77277926e1 100644 --- a/examples/tutorial/src/1-client-setup/credentials.ts +++ b/examples/tutorial/src/2-client-setup/credentials.ts @@ -8,7 +8,9 @@ // ?user_id=alice&user_name=Alice // + display name override // // Notes: -// - apiKey is the one thing you still need to set (via VITE_API_KEY). +// - apiKey is the one thing you still need to set. `getstream env --target vite` +// writes VITE_STREAM_API_KEY, which is what the tutorial tells you to run; +// VITE_API_KEY is still accepted for older local setups. // - The token endpoint and environment default to the values shared with // the other example apps in this repo; override with VITE_TOKEN_ENDPOINT // and VITE_TOKEN_ENVIRONMENT if you're pointing at a different Stream @@ -16,7 +18,7 @@ const searchParams = new URLSearchParams(window.location.search); -export const apiKey = import.meta.env.VITE_API_KEY; +export const apiKey = import.meta.env.VITE_STREAM_API_KEY || import.meta.env.VITE_API_KEY; export const userId = searchParams.get('user_id') || import.meta.env.VITE_USER_ID || 'react-tutorial'; diff --git a/examples/tutorial/src/2-core-component-setup/index.html b/examples/tutorial/src/2-core-component-setup/index.html deleted file mode 100644 index 7877092389..0000000000 --- a/examples/tutorial/src/2-core-component-setup/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + React + TS - - -
- - - diff --git a/examples/tutorial/src/2-core-component-setup/main.tsx b/examples/tutorial/src/2-core-component-setup/main.tsx deleted file mode 100644 index e17d50b103..0000000000 --- a/examples/tutorial/src/2-core-component-setup/main.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import App from './App.tsx'; - -createRoot(document.getElementById('root')!).render( - - - , -); diff --git a/examples/tutorial/src/3-channel-list/index.html b/examples/tutorial/src/3-channel-list/index.html deleted file mode 100644 index 7877092389..0000000000 --- a/examples/tutorial/src/3-channel-list/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + React + TS - - -
- - - diff --git a/examples/tutorial/src/3-channel-list/layout.css b/examples/tutorial/src/3-channel-list/layout.css deleted file mode 100644 index 2fd790e68c..0000000000 --- a/examples/tutorial/src/3-channel-list/layout.css +++ /dev/null @@ -1,53 +0,0 @@ -@layer stream, stream-overrides; -@import 'stream-chat-react/dist/css/index.css' layer(stream); - -@layer stream-overrides { - .custom-theme { - /* Accent */ - --str-chat__accent-primary: #0d47a1; - - /* Message bubble colors */ - --str-chat__chat-bg-outgoing: #1e3a8a; - --str-chat__chat-bg-attachment-outgoing: #0d47a1; - --str-chat__chat-bg-incoming: #dbeafe; - --str-chat__chat-text-outgoing: #ffffff; - --str-chat__chat-reply-indicator-outgoing: #93c5fd; - - /* Links */ - --str-chat__text-link: #1e40af; - --str-chat__chat-text-link: #93c5fd; - - /* Panel backgrounds */ - --str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ - --str-chat__background-core-app: #c7dafc; /* message list background */ - - /* Focus ring */ - --str-chat__border-utility-focused: #1e40af; - - /* Radii */ - --str-chat__radius-max: 8px; - --str-chat__button-radius-full: 6px; - } -} - -html, -body, -#root { - height: 100%; -} -body { - margin: 0; -} -#root { - display: flex; -} - -.str-chat__channel-list { - width: 30%; -} -.str-chat__channel { - width: 100%; -} -.str-chat__thread { - width: 45%; -} diff --git a/examples/tutorial/src/3-channel-list/main.tsx b/examples/tutorial/src/3-channel-list/main.tsx deleted file mode 100644 index e17d50b103..0000000000 --- a/examples/tutorial/src/3-channel-list/main.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import App from './App.tsx'; - -createRoot(document.getElementById('root')!).render( - - - , -); diff --git a/examples/tutorial/src/2-core-component-setup/App.tsx b/examples/tutorial/src/3-core-component-setup/App.tsx similarity index 95% rename from examples/tutorial/src/2-core-component-setup/App.tsx rename to examples/tutorial/src/3-core-component-setup/App.tsx index d6067dfb13..d7681f9890 100644 --- a/examples/tutorial/src/2-core-component-setup/App.tsx +++ b/examples/tutorial/src/3-core-component-setup/App.tsx @@ -14,7 +14,7 @@ import { import 'stream-chat-react/dist/css/index.css'; import './layout.css'; -import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; +import { apiKey, tokenProvider, userId, userName } from '../2-client-setup/credentials'; const user: User = { id: userId, diff --git a/examples/tutorial/src/2-core-component-setup/layout.css b/examples/tutorial/src/3-core-component-setup/layout.css similarity index 52% rename from examples/tutorial/src/2-core-component-setup/layout.css rename to examples/tutorial/src/3-core-component-setup/layout.css index c3cf99687a..5fa14209f5 100644 --- a/examples/tutorial/src/2-core-component-setup/layout.css +++ b/examples/tutorial/src/3-core-component-setup/layout.css @@ -1,21 +1,21 @@ html, body, #root { - height: 100%; + height: 100%; } body { - margin: 0; + margin: 0; } #root { - display: flex; + display: flex; } .str-chat__channel-list { - width: 30%; + width: 30%; } .str-chat__channel { - width: 100%; + width: 100%; } .str-chat__thread { - width: 45%; -} \ No newline at end of file + width: 45%; +} diff --git a/examples/tutorial/src/2-core-component-setup/stream-chat.d.ts b/examples/tutorial/src/3-core-component-setup/stream-chat.d.ts similarity index 100% rename from examples/tutorial/src/2-core-component-setup/stream-chat.d.ts rename to examples/tutorial/src/3-core-component-setup/stream-chat.d.ts diff --git a/examples/tutorial/src/4-channel-list/App.tsx b/examples/tutorial/src/4-channel-list/App.tsx new file mode 100644 index 0000000000..f86d16843f --- /dev/null +++ b/examples/tutorial/src/4-channel-list/App.tsx @@ -0,0 +1,57 @@ +import type { ChannelFilters, ChannelOptions, ChannelSort, User } from 'stream-chat'; +import { + Channel, + ChannelHeader, + ChannelList, + Chat, + MessageComposer, + MessageList, + Thread, + useCreateChatClient, + Window, +} from 'stream-chat-react'; + +import 'stream-chat-react/dist/css/index.css'; +import './layout.css'; +import { apiKey, tokenProvider, userId, userName } from '../2-client-setup/credentials'; + +const user: User = { + id: userId, + name: userName, + image: `https://getstream.io/random_png/?name=${userName}`, +}; + +const sort: ChannelSort = { last_message_at: -1 }; +const filters: ChannelFilters = { + type: 'messaging', + members: { $in: [userId] }, +}; +const options: ChannelOptions = { + limit: 10, +}; + +const App = () => { + const client = useCreateChatClient({ + apiKey, + tokenOrProvider: tokenProvider, + userData: user, + }); + + if (!client) return
Setting up client & connection...
; + + return ( + + + + + + + + + + + + ); +}; + +export default App; diff --git a/examples/tutorial/src/4-channel-list/layout.css b/examples/tutorial/src/4-channel-list/layout.css new file mode 100644 index 0000000000..5fa14209f5 --- /dev/null +++ b/examples/tutorial/src/4-channel-list/layout.css @@ -0,0 +1,21 @@ +html, +body, +#root { + height: 100%; +} +body { + margin: 0; +} +#root { + display: flex; +} + +.str-chat__channel-list { + width: 30%; +} +.str-chat__channel { + width: 100%; +} +.str-chat__thread { + width: 45%; +} diff --git a/examples/tutorial/src/4-custom-ui-components/index.html b/examples/tutorial/src/4-custom-ui-components/index.html deleted file mode 100644 index 7877092389..0000000000 --- a/examples/tutorial/src/4-custom-ui-components/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + React + TS - - -
- - - diff --git a/examples/tutorial/src/4-custom-ui-components/layout.css b/examples/tutorial/src/4-custom-ui-components/layout.css deleted file mode 100644 index 2fd790e68c..0000000000 --- a/examples/tutorial/src/4-custom-ui-components/layout.css +++ /dev/null @@ -1,53 +0,0 @@ -@layer stream, stream-overrides; -@import 'stream-chat-react/dist/css/index.css' layer(stream); - -@layer stream-overrides { - .custom-theme { - /* Accent */ - --str-chat__accent-primary: #0d47a1; - - /* Message bubble colors */ - --str-chat__chat-bg-outgoing: #1e3a8a; - --str-chat__chat-bg-attachment-outgoing: #0d47a1; - --str-chat__chat-bg-incoming: #dbeafe; - --str-chat__chat-text-outgoing: #ffffff; - --str-chat__chat-reply-indicator-outgoing: #93c5fd; - - /* Links */ - --str-chat__text-link: #1e40af; - --str-chat__chat-text-link: #93c5fd; - - /* Panel backgrounds */ - --str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ - --str-chat__background-core-app: #c7dafc; /* message list background */ - - /* Focus ring */ - --str-chat__border-utility-focused: #1e40af; - - /* Radii */ - --str-chat__radius-max: 8px; - --str-chat__button-radius-full: 6px; - } -} - -html, -body, -#root { - height: 100%; -} -body { - margin: 0; -} -#root { - display: flex; -} - -.str-chat__channel-list { - width: 30%; -} -.str-chat__channel { - width: 100%; -} -.str-chat__thread { - width: 45%; -} diff --git a/examples/tutorial/src/4-custom-ui-components/main.tsx b/examples/tutorial/src/4-custom-ui-components/main.tsx deleted file mode 100644 index e17d50b103..0000000000 --- a/examples/tutorial/src/4-custom-ui-components/main.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import App from './App.tsx'; - -createRoot(document.getElementById('root')!).render( - - - , -); diff --git a/examples/tutorial/src/5-custom-attachment-type/index.html b/examples/tutorial/src/5-custom-attachment-type/index.html deleted file mode 100644 index 7877092389..0000000000 --- a/examples/tutorial/src/5-custom-attachment-type/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + React + TS - - -
- - - diff --git a/examples/tutorial/src/5-custom-attachment-type/layout.css b/examples/tutorial/src/5-custom-attachment-type/layout.css deleted file mode 100644 index 2fd790e68c..0000000000 --- a/examples/tutorial/src/5-custom-attachment-type/layout.css +++ /dev/null @@ -1,53 +0,0 @@ -@layer stream, stream-overrides; -@import 'stream-chat-react/dist/css/index.css' layer(stream); - -@layer stream-overrides { - .custom-theme { - /* Accent */ - --str-chat__accent-primary: #0d47a1; - - /* Message bubble colors */ - --str-chat__chat-bg-outgoing: #1e3a8a; - --str-chat__chat-bg-attachment-outgoing: #0d47a1; - --str-chat__chat-bg-incoming: #dbeafe; - --str-chat__chat-text-outgoing: #ffffff; - --str-chat__chat-reply-indicator-outgoing: #93c5fd; - - /* Links */ - --str-chat__text-link: #1e40af; - --str-chat__chat-text-link: #93c5fd; - - /* Panel backgrounds */ - --str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ - --str-chat__background-core-app: #c7dafc; /* message list background */ - - /* Focus ring */ - --str-chat__border-utility-focused: #1e40af; - - /* Radii */ - --str-chat__radius-max: 8px; - --str-chat__button-radius-full: 6px; - } -} - -html, -body, -#root { - height: 100%; -} -body { - margin: 0; -} -#root { - display: flex; -} - -.str-chat__channel-list { - width: 30%; -} -.str-chat__channel { - width: 100%; -} -.str-chat__thread { - width: 45%; -} diff --git a/examples/tutorial/src/5-custom-attachment-type/main.tsx b/examples/tutorial/src/5-custom-attachment-type/main.tsx deleted file mode 100644 index e17d50b103..0000000000 --- a/examples/tutorial/src/5-custom-attachment-type/main.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import App from './App.tsx'; - -createRoot(document.getElementById('root')!).render( - - - , -); diff --git a/examples/tutorial/src/3-channel-list/App.tsx b/examples/tutorial/src/5-theming/App.tsx similarity index 94% rename from examples/tutorial/src/3-channel-list/App.tsx rename to examples/tutorial/src/5-theming/App.tsx index 5d37369fdf..68fc6190b1 100644 --- a/examples/tutorial/src/3-channel-list/App.tsx +++ b/examples/tutorial/src/5-theming/App.tsx @@ -12,7 +12,7 @@ import { } from 'stream-chat-react'; import './layout.css'; -import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; +import { apiKey, tokenProvider, userId, userName } from '../2-client-setup/credentials'; const user: User = { id: userId, diff --git a/examples/tutorial/src/5-theming/layout.css b/examples/tutorial/src/5-theming/layout.css new file mode 100644 index 0000000000..cacf7577dc --- /dev/null +++ b/examples/tutorial/src/5-theming/layout.css @@ -0,0 +1,60 @@ +@layer stream; +@import 'stream-chat-react/dist/css/index.css' layer(stream); + +/* One deliberate deviation from the tutorial, which wraps these tokens in + `@layer stream-overrides { .custom-theme { ... } }`. The step browser + (src/App.tsx) renders every step in one document, and the steps before this + one import the SDK stylesheet *unlayered*. Unlayered CSS outranks every + @layer, so the tutorial's layered override would silently do nothing here. + Declaring it unlayered on `.str-chat.custom-theme` (0,2,0) beats the SDK's + own `.str-chat` (0,1,0) regardless of source order, and only matches the + steps that actually pass `theme="custom-theme"`. + In your own app, follow the tutorial and keep these in the layer. */ +.str-chat.custom-theme { + /* Accent color - used by mentions, read receipts, attachment actions, focus states, etc. */ + --str-chat__accent-primary: #0d47a1; + + /* Message bubble colors */ + --str-chat__chat-bg-outgoing: #1e3a8a; + --str-chat__chat-bg-attachment-outgoing: #0d47a1; + --str-chat__chat-bg-incoming: #dbeafe; + --str-chat__chat-text-outgoing: #ffffff; + --str-chat__chat-reply-indicator-outgoing: #93c5fd; + + /* Link colors (inside bubbles and elsewhere) */ + --str-chat__text-link: #1e40af; + --str-chat__chat-text-link: #93c5fd; + + /* Panel backgrounds */ + --str-chat__background-core-elevation-1: #dbeafe; /* channel list and surrounding panels */ + --str-chat__background-core-app: #c7dafc; /* message list background */ + + /* Focus ring */ + --str-chat__border-utility-focused: #1e40af; + + /* Radii - the SDK uses --radius-max / --button-radius-full for pill shapes */ + --str-chat__radius-max: 8px; + --str-chat__button-radius-full: 6px; +} + +html, +body, +#root { + height: 100%; +} +body { + margin: 0; +} +#root { + display: flex; +} + +.str-chat__channel-list { + width: 30%; +} +.str-chat__channel { + width: 100%; +} +.str-chat__thread { + width: 45%; +} diff --git a/examples/tutorial/src/4-custom-ui-components/App.tsx b/examples/tutorial/src/6-custom-ui-components/App.tsx similarity index 97% rename from examples/tutorial/src/4-custom-ui-components/App.tsx rename to examples/tutorial/src/6-custom-ui-components/App.tsx index 60566f2c27..064857d61b 100644 --- a/examples/tutorial/src/4-custom-ui-components/App.tsx +++ b/examples/tutorial/src/6-custom-ui-components/App.tsx @@ -17,7 +17,7 @@ import { } from 'stream-chat-react'; import './layout.css'; -import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; +import { apiKey, tokenProvider, userId, userName } from '../2-client-setup/credentials'; const user: User = { id: userId, @@ -148,7 +148,7 @@ const App = () => { diff --git a/examples/tutorial/src/6-custom-ui-components/layout.css b/examples/tutorial/src/6-custom-ui-components/layout.css new file mode 100644 index 0000000000..cacf7577dc --- /dev/null +++ b/examples/tutorial/src/6-custom-ui-components/layout.css @@ -0,0 +1,60 @@ +@layer stream; +@import 'stream-chat-react/dist/css/index.css' layer(stream); + +/* One deliberate deviation from the tutorial, which wraps these tokens in + `@layer stream-overrides { .custom-theme { ... } }`. The step browser + (src/App.tsx) renders every step in one document, and the steps before this + one import the SDK stylesheet *unlayered*. Unlayered CSS outranks every + @layer, so the tutorial's layered override would silently do nothing here. + Declaring it unlayered on `.str-chat.custom-theme` (0,2,0) beats the SDK's + own `.str-chat` (0,1,0) regardless of source order, and only matches the + steps that actually pass `theme="custom-theme"`. + In your own app, follow the tutorial and keep these in the layer. */ +.str-chat.custom-theme { + /* Accent color - used by mentions, read receipts, attachment actions, focus states, etc. */ + --str-chat__accent-primary: #0d47a1; + + /* Message bubble colors */ + --str-chat__chat-bg-outgoing: #1e3a8a; + --str-chat__chat-bg-attachment-outgoing: #0d47a1; + --str-chat__chat-bg-incoming: #dbeafe; + --str-chat__chat-text-outgoing: #ffffff; + --str-chat__chat-reply-indicator-outgoing: #93c5fd; + + /* Link colors (inside bubbles and elsewhere) */ + --str-chat__text-link: #1e40af; + --str-chat__chat-text-link: #93c5fd; + + /* Panel backgrounds */ + --str-chat__background-core-elevation-1: #dbeafe; /* channel list and surrounding panels */ + --str-chat__background-core-app: #c7dafc; /* message list background */ + + /* Focus ring */ + --str-chat__border-utility-focused: #1e40af; + + /* Radii - the SDK uses --radius-max / --button-radius-full for pill shapes */ + --str-chat__radius-max: 8px; + --str-chat__button-radius-full: 6px; +} + +html, +body, +#root { + height: 100%; +} +body { + margin: 0; +} +#root { + display: flex; +} + +.str-chat__channel-list { + width: 30%; +} +.str-chat__channel { + width: 100%; +} +.str-chat__thread { + width: 45%; +} diff --git a/examples/tutorial/src/6-emoji-picker/index.html b/examples/tutorial/src/6-emoji-picker/index.html deleted file mode 100644 index 7877092389..0000000000 --- a/examples/tutorial/src/6-emoji-picker/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + React + TS - - -
- - - diff --git a/examples/tutorial/src/6-emoji-picker/layout.css b/examples/tutorial/src/6-emoji-picker/layout.css deleted file mode 100644 index 2fd790e68c..0000000000 --- a/examples/tutorial/src/6-emoji-picker/layout.css +++ /dev/null @@ -1,53 +0,0 @@ -@layer stream, stream-overrides; -@import 'stream-chat-react/dist/css/index.css' layer(stream); - -@layer stream-overrides { - .custom-theme { - /* Accent */ - --str-chat__accent-primary: #0d47a1; - - /* Message bubble colors */ - --str-chat__chat-bg-outgoing: #1e3a8a; - --str-chat__chat-bg-attachment-outgoing: #0d47a1; - --str-chat__chat-bg-incoming: #dbeafe; - --str-chat__chat-text-outgoing: #ffffff; - --str-chat__chat-reply-indicator-outgoing: #93c5fd; - - /* Links */ - --str-chat__text-link: #1e40af; - --str-chat__chat-text-link: #93c5fd; - - /* Panel backgrounds */ - --str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ - --str-chat__background-core-app: #c7dafc; /* message list background */ - - /* Focus ring */ - --str-chat__border-utility-focused: #1e40af; - - /* Radii */ - --str-chat__radius-max: 8px; - --str-chat__button-radius-full: 6px; - } -} - -html, -body, -#root { - height: 100%; -} -body { - margin: 0; -} -#root { - display: flex; -} - -.str-chat__channel-list { - width: 30%; -} -.str-chat__channel { - width: 100%; -} -.str-chat__thread { - width: 45%; -} diff --git a/examples/tutorial/src/6-emoji-picker/main.tsx b/examples/tutorial/src/6-emoji-picker/main.tsx deleted file mode 100644 index e17d50b103..0000000000 --- a/examples/tutorial/src/6-emoji-picker/main.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import App from './App.tsx'; - -createRoot(document.getElementById('root')!).render( - - - , -); diff --git a/examples/tutorial/src/6-emoji-picker/App.tsx b/examples/tutorial/src/7-emoji-picker/App.tsx similarity index 96% rename from examples/tutorial/src/6-emoji-picker/App.tsx rename to examples/tutorial/src/7-emoji-picker/App.tsx index 5f339501be..aeb7c27003 100644 --- a/examples/tutorial/src/6-emoji-picker/App.tsx +++ b/examples/tutorial/src/7-emoji-picker/App.tsx @@ -18,7 +18,7 @@ import { init, SearchIndex } from 'emoji-mart'; import data from '@emoji-mart/data'; import './layout.css'; -import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; +import { apiKey, tokenProvider, userId, userName } from '../2-client-setup/credentials'; const user: User = { id: userId, diff --git a/examples/tutorial/src/7-emoji-picker/layout.css b/examples/tutorial/src/7-emoji-picker/layout.css new file mode 100644 index 0000000000..cacf7577dc --- /dev/null +++ b/examples/tutorial/src/7-emoji-picker/layout.css @@ -0,0 +1,60 @@ +@layer stream; +@import 'stream-chat-react/dist/css/index.css' layer(stream); + +/* One deliberate deviation from the tutorial, which wraps these tokens in + `@layer stream-overrides { .custom-theme { ... } }`. The step browser + (src/App.tsx) renders every step in one document, and the steps before this + one import the SDK stylesheet *unlayered*. Unlayered CSS outranks every + @layer, so the tutorial's layered override would silently do nothing here. + Declaring it unlayered on `.str-chat.custom-theme` (0,2,0) beats the SDK's + own `.str-chat` (0,1,0) regardless of source order, and only matches the + steps that actually pass `theme="custom-theme"`. + In your own app, follow the tutorial and keep these in the layer. */ +.str-chat.custom-theme { + /* Accent color - used by mentions, read receipts, attachment actions, focus states, etc. */ + --str-chat__accent-primary: #0d47a1; + + /* Message bubble colors */ + --str-chat__chat-bg-outgoing: #1e3a8a; + --str-chat__chat-bg-attachment-outgoing: #0d47a1; + --str-chat__chat-bg-incoming: #dbeafe; + --str-chat__chat-text-outgoing: #ffffff; + --str-chat__chat-reply-indicator-outgoing: #93c5fd; + + /* Link colors (inside bubbles and elsewhere) */ + --str-chat__text-link: #1e40af; + --str-chat__chat-text-link: #93c5fd; + + /* Panel backgrounds */ + --str-chat__background-core-elevation-1: #dbeafe; /* channel list and surrounding panels */ + --str-chat__background-core-app: #c7dafc; /* message list background */ + + /* Focus ring */ + --str-chat__border-utility-focused: #1e40af; + + /* Radii - the SDK uses --radius-max / --button-radius-full for pill shapes */ + --str-chat__radius-max: 8px; + --str-chat__button-radius-full: 6px; +} + +html, +body, +#root { + height: 100%; +} +body { + margin: 0; +} +#root { + display: flex; +} + +.str-chat__channel-list { + width: 30%; +} +.str-chat__channel { + width: 100%; +} +.str-chat__thread { + width: 45%; +} diff --git a/examples/tutorial/src/7-livestream/index.html b/examples/tutorial/src/7-livestream/index.html deleted file mode 100644 index 7877092389..0000000000 --- a/examples/tutorial/src/7-livestream/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + React + TS - - -
- - - diff --git a/examples/tutorial/src/7-livestream/layout.css b/examples/tutorial/src/7-livestream/layout.css deleted file mode 100644 index 2fd790e68c..0000000000 --- a/examples/tutorial/src/7-livestream/layout.css +++ /dev/null @@ -1,53 +0,0 @@ -@layer stream, stream-overrides; -@import 'stream-chat-react/dist/css/index.css' layer(stream); - -@layer stream-overrides { - .custom-theme { - /* Accent */ - --str-chat__accent-primary: #0d47a1; - - /* Message bubble colors */ - --str-chat__chat-bg-outgoing: #1e3a8a; - --str-chat__chat-bg-attachment-outgoing: #0d47a1; - --str-chat__chat-bg-incoming: #dbeafe; - --str-chat__chat-text-outgoing: #ffffff; - --str-chat__chat-reply-indicator-outgoing: #93c5fd; - - /* Links */ - --str-chat__text-link: #1e40af; - --str-chat__chat-text-link: #93c5fd; - - /* Panel backgrounds */ - --str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ - --str-chat__background-core-app: #c7dafc; /* message list background */ - - /* Focus ring */ - --str-chat__border-utility-focused: #1e40af; - - /* Radii */ - --str-chat__radius-max: 8px; - --str-chat__button-radius-full: 6px; - } -} - -html, -body, -#root { - height: 100%; -} -body { - margin: 0; -} -#root { - display: flex; -} - -.str-chat__channel-list { - width: 30%; -} -.str-chat__channel { - width: 100%; -} -.str-chat__thread { - width: 45%; -} diff --git a/examples/tutorial/src/7-livestream/main.tsx b/examples/tutorial/src/7-livestream/main.tsx deleted file mode 100644 index e17d50b103..0000000000 --- a/examples/tutorial/src/7-livestream/main.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import App from './App.tsx'; - -createRoot(document.getElementById('root')!).render( - - - , -); diff --git a/examples/tutorial/src/App.tsx b/examples/tutorial/src/App.tsx index 35a6b0092f..267f15f350 100644 --- a/examples/tutorial/src/App.tsx +++ b/examples/tutorial/src/App.tsx @@ -1,13 +1,14 @@ import { useEffect, useState } from 'react'; import type { ComponentType } from 'react'; -import ClientSetupStep from './1-client-setup/App'; -import CoreComponentSetupStep from './2-core-component-setup/App'; -import ChannelListStep from './3-channel-list/App'; -import CustomUiComponentsStep from './4-custom-ui-components/App'; -import CustomAttachmentTypeStep from './5-custom-attachment-type/App'; -import EmojiPickerStep from './6-emoji-picker/App'; -import LivestreamStep from './7-livestream/App'; +import ClientSetupStep from './2-client-setup/App'; +import CoreComponentSetupStep from './3-core-component-setup/App'; +import ChannelListStep from './4-channel-list/App'; +import ThemingStep from './5-theming/App'; +import CustomUiComponentsStep from './6-custom-ui-components/App'; +import EmojiPickerStep from './7-emoji-picker/App'; +import CustomAttachmentTypeStep from './optional-custom-attachment-type/App'; +import LivestreamStep from './optional-livestream/App'; import './tutorial-main.css'; type TutorialStep = { @@ -17,52 +18,64 @@ type TutorialStep = { Component: ComponentType; }; +// Titles and order mirror the published tutorial, so a step here maps 1:1 to a +// heading there: https://getstream.io/chat/sdk/react/tutorial/ +// +// The tutorial's Step 0 (environment) and Step 1 (project + credentials) have no +// runnable counterpart, so this browser starts at Step 2. const steps: TutorialStep[] = [ { id: 'client-setup', - title: '1. Client Setup', + title: 'Step 2. Connect the client', description: 'Connect the SDK to your Stream app and verify the chat client is ready.', Component: ClientSetupStep, }, { id: 'core-component-setup', - title: '2. Core Components', + title: 'Step 3. Get a working chat UI', description: 'Render the first complete chat UI with Channel, MessageList, MessageComposer, and Thread.', Component: CoreComponentSetupStep, }, { id: 'channel-list', - title: '3. Channel List', + title: 'Step 4. Add a channel list', description: 'Add channel navigation so the tutorial app feels like a real messaging experience.', Component: ChannelListStep, }, + { + id: 'theming', + title: 'Step 5. Theme it', + description: + 'Brand the default theme by overriding the SDK design tokens. Everything from here on carries the custom theme.', + Component: ThemingStep, + }, { id: 'custom-ui-components', - title: '4. Custom UI Components', + title: 'Step 6. Replace an SDK component', description: 'Use WithComponents to replace SDK-owned UI surfaces without rebuilding the whole app.', Component: CustomUiComponentsStep, }, { - id: 'custom-attachment-type', - title: '5. Custom Attachment Type', + id: 'emoji-picker', + title: 'Step 7. Emoji picker and autocomplete', description: - 'Render a branded product attachment while keeping the default attachment fallbacks.', - Component: CustomAttachmentTypeStep, + 'Wire the SDK EmojiPicker into MessageComposer with emoji-mart search support.', + Component: EmojiPickerStep, }, { - id: 'emoji-picker', - title: '6. Emoji Picker', + id: 'custom-attachment-type', + title: 'Optional. Custom attachment type', description: - 'Wire a custom EmojiPicker into MessageComposer with emoji-mart search support.', - Component: EmojiPickerStep, + 'Render a branded product attachment while keeping the default attachment fallbacks.', + Component: CustomAttachmentTypeStep, }, { id: 'livestream', - title: '7. Livestream', + title: 'Optional. Livestream-style chat', description: 'Switch the layout to a livestream-style experience with VirtualizedMessageList.', Component: LivestreamStep, @@ -137,7 +150,12 @@ const App = () => {
-
+ {/* The `step-` class lets tutorial-main.css target an individual + step's chrome. Only `step-client-setup` needs it today. */} +
diff --git a/examples/tutorial/src/5-custom-attachment-type/App.tsx b/examples/tutorial/src/optional-custom-attachment-type/App.tsx similarity index 94% rename from examples/tutorial/src/5-custom-attachment-type/App.tsx rename to examples/tutorial/src/optional-custom-attachment-type/App.tsx index 3cd1483ee8..4229326028 100644 --- a/examples/tutorial/src/5-custom-attachment-type/App.tsx +++ b/examples/tutorial/src/optional-custom-attachment-type/App.tsx @@ -19,7 +19,7 @@ import { } from 'stream-chat-react'; import './layout.css'; -import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; +import { apiKey, tokenProvider, userId, userName } from '../2-client-setup/credentials'; const user: User = { id: userId, @@ -93,7 +93,9 @@ const App = () => { await channel.watch(); const hasProductMessage = channel.state.messages.some((message) => - message.attachments?.some(isProductAttachment), + message.attachments?.some( + (attachment) => 'type' in attachment && attachment.type === 'product', + ), ); if (!hasProductMessage) { diff --git a/examples/tutorial/src/optional-custom-attachment-type/layout.css b/examples/tutorial/src/optional-custom-attachment-type/layout.css new file mode 100644 index 0000000000..cacf7577dc --- /dev/null +++ b/examples/tutorial/src/optional-custom-attachment-type/layout.css @@ -0,0 +1,60 @@ +@layer stream; +@import 'stream-chat-react/dist/css/index.css' layer(stream); + +/* One deliberate deviation from the tutorial, which wraps these tokens in + `@layer stream-overrides { .custom-theme { ... } }`. The step browser + (src/App.tsx) renders every step in one document, and the steps before this + one import the SDK stylesheet *unlayered*. Unlayered CSS outranks every + @layer, so the tutorial's layered override would silently do nothing here. + Declaring it unlayered on `.str-chat.custom-theme` (0,2,0) beats the SDK's + own `.str-chat` (0,1,0) regardless of source order, and only matches the + steps that actually pass `theme="custom-theme"`. + In your own app, follow the tutorial and keep these in the layer. */ +.str-chat.custom-theme { + /* Accent color - used by mentions, read receipts, attachment actions, focus states, etc. */ + --str-chat__accent-primary: #0d47a1; + + /* Message bubble colors */ + --str-chat__chat-bg-outgoing: #1e3a8a; + --str-chat__chat-bg-attachment-outgoing: #0d47a1; + --str-chat__chat-bg-incoming: #dbeafe; + --str-chat__chat-text-outgoing: #ffffff; + --str-chat__chat-reply-indicator-outgoing: #93c5fd; + + /* Link colors (inside bubbles and elsewhere) */ + --str-chat__text-link: #1e40af; + --str-chat__chat-text-link: #93c5fd; + + /* Panel backgrounds */ + --str-chat__background-core-elevation-1: #dbeafe; /* channel list and surrounding panels */ + --str-chat__background-core-app: #c7dafc; /* message list background */ + + /* Focus ring */ + --str-chat__border-utility-focused: #1e40af; + + /* Radii - the SDK uses --radius-max / --button-radius-full for pill shapes */ + --str-chat__radius-max: 8px; + --str-chat__button-radius-full: 6px; +} + +html, +body, +#root { + height: 100%; +} +body { + margin: 0; +} +#root { + display: flex; +} + +.str-chat__channel-list { + width: 30%; +} +.str-chat__channel { + width: 100%; +} +.str-chat__thread { + width: 45%; +} diff --git a/examples/tutorial/src/5-custom-attachment-type/stream-chat.d.ts b/examples/tutorial/src/optional-custom-attachment-type/stream-chat.d.ts similarity index 100% rename from examples/tutorial/src/5-custom-attachment-type/stream-chat.d.ts rename to examples/tutorial/src/optional-custom-attachment-type/stream-chat.d.ts diff --git a/examples/tutorial/src/7-livestream/App.tsx b/examples/tutorial/src/optional-livestream/App.tsx similarity index 95% rename from examples/tutorial/src/7-livestream/App.tsx rename to examples/tutorial/src/optional-livestream/App.tsx index 5ab17f6cbb..afa2633026 100644 --- a/examples/tutorial/src/7-livestream/App.tsx +++ b/examples/tutorial/src/optional-livestream/App.tsx @@ -11,7 +11,7 @@ import { } from 'stream-chat-react'; import './layout.css'; -import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; +import { apiKey, tokenProvider, userId, userName } from '../2-client-setup/credentials'; const user: User = { id: userId, diff --git a/examples/tutorial/src/optional-livestream/layout.css b/examples/tutorial/src/optional-livestream/layout.css new file mode 100644 index 0000000000..cacf7577dc --- /dev/null +++ b/examples/tutorial/src/optional-livestream/layout.css @@ -0,0 +1,60 @@ +@layer stream; +@import 'stream-chat-react/dist/css/index.css' layer(stream); + +/* One deliberate deviation from the tutorial, which wraps these tokens in + `@layer stream-overrides { .custom-theme { ... } }`. The step browser + (src/App.tsx) renders every step in one document, and the steps before this + one import the SDK stylesheet *unlayered*. Unlayered CSS outranks every + @layer, so the tutorial's layered override would silently do nothing here. + Declaring it unlayered on `.str-chat.custom-theme` (0,2,0) beats the SDK's + own `.str-chat` (0,1,0) regardless of source order, and only matches the + steps that actually pass `theme="custom-theme"`. + In your own app, follow the tutorial and keep these in the layer. */ +.str-chat.custom-theme { + /* Accent color - used by mentions, read receipts, attachment actions, focus states, etc. */ + --str-chat__accent-primary: #0d47a1; + + /* Message bubble colors */ + --str-chat__chat-bg-outgoing: #1e3a8a; + --str-chat__chat-bg-attachment-outgoing: #0d47a1; + --str-chat__chat-bg-incoming: #dbeafe; + --str-chat__chat-text-outgoing: #ffffff; + --str-chat__chat-reply-indicator-outgoing: #93c5fd; + + /* Link colors (inside bubbles and elsewhere) */ + --str-chat__text-link: #1e40af; + --str-chat__chat-text-link: #93c5fd; + + /* Panel backgrounds */ + --str-chat__background-core-elevation-1: #dbeafe; /* channel list and surrounding panels */ + --str-chat__background-core-app: #c7dafc; /* message list background */ + + /* Focus ring */ + --str-chat__border-utility-focused: #1e40af; + + /* Radii - the SDK uses --radius-max / --button-radius-full for pill shapes */ + --str-chat__radius-max: 8px; + --str-chat__button-radius-full: 6px; +} + +html, +body, +#root { + height: 100%; +} +body { + margin: 0; +} +#root { + display: flex; +} + +.str-chat__channel-list { + width: 30%; +} +.str-chat__channel { + width: 100%; +} +.str-chat__thread { + width: 45%; +} diff --git a/examples/tutorial/src/tutorial-main.css b/examples/tutorial/src/tutorial-main.css index c6076e0fc9..7b0397a277 100644 --- a/examples/tutorial/src/tutorial-main.css +++ b/examples/tutorial/src/tutorial-main.css @@ -1,7 +1,37 @@ +/* Host document rules. Every step's layout.css sets these too, because the + tutorial has the reader write them - but the chrome must not depend on a + step's stylesheet for its own layout. Declared here so the browser stands on + its own if steps are ever loaded lazily or in isolation. */ +html, +body, +#root { + height: 100%; +} +body { + margin: 0; +} +#root { + display: flex; +} + +/* Chrome only - deliberately NOT `.tutorial-browser *`, so the SDK's own + box-sizing is left alone. These panes are sized in viewport units *and* + padded, so with the default content-box the padding is added on top of 100vh + and pushes the chat UI (and its composer) below the fold. */ +.tutorial-browser, +.tutorial-browser__sidebar, +.tutorial-browser__main, +.tutorial-browser__header, +.tutorial-browser__preview-card, +.tutorial-browser__step-button { + box-sizing: border-box; +} + .tutorial-browser { - min-height: 100vh; + height: 100vh; width: 100%; display: flex; + overflow: hidden; background: linear-gradient(180deg, #eff5ff 0%, #f7fafc 32%, #eef7f6 100%); } @@ -11,10 +41,7 @@ background: rgba(255, 255, 255, 0.82); backdrop-filter: blur(16px); padding: 24px 20px; - position: sticky; - top: 0; - align-self: start; - height: 100vh; + height: 100%; overflow-y: auto; } @@ -84,7 +111,11 @@ flex-direction: column; padding: 20px; gap: 16px; - min-height: 100vh; + /* Exactly the viewport, not "at least" - the preview card below flexes into + whatever is left after the header, instead of overflowing the window. */ + height: 100%; + min-height: 0; + overflow: hidden; } .tutorial-browser__header { @@ -137,6 +168,14 @@ overflow: hidden; } +/* Step 2 renders bare text (`Chat with client is ready!`) with no + chat chrome, so it lands in the card's 28px corner arc and the first glyph + gets clipped. The other steps fill the corners with the channel header and + composer bars, which round cleanly, so they stay flush. */ +.tutorial-browser__step-shell.step-client-setup { + padding: 24px 28px; +} + .tutorial-browser__step-shell > * { flex: 1 1 auto; min-width: 0; diff --git a/examples/tutorial/vite.config.ts b/examples/tutorial/vite.config.ts index 0466183af6..d9e728778b 100644 --- a/examples/tutorial/vite.config.ts +++ b/examples/tutorial/vite.config.ts @@ -3,4 +3,11 @@ import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], + // `stream-chat-react` is consumed as a workspace dependency, so Vite serves + // its built output from outside this app's root and resolves that copy's + // `react` import separately from the app's. Without deduping, the SDK and the + // app end up on two React instances and every hook call throws. + resolve: { + dedupe: ['react', 'react-dom'], + }, }); From 972b68c6d08a89ec8667ad2dd13c7aa927f001a0 Mon Sep 17 00:00:00 2001 From: Anton Arnautov <43254280+arnautov-anton@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:25:02 +0200 Subject: [PATCH 2/9] feat: add icons to ComponentContext (#3246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🎯 Goal Adds ability to override icons through the component context. Ref: https://github.com/GetStream/stream-chat-react-native/pull/3731 ## Summary by CodeRabbit * **New Features** * Added `useComponentContextIcons`, enabling centralized, context-based icon customization (with SDK defaults) across messages, attachments, dialogs, forms, media, polls, reactions, and channel views. * **Bug Fixes** * Improved icon override merging so nested `icons` customizations are preserved and combined correctly instead of being replaced. * **Documentation** * Expanded deprecation guidance for select icon customization props in favor of `ComponentContext` icon slots. * **Tests** * Updated test mocks to support component-context icon resolution. --- src/components/Attachment/Geolocation.tsx | 8 +- src/components/Attachment/Giphy.tsx | 17 +-- .../Attachment/LinkPreview/Card.tsx | 3 +- .../Attachment/LinkPreview/CardAudio.tsx | 36 ++++--- src/components/Attachment/ModalGallery.tsx | 8 +- .../Attachment/UnsupportedAttachment.tsx | 5 +- .../Attachment/VisibilityDisclaimer.tsx | 5 +- .../Attachment/__tests__/Giphy.test.tsx | 34 +++--- .../Attachment/components/DownloadButton.tsx | 5 +- src/components/Avatar/Avatar.tsx | 14 ++- src/components/Badge/Badge.tsx | 15 +-- src/components/Badge/MediaBadge.tsx | 16 +-- src/components/BaseImage/ImagePlaceholder.tsx | 4 +- src/components/Button/PlayButton.tsx | 5 +- .../ChannelListItemActionButtons.defaults.tsx | 22 ++-- .../ChannelListItem/ChannelListItemUI.tsx | 3 +- src/components/ChatView/ChatView.tsx | 9 +- src/components/Dialog/components/Callout.tsx | 8 +- .../Dialog/components/ContextMenu.tsx | 74 +++++++------ src/components/Dialog/components/Prompt.tsx | 8 +- src/components/Dialog/components/Viewer.tsx | 8 +- .../EmptyStateIndicator.tsx | 3 +- src/components/Form/NumericInput.tsx | 4 +- src/components/Form/TextInput.tsx | 3 +- src/components/Gallery/GalleryHeader.tsx | 4 +- src/components/Gallery/GalleryUI.tsx | 9 +- src/components/Icons/index.ts | 1 + src/components/Icons/slots.ts | 101 ++++++++++++++++++ src/components/Loading/LoadingIndicator.tsx | 12 ++- .../Location/ShareLocationDialog.tsx | 7 +- .../AudioRecorderRecordingControls.tsx | 10 +- .../AudioRecordingButtonWithNotification.tsx | 3 +- .../AudioRecorder/AudioRecordingPlayback.tsx | 5 +- .../AudioRecorder/AudioRecordingPreview.tsx | 5 +- .../__tests__/AudioRecordingPreview.test.tsx | 13 ++- .../MessageAlsoSentInChannelIndicator.tsx | 4 +- .../Message/MessageDeletedBubble.tsx | 4 +- src/components/Message/MessageStatus.tsx | 3 +- .../Message/MessageTranslationIndicator.tsx | 4 +- src/components/Message/PinIndicator.tsx | 8 +- .../Message/ReminderNotification.tsx | 5 +- .../MessageActions/DownloadSubmenu.tsx | 10 +- .../MessageActions.defaults.tsx | 43 ++++---- .../MessageActions/RemindMeSubmenu.tsx | 10 +- .../MessageBounce/MessageBouncePrompt.tsx | 4 +- .../AudioAttachmentPreview.tsx | 5 +- .../FileAttachmentPreview.tsx | 5 +- .../GeolocationPreview.tsx | 16 +-- .../MediaAttachmentPreview.tsx | 9 +- .../UnsupportedAttachmentPreview.tsx | 5 +- .../AttachmentSelector/AttachmentSelector.tsx | 13 ++- .../AttachmentSelector/CommandsMenu.tsx | 40 +++---- .../__tests__/CommandsMenu.test.tsx | 13 ++- .../MessageComposer/CommandChip.tsx | 9 +- .../MessageComposer/LinkPreviewList.tsx | 3 +- .../MessageComposerActions.tsx | 4 +- .../MessageComposer/QuotedMessagePreview.tsx | 69 ++++++++++-- .../RemoveAttachmentPreviewButton.tsx | 5 +- src/components/MessageComposer/SendButton.tsx | 5 +- .../MessageComposer/SendToChannelCheckbox.tsx | 5 +- .../MessageComposer/WithDragAndDropUpload.tsx | 8 +- .../__tests__/CommandChip.test.tsx | 29 ++--- .../ScrollToLatestMessageButton.tsx | 3 +- .../UnreadMessagesNotification.tsx | 9 +- .../MessageList/UnreadMessagesSeparator.tsx | 9 +- .../Modal/CloseButtonOnModalOverlay.tsx | 25 +++-- src/components/Notifications/Notification.tsx | 30 +++--- .../PollResults/PollOptionWithVotesHeader.tsx | 9 +- .../PollCreationDialog/OptionFieldSet.tsx | 30 +++--- .../PollCreationDialogControls.tsx | 9 +- .../PollOptionReorderHandle.tsx | 5 +- .../Reactions/MessageReactionsDetail.tsx | 3 +- src/components/Reactions/ReactionSelector.tsx | 3 +- .../Reactions/ReactionSelectorWithButton.tsx | 16 ++- .../SummarizedMessagePreview.tsx | 77 ++++++------- .../MentionItem/BroadcastMentionItem.tsx | 5 +- .../SuggestionList/MentionItem/RoleItem.tsx | 5 +- .../MentionItem/UserGroupItem.tsx | 4 +- .../__tests__/CommandItem.test.tsx | 15 ++- src/components/Thread/ThreadHeader.tsx | 4 +- .../ThreadList/ThreadListEmptyPlaceholder.tsx | 5 +- .../ThreadListUnseenThreadsBanner.tsx | 9 +- src/components/VideoPlayer/VideoThumbnail.tsx | 5 +- src/context/ComponentContext.tsx | 3 + src/context/WithComponents.tsx | 10 +- src/context/index.ts | 1 + src/context/useComponentContextIcons.ts | 33 ++++++ src/plugins/ChannelDetail/ChannelDetail.tsx | 43 ++++---- .../ChannelDetail/ChannelDetailEmptyList.tsx | 17 +-- .../ChannelDetailSearchInput.tsx | 5 +- .../SectionNavigatorHeader.tsx | 7 +- .../ChannelFilesEmptyList.tsx | 5 +- .../__tests__/ChannelFilesView.test.tsx | 6 ++ .../ChannelManagementActions.defaults.tsx | 53 +++++---- .../ChannelManagementView.tsx | 5 +- .../ChannelMediaEmptyList.tsx | 5 +- .../ChannelMediaView/ChannelMediaView.tsx | 9 +- .../__tests__/ChannelMediaView.test.tsx | 3 + .../ChannelMemberActions.defaults.tsx | 51 +++++---- .../__tests__/ChannelMemberDetail.test.tsx | 3 + .../ChannelMembersAddView.tsx | 11 +- .../ChannelMembersBrowseView.tsx | 3 +- .../ChannelMembersHeaderActions.defaults.tsx | 8 +- .../__tests__/ChannelMembersAddView.test.tsx | 5 + .../ChannelMembersBrowseView.test.tsx | 4 + .../__tests__/ChannelMembersView.test.tsx | 3 + .../PinnedMessagesEmptyList.tsx | 5 +- .../__tests__/PinnedMessagesView.test.tsx | 5 + ...ChannelManagementActions.defaults.test.tsx | 46 ++++---- .../__tests__/ChannelManagementView.test.tsx | 34 +++--- src/plugins/Emojis/EmojiPicker.tsx | 17 ++- 111 files changed, 987 insertions(+), 518 deletions(-) create mode 100644 src/components/Icons/slots.ts create mode 100644 src/context/useComponentContextIcons.ts diff --git a/src/components/Attachment/Geolocation.tsx b/src/components/Attachment/Geolocation.tsx index e997bb2592..a4a80483bf 100644 --- a/src/components/Attachment/Geolocation.tsx +++ b/src/components/Attachment/Geolocation.tsx @@ -3,9 +3,12 @@ import { useEffect } from 'react'; import { useRef, useState } from 'react'; import React from 'react'; import type { Coords, SharedLocationResponse } from 'stream-chat'; -import { useChatContext, useTranslationContext } from '../../context'; +import { + useChatContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; import { ExternalLinkIcon } from './icons'; -import { IconLocation } from '../Icons'; import { Button } from '../Button'; export type GeolocationMapProps = Coords; @@ -102,6 +105,7 @@ const DefaultGeolocationAttachmentMapPlaceholder = ({ location, }: GeolocationAttachmentMapPlaceholderProps) => { const { t } = useTranslationContext(); + const { IconLocation } = useComponentContextIcons(); return (
{ ); }; -const GiphyBadge = () => ( -
- - Giphy -
-); +const GiphyBadge = () => { + const { IconGiphy } = useComponentContextIcons(); + return ( +
+ + Giphy +
+ ); +}; diff --git a/src/components/Attachment/LinkPreview/Card.tsx b/src/components/Attachment/LinkPreview/Card.tsx index 365a8088d0..6c473eb03b 100644 --- a/src/components/Attachment/LinkPreview/Card.tsx +++ b/src/components/Attachment/LinkPreview/Card.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { BaseImage } from '../../BaseImage'; import { SafeAnchor } from '../../SafeAnchor'; import { useChannelStateContext } from '../../../context/ChannelStateContext'; +import { useComponentContextIcons } from '../../../context'; import type { Attachment } from 'stream-chat'; import type { RenderAttachmentProps } from '../utils'; import type { Dimensions } from '../../../types/types'; -import { IconLink } from '../../Icons'; import { UnableToRenderCard } from './UnableToRenderCard'; import clsx from 'clsx'; @@ -64,6 +64,7 @@ type CardContentProps = RenderAttachmentProps['attachment']; const CardContent = (props: CardContentProps) => { const { og_scrape_url, text, title, title_link } = props; const url = title_link || og_scrape_url; + const { IconLink } = useComponentContextIcons(); return (
diff --git a/src/components/Attachment/LinkPreview/CardAudio.tsx b/src/components/Attachment/LinkPreview/CardAudio.tsx index 538edbe448..1d8cf6476b 100644 --- a/src/components/Attachment/LinkPreview/CardAudio.tsx +++ b/src/components/Attachment/LinkPreview/CardAudio.tsx @@ -1,10 +1,9 @@ import { type AudioPlayerState, ProgressBar, useAudioPlayer } from '../../AudioPlayback'; -import { useMessageContext } from '../../../context'; +import { useComponentContextIcons, useMessageContext } from '../../../context'; import { useStateStore } from '../../../store'; import { PlayButton } from '../../Button'; import type { AudioProps } from '../Audio'; import React from 'react'; -import { IconLink } from '../../Icons'; import { SafeAnchor } from '../../SafeAnchor'; import type { CardProps } from './Card'; @@ -21,22 +20,25 @@ const SourceLink = ({ author_name, showUrl, url, -}: Pick & { url: string; showUrl?: boolean }) => ( -
- - & { url: string; showUrl?: boolean }) => { + const { IconLink } = useComponentContextIcons(); + return ( +
- {showUrl ? url : author_name || getHostFromURL(url)} - -
-); + + + {showUrl ? url : author_name || getHostFromURL(url)} + +
+ ); +}; const audioPlayerStateSelector = (state: AudioPlayerState) => ({ durationSeconds: state.durationSeconds, diff --git a/src/components/Attachment/ModalGallery.tsx b/src/components/Attachment/ModalGallery.tsx index 3557c68be7..05b8ff7417 100644 --- a/src/components/Attachment/ModalGallery.tsx +++ b/src/components/Attachment/ModalGallery.tsx @@ -7,8 +7,11 @@ import { BaseImage as DefaultBaseImage } from '../BaseImage'; import { Gallery as DefaultGallery, GalleryUI } from '../Gallery'; import { LoadingIndicator } from '../Loading'; import { GlobalModal, type ModalCloseSource } from '../Modal'; -import { useComponentContext, useTranslationContext } from '../../context'; -import { IconRetry } from '../Icons'; +import { + useComponentContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; import { VideoThumbnail } from '../VideoPlayer/VideoThumbnail'; const MAX_VISIBLE_THUMBNAILS = 4; @@ -144,6 +147,7 @@ const ThumbnailButton = ({ showOverlay, }: ThumbnailButtonProps) => { const { t } = useTranslationContext(); + const { IconRetry } = useComponentContextIcons(); const imageUrl = item.imageUrl; const [isLoadFailed, setIsLoadFailed] = useState(false); const [isImageLoading, setIsImageLoading] = useState(Boolean(imageUrl)); diff --git a/src/components/Attachment/UnsupportedAttachment.tsx b/src/components/Attachment/UnsupportedAttachment.tsx index b91fcb6d17..b9a67645d6 100644 --- a/src/components/Attachment/UnsupportedAttachment.tsx +++ b/src/components/Attachment/UnsupportedAttachment.tsx @@ -1,13 +1,14 @@ import React from 'react'; import type { Attachment } from 'stream-chat'; -import { useTranslationContext } from '../../context'; -import { IconUnsupportedAttachment } from '../Icons'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; export type UnsupportedAttachmentProps = { attachment: Attachment; }; export const UnsupportedAttachment = () => { + const { IconUnsupportedAttachment } = useComponentContextIcons(); + const { t } = useTranslationContext('UnsupportedAttachment'); return (
{ + const { IconEyeFill } = useComponentContextIcons(); + const { t } = useTranslationContext(); return (
diff --git a/src/components/Attachment/__tests__/Giphy.test.tsx b/src/components/Attachment/__tests__/Giphy.test.tsx index d6dee86402..16f3d1777f 100644 --- a/src/components/Attachment/__tests__/Giphy.test.tsx +++ b/src/components/Attachment/__tests__/Giphy.test.tsx @@ -11,21 +11,25 @@ const { channelStateMock } = vi.hoisted(() => ({ }, })); -vi.mock('../../../context', () => ({ - useChannelStateContext: () => channelStateMock, - useComponentContext: () => ({}), - useTranslationContext: () => ({ - t: (key, params) => - Object.keys(params ?? {}).reduce( - (acc, paramKey) => - acc.replace( - new RegExp(`\\{\\{\\s${paramKey}\\s\\}\\}`, 'g'), - String(params?.[paramKey]), - ), - key.replace(/^aria\//, ''), - ), - }), -})); +vi.mock('../../../context', async (importOriginal) => { + const actual = await importOriginal(); + return { + useChannelStateContext: () => channelStateMock, + useComponentContext: () => ({}), + useComponentContextIcons: actual.useComponentContextIcons, + useTranslationContext: () => ({ + t: (key, params) => + Object.keys(params ?? {}).reduce( + (acc, paramKey) => + acc.replace( + new RegExp(`\\{\\{\\s${paramKey}\\s\\}\\}`, 'g'), + String(params?.[paramKey]), + ), + key.replace(/^aria\//, ''), + ), + }), + }; +}); describe('Giphy accessible name', () => { it('uses the giphy title as the image accessible name', () => { diff --git a/src/components/Attachment/components/DownloadButton.tsx b/src/components/Attachment/components/DownloadButton.tsx index 73c150dff0..8be36b5346 100644 --- a/src/components/Attachment/components/DownloadButton.tsx +++ b/src/components/Attachment/components/DownloadButton.tsx @@ -2,8 +2,7 @@ import React from 'react'; import clsx from 'clsx'; import { sanitizeUrl } from '@braintree/sanitize-url'; -import { useTranslationContext } from '../../../context'; -import { IconDownload } from '../../Icons'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; export type DownloadButtonProps = { /** Attachment asset URL (e.g. `asset_url`). */ @@ -25,6 +24,8 @@ export const DownloadButton = ({ suggestedFileName, tooltipTitle, }: DownloadButtonProps) => { + const { IconDownload } = useComponentContextIcons(); + const { t } = useTranslationContext(); if (!assetUrl) return null; const href = sanitizeUrl(assetUrl); diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index 79149a9099..34ac8eb671 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -6,10 +6,14 @@ import React, { useMemo, useState, } from 'react'; -import { IconUser } from '../Icons'; +import { useComponentContextIcons } from '../../context'; export type AvatarProps = { - /** Custom icon rendered when there is no image and no initials */ + /** + * Custom icon rendered when there is no image and no initials. + * @deprecated Use the `icons.IconUser` slot on `ComponentContext` (via ``) instead. + * Passing this prop still wins over the context slot for backwards compatibility. + */ FallbackIcon?: ComponentType>; /** URL of the avatar image */ imageUrl?: string; @@ -51,7 +55,7 @@ const getInitials = (name?: string) => { */ export const Avatar = ({ className, - FallbackIcon = IconUser, + FallbackIcon, imageUrl, initials: customInitials, isOnline, @@ -59,6 +63,8 @@ export const Avatar = ({ userName, ...rest }: AvatarProps) => { + const { IconUser } = useComponentContextIcons(); + const ResolvedFallbackIcon = FallbackIcon ?? IconUser; const [error, setError] = useState(false); useEffect(() => () => setError(false), [imageUrl]); @@ -113,7 +119,7 @@ export const Avatar = ({ {sizeAwareInitials}
)} - {!sizeAwareInitials.length && } + {!sizeAwareInitials.length && } )}
diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index 66fe564f76..3a2c24869c 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import React, { type ComponentProps } from 'react'; -import { IconExclamationMarkFill } from '../Icons'; +import { useComponentContextIcons } from '../../context'; export type BadgeVariant = | 'default' @@ -47,8 +47,11 @@ export const ErrorBadge = ({ className, size = 'sm', ...rest -}: Omit) => ( - - - -); +}: Omit) => { + const { IconExclamationMarkFill } = useComponentContextIcons(); + return ( + + + + ); +}; diff --git a/src/components/Badge/MediaBadge.tsx b/src/components/Badge/MediaBadge.tsx index 05129ccbdc..bff83df739 100644 --- a/src/components/Badge/MediaBadge.tsx +++ b/src/components/Badge/MediaBadge.tsx @@ -1,4 +1,4 @@ -import { IconMicrophoneSolid, IconVideoFill } from '../Icons'; +import { useComponentContextIcons } from '../../context'; import React, { type ComponentType } from 'react'; import type { LocalAttachment } from 'stream-chat'; import clsx from 'clsx'; @@ -10,13 +10,15 @@ export type MediaBadgeProps = { variant: 'video' | 'voice-recording' | string; }; -const MediaBadgeVariantToIcon: Record = { - video: IconVideoFill, - voiceRecording: IconMicrophoneSolid, -}; - export const MediaBadge = ({ attachment, variant }: MediaBadgeProps) => { - const Icon = MediaBadgeVariantToIcon[variant]; + const { IconMicrophoneSolid, IconVideoFill } = useComponentContextIcons(); + + const mediaBadgeVariantToIcon: Record = { + video: IconVideoFill, + voiceRecording: IconMicrophoneSolid, + }; + + const Icon = mediaBadgeVariantToIcon[variant]; return (
{ + const { IconImage } = useComponentContextIcons(); + const { t } = useTranslationContext(); return (
& { isPlaying: boolean; }; export const PlayButton = ({ className, isPlaying, ...props }: PlayButtonProps) => { + const { IconPauseFill, IconPlayFill } = useComponentContextIcons(); + const { t } = useTranslationContext(); return ( -); +}: BaseContextMenuButtonProps) => { + const { IconChevronRight } = useComponentContextIcons(); + const ResolvedSubmenuIcon = SubmenuIcon ?? IconChevronRight; + return ( + + ); +}; export type UserContextMenuButtonProps = Pick & ComponentProps<'button'>; @@ -671,6 +682,7 @@ export function ContextMenuContent({ ...props }: ContextMenuContentProps) { const { t } = useTranslationContext(); + const { IconChevronLeft } = useComponentContextIcons(); const resolvedBackLabel = backLabel ?? t('Back'); const { ['aria-describedby']: rootAriaDescribedBy, diff --git a/src/components/Dialog/components/Prompt.tsx b/src/components/Dialog/components/Prompt.tsx index 87eb2c576c..ca523d4bd8 100644 --- a/src/components/Dialog/components/Prompt.tsx +++ b/src/components/Dialog/components/Prompt.tsx @@ -1,8 +1,11 @@ import React, { type ComponentProps, type PropsWithChildren } from 'react'; import clsx from 'clsx'; import { Button, type ButtonProps } from '../../Button'; -import { IconArrowLeft, IconXmark } from '../../Icons'; -import { useModalContext, useTranslationContext } from '../../../context'; +import { + useComponentContextIcons, + useModalContext, + useTranslationContext, +} from '../../../context'; import { useAriaIdentifiers } from '../../../a11y/hooks/useAriaIdentifiers'; const PromptRoot = ({ children, className, ...props }: ComponentProps<'div'>) => ( @@ -36,6 +39,7 @@ const PromptHeader = ({ }: PromptHeaderProps) => { const { t } = useTranslationContext(); const { dialogId } = useModalContext(); + const { IconArrowLeft, IconXmark } = useComponentContextIcons(); const { descriptionId: derivedDescriptionId, titleId: derivedTitleId } = useAriaIdentifiers(dialogId); const resolvedTitleId = titleId ?? derivedTitleId; diff --git a/src/components/Dialog/components/Viewer.tsx b/src/components/Dialog/components/Viewer.tsx index 70b5ec903e..0948663b23 100644 --- a/src/components/Dialog/components/Viewer.tsx +++ b/src/components/Dialog/components/Viewer.tsx @@ -1,8 +1,11 @@ import React, { type ComponentProps, type PropsWithChildren } from 'react'; import clsx from 'clsx'; import { Button, type ButtonProps } from '../../Button'; -import { IconArrowLeft, IconXmark } from '../../Icons'; -import { useModalContext, useTranslationContext } from '../../../context'; +import { + useComponentContextIcons, + useModalContext, + useTranslationContext, +} from '../../../context'; import { useAriaIdentifiers } from '../../../a11y/hooks/useAriaIdentifiers'; const ViewerRoot = ({ children, className, ...props }: ComponentProps<'div'>) => ( @@ -32,6 +35,7 @@ const ViewerHeader = ({ }: ViewerHeaderProps) => { const { t } = useTranslationContext(); const { dialogId } = useModalContext(); + const { IconArrowLeft, IconXmark } = useComponentContextIcons(); const { descriptionId: derivedDescriptionId, titleId: derivedTitleId } = useAriaIdentifiers(dialogId); const resolvedTitleId = titleId ?? derivedTitleId; diff --git a/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx b/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx index 1f18a4faa3..d9fb89c9f1 100644 --- a/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx +++ b/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx @@ -1,7 +1,7 @@ import React from 'react'; +import { useComponentContextIcons } from '../../context'; import { useTranslationContext } from '../../context/TranslationContext'; -import { IconMessageBubble, IconMessageBubbles } from '../Icons'; export type EmptyStateIndicatorProps = { /** List Type: channel | message */ @@ -13,6 +13,7 @@ const UnMemoizedEmptyStateIndicator = (props: EmptyStateIndicatorProps) => { const { listType, messageText } = props; const { t } = useTranslationContext('EmptyStateIndicator'); + const { IconMessageBubble, IconMessageBubbles } = useComponentContextIcons(); if (listType === 'thread') return null; diff --git a/src/components/Form/NumericInput.tsx b/src/components/Form/NumericInput.tsx index e58e7f8b59..710aadb8b7 100644 --- a/src/components/Form/NumericInput.tsx +++ b/src/components/Form/NumericInput.tsx @@ -1,9 +1,8 @@ import clsx from 'clsx'; import React, { forwardRef, useCallback } from 'react'; import type { ChangeEvent, ComponentProps, KeyboardEvent } from 'react'; -import { useTranslationContext } from '../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; import { useStableId } from '../UtilityComponents/useStableId'; -import { IconMinus, IconPlusSmall } from '../Icons'; import { Button } from '../Button'; export type NumericInputProps = Omit< @@ -52,6 +51,7 @@ export const NumericInput = forwardRef( const generatedId = useStableId(); const id = idProp ?? generatedId; const { t } = useTranslationContext(); + const { IconMinus, IconPlusSmall } = useComponentContextIcons(); const num = parseNumeric(value); const minDef = min ?? -Infinity; diff --git a/src/components/Form/TextInput.tsx b/src/components/Form/TextInput.tsx index cb6479f67b..32695c2aef 100644 --- a/src/components/Form/TextInput.tsx +++ b/src/components/Form/TextInput.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx'; import React, { forwardRef } from 'react'; import type { ComponentProps, ReactNode } from 'react'; import { useStableId } from '../UtilityComponents/useStableId'; -import { IconCheckmark, IconExclamationMark } from '../Icons'; +import { useComponentContextIcons } from '../../context'; export type TextInputVariant = 'outline' | 'ghost'; @@ -79,6 +79,7 @@ type TextInputFieldMessageProps = }; const TextInputFieldMessage = (props: TextInputFieldMessageProps) => { + const { IconCheckmark, IconExclamationMark } = useComponentContextIcons(); if (props.kind === 'neutral') { return (
{ + const { IconArrowDownCircle, IconXmark } = useComponentContextIcons(); + const { t } = useTranslationContext(); const { MessageTimestamp = DefaultMessageTimestamp } = useComponentContext('GalleryUI'); const { isMyMessage, message } = useMessageContext('GalleryUI'); diff --git a/src/components/Gallery/GalleryUI.tsx b/src/components/Gallery/GalleryUI.tsx index 4112fd4152..76a3e146cf 100644 --- a/src/components/Gallery/GalleryUI.tsx +++ b/src/components/Gallery/GalleryUI.tsx @@ -4,8 +4,11 @@ import { BaseImage } from '../BaseImage'; import { GalleryHeader } from './GalleryHeader'; import { useGalleryContext } from './GalleryContext'; import { Button, type ButtonProps } from '../Button'; -import { IconChevronLeft, IconChevronRight } from '../Icons'; -import { ModalContext, useTranslationContext } from '../../context'; +import { + ModalContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; import { VideoPlayer } from '../VideoPlayer'; import { VideoThumbnail } from '../VideoPlayer/VideoThumbnail'; @@ -15,6 +18,8 @@ const SWIPE_THRESHOLD = 50; const TRANSITION_DURATION = 300; export const GalleryUI = () => { + const { IconChevronLeft, IconChevronRight } = useComponentContextIcons(); + const { t } = useTranslationContext(); const { closeOnBackgroundClick, diff --git a/src/components/Icons/index.ts b/src/components/Icons/index.ts index 01f3a3b082..47ee77c421 100644 --- a/src/components/Icons/index.ts +++ b/src/components/Icons/index.ts @@ -1,2 +1,3 @@ export { createIcon } from './createIcon'; export * from './icons'; +export * from './slots'; diff --git a/src/components/Icons/slots.ts b/src/components/Icons/slots.ts new file mode 100644 index 0000000000..f05d1f92c1 --- /dev/null +++ b/src/components/Icons/slots.ts @@ -0,0 +1,101 @@ +import type { ComponentPropsWithoutRef, ComponentType } from 'react'; + +export type IconComponent = ComponentType>; + +/** + * Names of icons that can be overridden via `ComponentContext.icons`. Enumerated from icons + * actually imported from `../components/Icons` anywhere in `src/`. Overrides are deep-merged + * with sibling entries by `WithComponents`, so a consumer can rebrand a single icon without + * clearing the others. + */ +export type IconSlots = Partial< + Record< + | 'IconArchive' + | 'IconArrowDown' + | 'IconArrowDownCircle' + | 'IconArrowLeft' + | 'IconArrowUp' + | 'IconArrowUpRight' + | 'IconAttachment' + | 'IconAudio' + | 'IconBell' + | 'IconBellOff' + | 'IconBolt' + | 'IconBookmark' + | 'IconBookmarkRemove' + | 'IconCamera' + | 'IconCheckmark' + | 'IconCheckmark1Small' + | 'IconChecks' + | 'IconChevronDown' + | 'IconChevronLeft' + | 'IconChevronRight' + | 'IconClock' + | 'IconCommand' + | 'IconCopy' + | 'IconDelete' + | 'IconDownload' + | 'IconEdit' + | 'IconEmoji' + | 'IconEmojiAdd' + | 'IconExclamationCircleFill' + | 'IconExclamationMark' + | 'IconExclamationMarkFill' + | 'IconExclamationTriangleFill' + | 'IconEyeFill' + | 'IconFile' + | 'IconFlag' + | 'IconFolder' + | 'IconGiphy' + | 'IconImage' + | 'IconInfo' + | 'IconLeave' + | 'IconLink' + | 'IconLoading' + | 'IconLocation' + | 'IconMegaphone' + | 'IconMenu' + | 'IconMessageBubble' + | 'IconMessageBubbleFill' + | 'IconMessageBubbles' + | 'IconMicrophoneSolid' + | 'IconMinus' + | 'IconMinusCircle' + | 'IconMore' + | 'IconMute' + | 'IconNoSign' + | 'IconNotification' + | 'IconPauseFill' + | 'IconPin' + | 'IconPlayFill' + | 'IconPlus' + | 'IconPlusSmall' + | 'IconPoll' + | 'IconQuote' + | 'IconRefresh' + | 'IconReorder' + | 'IconReply' + | 'IconRetry' + | 'IconSearch' + | 'IconSend' + | 'IconShield' + | 'IconThread' + | 'IconThreadFill' + | 'IconTranslate' + | 'IconTrophy' + | 'IconUnpin' + | 'IconUnsupportedAttachment' + | 'IconUpload' + | 'IconUser' + | 'IconUserAdd' + | 'IconUserCheck' + | 'IconUserRemove' + | 'IconUsers' + | 'IconVideo' + | 'IconVideoFill' + | 'IconVoice' + | 'IconXmark' + | 'IconXmarkSmall', + IconComponent + > +>; diff --git a/src/components/Loading/LoadingIndicator.tsx b/src/components/Loading/LoadingIndicator.tsx index 1ba631195e..9bc81f7a02 100644 --- a/src/components/Loading/LoadingIndicator.tsx +++ b/src/components/Loading/LoadingIndicator.tsx @@ -1,8 +1,10 @@ import React, { type ComponentProps } from 'react'; -import { IconLoading } from '../Icons'; +import { useComponentContextIcons } from '../../context'; +import type { IconLoading as DefaultIconLoading } from '../Icons'; -export type LoadingIndicatorProps = ComponentProps; +export type LoadingIndicatorProps = ComponentProps; -export const LoadingIndicator = (props: LoadingIndicatorProps) => ( - -); +export const LoadingIndicator = (props: LoadingIndicatorProps) => { + const { IconLoading } = useComponentContextIcons(); + return ; +}; diff --git a/src/components/Location/ShareLocationDialog.tsx b/src/components/Location/ShareLocationDialog.tsx index 5496147b95..6b2355a6a0 100644 --- a/src/components/Location/ShareLocationDialog.tsx +++ b/src/components/Location/ShareLocationDialog.tsx @@ -5,14 +5,13 @@ import React, { useMemo, useState, } from 'react'; -import { useTranslationContext } from '../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; import { ContextMenuBody, ContextMenuButton, ContextMenuRoot, Prompt } from '../Dialog'; import { Dropdown, type DropdownTriggerProps, useDropdownContext, } from '../Form/Dropdown'; -import { IconChevronDown } from '../Icons'; import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController'; import { SwitchField } from '../Form/SwitchField'; import { useNotificationApi } from '../Notifications'; @@ -65,6 +64,8 @@ export const ShareLocationDialog = ({ GeolocationMap = DefaultGeolocationMap, shareDurations = DEFAULT_SHARE_LOCATION_DURATIONS, }: ShareLocationDialogProps) => { + const { IconChevronDown } = useComponentContextIcons(); + const { addNotification } = useNotificationApi(); const { t } = useTranslationContext(); const messageComposer = useMessageComposerController(); @@ -101,7 +102,7 @@ export const ShareLocationDialog = ({ ) : null, }), - [selectedDurationLabel], + [IconChevronDown, selectedDurationLabel], ); const getPosition = useCallback( diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx index a3b96cb522..25f50f4cb6 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx @@ -1,13 +1,17 @@ import { CheckSignIcon } from '../../MessageComposer/icons'; -import { IconDelete, IconPauseFill, IconVoice } from '../../Icons'; import React from 'react'; -import { useMessageComposerContext, useTranslationContext } from '../../../context'; +import { + useComponentContextIcons, + useMessageComposerContext, + useTranslationContext, +} from '../../../context'; import { isRecording } from './recordingStateIdentity'; import { Button } from '../../Button'; import { useNotificationApi } from '../../Notifications'; import { UploadProgressIndicator } from '../../Loading/UploadProgressIndicator'; const ToggleRecordingButton = () => { + const { IconPauseFill, IconVoice } = useComponentContextIcons(); const { t } = useTranslationContext(); const { recordingController: { recorder, recordingState }, @@ -31,6 +35,8 @@ const ToggleRecordingButton = () => { }; export const AudioRecorderRecordingControls = () => { + const { IconDelete } = useComponentContextIcons(); + const { addNotification } = useNotificationApi(); const { t } = useTranslationContext(); const { diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx index 03abcdf0fa..82f15a270e 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification.tsx @@ -4,12 +4,12 @@ import React, { forwardRef, useRef } from 'react'; import { useAttachmentManagerState } from '../../MessageComposer/hooks/useAttachmentManagerState'; import { useComponentContext, + useComponentContextIcons, useMessageComposerContext, useTranslationContext, } from '../../../context'; import { Callout, useDialogOnNearestManager } from '../../Dialog'; import { Button } from '../../Button'; -import { IconVoice } from '../../Icons'; const dialogId = 'recording-permission-denied-notification'; @@ -69,6 +69,7 @@ export const DefaultStartRecordingAudioButton = forwardRef< StartRecordingAudioButtonProps >(function StartRecordingAudioButton(props, ref) { const { t } = useTranslationContext(); + const { IconVoice } = useComponentContextIcons(); return ( -); +}: ComponentProps<'button'>) => { + const { IconXmark } = useComponentContextIcons(); + return ( + + ); +}; diff --git a/src/components/Notifications/Notification.tsx b/src/components/Notifications/Notification.tsx index 75270f2e69..2d8118f377 100644 --- a/src/components/Notifications/Notification.tsx +++ b/src/components/Notifications/Notification.tsx @@ -3,13 +3,7 @@ import clsx from 'clsx'; import type { NotificationSeverity } from 'stream-chat'; import { type Notification as NotificationType } from 'stream-chat'; -import { - IconCheckmark, - IconExclamationMark, - IconExclamationTriangleFill, - IconRefresh, - IconXmark, -} from '../../components/Icons'; +import { useComponentContextIcons } from '../../context'; import { useTranslationContext } from '../../context/TranslationContext'; import { Button } from '../Button'; import { useNotificationApi } from './hooks/useNotificationApi'; @@ -21,18 +15,20 @@ export type NotificationIconProps = { notification: NotificationType; }; -const IconsBySeverity: Record = { - error: IconExclamationMark, - info: null, - loading: IconRefresh, - success: IconCheckmark, - warning: IconExclamationTriangleFill, -}; - const DefaultNotificationIcon = ({ notification }: NotificationIconProps) => { + const { IconCheckmark, IconExclamationMark, IconExclamationTriangleFill, IconRefresh } = + useComponentContextIcons(); if (!notification.severity) return null; - const Icon = IconsBySeverity[notification.severity] ?? null; + const iconsBySeverity: Record = { + error: IconExclamationMark, + info: null, + loading: IconRefresh, + success: IconCheckmark, + warning: IconExclamationTriangleFill, + }; + + const Icon = iconsBySeverity[notification.severity] ?? null; return ( Icon && (
@@ -72,6 +68,8 @@ export const Notification = forwardRef( }: NotificationProps, ref, ) => { + const { IconXmark } = useComponentContextIcons(); + const { removeNotification } = useNotificationApi(); const { t } = useTranslationContext(); diff --git a/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx b/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx index 6e37c8bfc1..9f83834504 100644 --- a/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx +++ b/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx @@ -1,8 +1,11 @@ import React from 'react'; import { useStateStore } from '../../../../store'; -import { usePollContext, useTranslationContext } from '../../../../context'; +import { + useComponentContextIcons, + usePollContext, + useTranslationContext, +} from '../../../../context'; import type { PollOption, PollState } from 'stream-chat'; -import { IconTrophy } from '../../../Icons'; type PollStateSelectorReturnValue = { maxVotedOptionIds: string[]; @@ -20,6 +23,8 @@ export type PollResultOptionVoteCounterProps = { export const PollResultOptionVoteCounter = ({ optionId, }: PollResultOptionVoteCounterProps) => { + const { IconTrophy } = useComponentContextIcons(); + const { t } = useTranslationContext(); const { poll } = usePollContext(); const { maxVotedOptionIds, vote_counts_by_option } = useStateStore( diff --git a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx index e597ce70a1..738999c164 100644 --- a/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx +++ b/src/components/Poll/PollCreationDialog/OptionFieldSet.tsx @@ -1,11 +1,10 @@ import clsx from 'clsx'; import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { TextInput } from '../../Form/TextInput'; -import { useTranslationContext } from '../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController'; import { useStateStore } from '../../../store'; import type { PollComposerOption, PollComposerState } from 'stream-chat'; -import { IconMinusCircle } from '../../Icons'; import { Button, type ButtonProps } from '../../Button'; import { TextInputFieldSet } from '../../Form/TextInputFieldSet'; import { VisuallyHidden } from '../../VisuallyHidden'; @@ -281,15 +280,18 @@ export const OptionFieldSet = () => { ); }; -const RemoveOptionButton = ({ className, ...props }: ButtonProps) => ( - -); +const RemoveOptionButton = ({ className, ...props }: ButtonProps) => { + const { IconMinusCircle } = useComponentContextIcons(); + return ( + + ); +}; diff --git a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx index b218f909ec..6e1ad9beaf 100644 --- a/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx +++ b/src/components/Poll/PollCreationDialog/PollCreationDialogControls.tsx @@ -2,9 +2,12 @@ import React from 'react'; import { flushSync } from 'react-dom'; import { useCanCreatePoll } from '../../MessageComposer/hooks/useCanCreatePoll'; import { useMessageComposerController } from '../../MessageComposer/hooks/useMessageComposerController'; -import { useMessageComposerContext, useTranslationContext } from '../../../context'; +import { + useComponentContextIcons, + useMessageComposerContext, + useTranslationContext, +} from '../../../context'; import clsx from 'clsx'; -import { IconSend } from '../../Icons'; import { Prompt } from '../../Dialog'; import { useNotificationApi } from '../../Notifications'; @@ -15,6 +18,8 @@ export type PollCreationDialogControlsProps = { export const PollCreationDialogControls = ({ close, }: PollCreationDialogControlsProps) => { + const { IconSend } = useComponentContextIcons(); + const { t } = useTranslationContext('PollCreationDialogControls'); const { handleSubmit: handleSubmitMessage, textareaRef } = useMessageComposerContext(); const messageComposer = useMessageComposerController(); diff --git a/src/components/Poll/PollCreationDialog/PollOptionReorderHandle.tsx b/src/components/Poll/PollCreationDialog/PollOptionReorderHandle.tsx index edcc0a14d7..9e0cd6bd5f 100644 --- a/src/components/Poll/PollCreationDialog/PollOptionReorderHandle.tsx +++ b/src/components/Poll/PollCreationDialog/PollOptionReorderHandle.tsx @@ -2,9 +2,8 @@ import type { KeyboardEvent as ReactKeyboardEvent } from 'react'; import React, { useEffect, useRef } from 'react'; import type { PollComposerOption } from 'stream-chat'; -import { IconReorder } from '../../Icons'; import { useAriaLiveAnnouncer } from '../../Accessibility'; -import { useTranslationContext } from '../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; type PollOptionReorderHandleProps = { index: number; @@ -30,6 +29,8 @@ export const PollOptionReorderHandle = ({ registerRef, totalOptionCount, }: PollOptionReorderHandleProps) => { + const { IconReorder } = useComponentContextIcons(); + const { t } = useTranslationContext(); const announce = useAriaLiveAnnouncer(); const hasAnnouncedFocusRef = useRef(false); diff --git a/src/components/Reactions/MessageReactionsDetail.tsx b/src/components/Reactions/MessageReactionsDetail.tsx index 7a713b6741..6e50d43064 100644 --- a/src/components/Reactions/MessageReactionsDetail.tsx +++ b/src/components/Reactions/MessageReactionsDetail.tsx @@ -9,13 +9,13 @@ import type { MessageContextValue } from '../../context'; import { useChatContext, useComponentContext, + useComponentContextIcons, useMessageContext, useTranslationContext, } from '../../context'; import type { ReactionSort } from 'stream-chat'; import { defaultReactionOptions, getHasExtendedReactions } from './reactionOptions'; import type { useProcessReactions } from './hooks/useProcessReactions'; -import { IconEmojiAdd } from '../Icons'; import { ReactionSelector, type ReactionSelectorProps } from './ReactionSelector'; export type MessageReactionsDetailProps = Partial< @@ -72,6 +72,7 @@ export const MessageReactionsDetail: MessageReactionsDetailInterface = ({ reactionOptions = defaultReactionOptions, ReactionSelectorExtendedList = ReactionSelector.ExtendedList, } = useComponentContext(MessageReactionsDetail.name); + const { IconEmojiAdd } = useComponentContextIcons(); const { t } = useTranslationContext(); const { diff --git a/src/components/Reactions/ReactionSelector.tsx b/src/components/Reactions/ReactionSelector.tsx index 2cda7962f7..588295db53 100644 --- a/src/components/Reactions/ReactionSelector.tsx +++ b/src/components/Reactions/ReactionSelector.tsx @@ -7,10 +7,10 @@ import { useComponentContext } from '../../context/ComponentContext'; import { useMessageContext } from '../../context/MessageContext'; import { useTranslationContext } from '../../context/TranslationContext'; import { Button } from '../Button'; -import { IconPlus } from '../Icons'; import type { ReactionResponse } from 'stream-chat'; +import { useComponentContextIcons } from '../../context'; export type ReactionSelectorProps = { /** Override dialog id used by the selector popover. */ dialogId?: string; @@ -44,6 +44,7 @@ export const ReactionSelector: ReactionSelectorInterface = (props) => { reactionOptions = defaultReactionOptions, ReactionSelectorExtendedList = ReactionSelector.ExtendedList, } = useComponentContext('ReactionSelector'); + const { IconPlus } = useComponentContextIcons(); const { closeReactionSelectorOnClick, diff --git a/src/components/Reactions/ReactionSelectorWithButton.tsx b/src/components/Reactions/ReactionSelectorWithButton.tsx index d489c81d30..5c4ddfb65c 100644 --- a/src/components/Reactions/ReactionSelectorWithButton.tsx +++ b/src/components/Reactions/ReactionSelectorWithButton.tsx @@ -4,6 +4,7 @@ import { ReactionSelector as DefaultReactionSelector } from './ReactionSelector' import { DialogAnchor, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog'; import { useComponentContext, + useComponentContextIcons, useMessageContext, useTranslationContext, } from '../../context'; @@ -12,8 +13,12 @@ import type { IconProps } from '../../types/types'; import { QuickMessageActionsButton } from '../MessageActions'; type ReactionSelectorWithButtonProps = { - /* Custom component rendering the icon used in a button invoking reactions selector for a given message. */ - ReactionIcon: React.ComponentType; + /** + * Custom component rendering the icon used in a button invoking reactions selector for a given message. + * @deprecated Use the `icons.IconEmoji` slot on `ComponentContext` (via ``) instead. + * Passing this prop still wins over the context slot for backwards compatibility. + */ + ReactionIcon?: React.ComponentType; }; /** @@ -25,8 +30,9 @@ export const ReactionSelectorWithButton = ({ }: ReactionSelectorWithButtonProps) => { const { t } = useTranslationContext('ReactionSelectorWithButton'); const { isMyMessage, message, threadList } = useMessageContext('MessageOptions'); - const { ReactionSelector = DefaultReactionSelector } = - useComponentContext('MessageOptions'); + const { ReactionSelector = DefaultReactionSelector } = useComponentContext(); + const { IconEmoji } = useComponentContextIcons(); + const ResolvedReactionIcon = ReactionIcon ?? IconEmoji; const buttonRef = useRef>(null); const dialogId = DefaultReactionSelector.getDialogId({ messageId: message.id, @@ -56,7 +62,7 @@ export const ReactionSelectorWithButton = ({ onClick={() => dialog?.toggle()} ref={buttonRef} > - + ); diff --git a/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx b/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx index acccac0f83..0057a336c5 100644 --- a/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx +++ b/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx @@ -6,49 +6,52 @@ import { useLatestMessagePreview, type UseLatestMessagePreviewParams, } from './hooks/useLatestMessagePreview'; -import { - IconCamera, - IconCheckmark1Small, - IconChecks, - IconClock, - IconExclamationCircleFill, - IconFile, - IconGiphy, - IconLink, - IconLocation, - IconNoSign, - IconUnsupportedAttachment, - IconVideo, - IconVoice, -} from '../Icons'; - -const deliveryStatusIconMap: Record = { - delivered: IconChecks, - read: IconChecks, - sending: IconClock, - sent: IconCheckmark1Small, -}; - -const contentTypeIconMap: Partial< - Record -> = { - deleted: IconNoSign, - error: IconExclamationCircleFill, - file: IconFile, - giphy: IconGiphy, - image: IconCamera, - link: IconLink, - location: IconLocation, - unsupported: IconUnsupportedAttachment, - video: IconVideo, - voice: IconVoice, -}; +import { useComponentContextIcons } from '../../context'; export const SummarizedMessagePreview = ({ latestMessage, messageDeliveryStatus, participantCount, }: UseLatestMessagePreviewParams) => { + const { + IconCamera, + IconCheckmark1Small, + IconChecks, + IconClock, + IconExclamationCircleFill, + IconFile, + IconGiphy, + IconLink, + IconLocation, + IconNoSign, + IconUnsupportedAttachment, + IconVideo, + IconVoice, + } = useComponentContextIcons(); + + const deliveryStatusIconMap: Record = + { + delivered: IconChecks, + read: IconChecks, + sending: IconClock, + sent: IconCheckmark1Small, + }; + + const contentTypeIconMap: Partial< + Record + > = { + deleted: IconNoSign, + error: IconExclamationCircleFill, + file: IconFile, + giphy: IconGiphy, + image: IconCamera, + link: IconLink, + location: IconLocation, + unsupported: IconUnsupportedAttachment, + video: IconVideo, + voice: IconVoice, + }; + const { deliveryStatus, senderName, text, type } = useLatestMessagePreview({ latestMessage, messageDeliveryStatus, diff --git a/src/components/TextareaComposer/SuggestionList/MentionItem/BroadcastMentionItem.tsx b/src/components/TextareaComposer/SuggestionList/MentionItem/BroadcastMentionItem.tsx index d6f1e7105f..bcb8ba4b4c 100644 --- a/src/components/TextareaComposer/SuggestionList/MentionItem/BroadcastMentionItem.tsx +++ b/src/components/TextareaComposer/SuggestionList/MentionItem/BroadcastMentionItem.tsx @@ -1,9 +1,8 @@ import clsx from 'clsx'; import React from 'react'; import type { ChannelMentionSuggestion, HereMentionSuggestion } from 'stream-chat'; -import { IconMegaphone } from '../../../Icons'; import { ListItemLayout } from '../../../ListItemLayout'; -import { useTranslationContext } from '../../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../../context'; import { MentionSuggestionTitle } from './MentionSuggestionTitle'; import type { MentionItemComponentProps } from './types'; @@ -16,6 +15,8 @@ export const BroadcastMentionItem = ({ focused, ...buttonProps }: BroadcastMentionItemProps) => { + const { IconMegaphone } = useComponentContextIcons(); + const { t } = useTranslationContext(); const description = entity.mentionType === 'channel' diff --git a/src/components/TextareaComposer/SuggestionList/MentionItem/RoleItem.tsx b/src/components/TextareaComposer/SuggestionList/MentionItem/RoleItem.tsx index 0c3de993d0..89558c0678 100644 --- a/src/components/TextareaComposer/SuggestionList/MentionItem/RoleItem.tsx +++ b/src/components/TextareaComposer/SuggestionList/MentionItem/RoleItem.tsx @@ -1,9 +1,8 @@ import clsx from 'clsx'; import React from 'react'; import type { RoleMentionSuggestion } from 'stream-chat'; -import { IconShield } from '../../../Icons'; import { ListItemLayout } from '../../../ListItemLayout'; -import { useTranslationContext } from '../../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../../context'; import { MentionSuggestionTitle } from './MentionSuggestionTitle'; import type { MentionItemComponentProps } from './types'; import { TokenizedSuggestionParts } from '../TokenizedSuggestionParts'; @@ -11,6 +10,8 @@ import { TokenizedSuggestionParts } from '../TokenizedSuggestionParts'; export type RoleItemProps = MentionItemComponentProps; export const RoleItem = ({ entity, focused, ...buttonProps }: RoleItemProps) => { + const { IconShield } = useComponentContextIcons(); + void focused; const { t } = useTranslationContext(); const role = entity.name; diff --git a/src/components/TextareaComposer/SuggestionList/MentionItem/UserGroupItem.tsx b/src/components/TextareaComposer/SuggestionList/MentionItem/UserGroupItem.tsx index cad38aca75..a304196330 100644 --- a/src/components/TextareaComposer/SuggestionList/MentionItem/UserGroupItem.tsx +++ b/src/components/TextareaComposer/SuggestionList/MentionItem/UserGroupItem.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx'; import React from 'react'; import type { UserGroupMentionSuggestion } from 'stream-chat'; -import { IconUsers } from '../../../Icons'; +import { useComponentContextIcons } from '../../../../context'; import { ListItemLayout } from '../../../ListItemLayout'; import { MentionSuggestionTitle } from './MentionSuggestionTitle'; import type { MentionItemComponentProps } from './types'; @@ -14,6 +14,8 @@ export const UserGroupItem = ({ focused, ...buttonProps }: UserGroupItemProps) => { + const { IconUsers } = useComponentContextIcons(); + void focused; return ( diff --git a/src/components/TextareaComposer/__tests__/CommandItem.test.tsx b/src/components/TextareaComposer/__tests__/CommandItem.test.tsx index 5dffad064e..3d456c21f5 100644 --- a/src/components/TextareaComposer/__tests__/CommandItem.test.tsx +++ b/src/components/TextareaComposer/__tests__/CommandItem.test.tsx @@ -21,11 +21,16 @@ vi.mock('../../MessageComposer/hooks', () => ({ }), })); -vi.mock('../../../context', () => ({ - useTranslationContext: () => ({ - t: (key: string) => key, - }), -})); +vi.mock('../../../context', async (importOriginal) => { + const actual = await importOriginal(); + return { + useComponentContext: () => ({}), + useComponentContextIcons: actual.useComponentContextIcons, + useTranslationContext: () => ({ + t: (key: string) => key, + }), + }; +}); afterEach(cleanup); diff --git a/src/components/Thread/ThreadHeader.tsx b/src/components/Thread/ThreadHeader.tsx index f61303e623..8d70b39d46 100644 --- a/src/components/Thread/ThreadHeader.tsx +++ b/src/components/Thread/ThreadHeader.tsx @@ -13,9 +13,9 @@ import { useTypingContext } from '../../context/TypingContext'; import type { LocalMessage } from 'stream-chat'; import type { ThreadState } from 'stream-chat'; import { Button } from '../Button'; -import { IconXmark } from '../Icons'; import { useChatViewContext } from '../ChatView'; +import { useComponentContextIcons } from '../../context'; const threadStateSelector = ({ replyCount }: ThreadState) => ({ replyCount }); /** Fallback when channel has no display title: parent message author (name only). */ @@ -72,6 +72,8 @@ export type ThreadHeaderProps = { }; export const ThreadHeader = (props: ThreadHeaderProps) => { + const { IconXmark } = useComponentContextIcons(); + const { closeThread, overrideTitle, thread } = props; const { t } = useTranslationContext(); diff --git a/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx b/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx index a15dc0e523..b505755b91 100644 --- a/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx +++ b/src/components/Threads/ThreadList/ThreadListEmptyPlaceholder.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import { useTranslationContext } from '../../../context'; -import { IconMessageBubbles } from '../../Icons'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; export const ThreadListEmptyPlaceholder = () => { + const { IconMessageBubbles } = useComponentContextIcons(); + const { t } = useTranslationContext('ThreadListEmptyPlaceholder'); return ( diff --git a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx index 66a96d7aa6..fce96b1260 100644 --- a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx +++ b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx @@ -3,8 +3,11 @@ import clsx from 'clsx'; import type { ThreadManagerState } from 'stream-chat'; -import { IconRefresh } from '../../Icons'; -import { useChatContext, useTranslationContext } from '../../../context'; +import { + useChatContext, + useComponentContextIcons, + useTranslationContext, +} from '../../../context'; import { useStateStore } from '../../../store'; import { LoadingIndicator } from '../../Loading'; @@ -14,6 +17,8 @@ const selector = (nextValue: ThreadManagerState) => ({ }); export const ThreadListUnseenThreadsBanner = () => { + const { IconRefresh } = useComponentContextIcons(); + const { client } = useChatContext(); const { t } = useTranslationContext(); const { isLoading, unseenThreadIds } = useStateStore(client.threads.state, selector); diff --git a/src/components/VideoPlayer/VideoThumbnail.tsx b/src/components/VideoPlayer/VideoThumbnail.tsx index 724fb2a3ac..0dff287f7e 100644 --- a/src/components/VideoPlayer/VideoThumbnail.tsx +++ b/src/components/VideoPlayer/VideoThumbnail.tsx @@ -1,9 +1,8 @@ import { BaseImage, type BaseImageProps } from '../BaseImage'; import { Button } from '../Button'; import clsx from 'clsx'; -import { IconPlayFill } from '../Icons'; import React from 'react'; -import { useTranslationContext } from '../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; export type VideoThumbnailProps = BaseImageProps & { onPlay?: () => void; @@ -14,6 +13,8 @@ export const VideoThumbnail = ({ onPlay, ...imageProps }: VideoThumbnailProps) => { + const { IconPlayFill } = useComponentContextIcons(); + const { t } = useTranslationContext(); return ( diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index d30f05082c..d02d021672 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -72,6 +72,7 @@ import type { SuggestionListProps, } from '../components/TextareaComposer'; +import type { IconSlots } from '../components/Icons'; import type { PropsWithChildrenOnly } from '../types/types'; import type { StopAIGenerationButtonProps } from '../components/MessageComposer/StopAIGenerationButton'; import type { VideoPlayerProps } from '../components/VideoPlayer'; @@ -107,6 +108,8 @@ export type ComponentContextValue = { extractDisplayInfo?: (_: { user?: Partial; }) => NonNullable[number]; + /** Overrides for icons rendered across the SDK. Individual keys are deep-merged with parent overrides via `WithComponents`, so a consumer can rebrand a single icon without wiping out the others. Preferred over component-level icon props (which are `@deprecated`). */ + icons?: IconSlots; /** UI component to display a user's avatar, defaults to and accepts same props as: [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) */ Avatar?: React.ComponentType; /** UI component to display a list of avatars stacked in a row, defaults to and accepts same props as: [AvatarStack](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/AvatarStack.tsx) */ diff --git a/src/context/WithComponents.tsx b/src/context/WithComponents.tsx index 4f67a0da7d..33a89f5402 100644 --- a/src/context/WithComponents.tsx +++ b/src/context/WithComponents.tsx @@ -9,7 +9,15 @@ export function WithComponents({ overrides, }: PropsWithChildren<{ overrides: Partial }>) { const parentOverrides = useContext(ComponentContext); - const actualOverrides: ComponentContextValue = { ...parentOverrides, ...overrides }; + const actualOverrides: ComponentContextValue = { + ...parentOverrides, + ...overrides, + icons: { + ...parentOverrides?.icons, + ...overrides?.icons, + }, + }; + return ( {children} diff --git a/src/context/index.ts b/src/context/index.ts index e1e070fd15..982a0e482d 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -4,6 +4,7 @@ export * from './ChannelListContext'; export * from './ChannelStateContext'; export * from './ChatContext'; export * from './ComponentContext'; +export * from './useComponentContextIcons'; export * from './DialogManagerContext'; export * from './MessageContext'; export * from './MessageBounceContext'; diff --git a/src/context/useComponentContextIcons.ts b/src/context/useComponentContextIcons.ts new file mode 100644 index 0000000000..05932900d6 --- /dev/null +++ b/src/context/useComponentContextIcons.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; + +import { useComponentContext } from './ComponentContext'; +import * as DEFAULT_ICONS from '../components/Icons/icons'; +import type { IconSlots } from '../components/Icons/slots'; + +/** + * Reads the `icons` override from `ComponentContext` and merges it on top of + * `DEFAULT_ICONS`. Every returned icon is guaranteed defined, so callers can + * destructure without fallbacks: + * + * ```tsx + * const { IconFlag } = useComponentContextIcons(); + * ``` + * + * Overrides supplied via `` win + * over defaults on a per-slot basis; slots the consumer didn't provide fall + * back to the SDK's own icon. + */ +export const useComponentContextIcons = (): Required => { + const { icons } = useComponentContext(); + + return useMemo(() => { + const definedOverrides = Object.fromEntries( + Object.entries(icons ?? {}).filter(([, Icon]) => typeof Icon === 'function'), + ); + + return { ...DEFAULT_ICONS, ...definedOverrides }; + + // Component should be stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +}; diff --git a/src/plugins/ChannelDetail/ChannelDetail.tsx b/src/plugins/ChannelDetail/ChannelDetail.tsx index 05c2f36144..860319a1cd 100644 --- a/src/plugins/ChannelDetail/ChannelDetail.tsx +++ b/src/plugins/ChannelDetail/ChannelDetail.tsx @@ -18,33 +18,32 @@ import { ChannelMediaView } from './Views/ChannelMediaView'; import { ChannelMembersView } from './Views/ChannelMembersView'; import { PinnedMessagesView } from './Views/PinnedMessagesView'; import { Prompt } from '../../components/Dialog'; -import { - IconFolder, - IconImage, - IconInfo, - IconPin, - IconUser, -} from '../../components/Icons'; +import { useComponentContextIcons } from '../../context'; -const ChannelManagementNavButtonIcon = () => ( - -); +const ChannelManagementNavButtonIcon = () => { + const { IconInfo } = useComponentContextIcons(); + return ; +}; -const ChannelMembersNavButtonIcon = () => ( - -); +const ChannelMembersNavButtonIcon = () => { + const { IconUser } = useComponentContextIcons(); + return ; +}; -const PinnedMessagesNavButtonIcon = () => ( - -); +const PinnedMessagesNavButtonIcon = () => { + const { IconPin } = useComponentContextIcons(); + return ; +}; -const ChannelMediaNavButtonIcon = () => ( - -); +const ChannelMediaNavButtonIcon = () => { + const { IconImage } = useComponentContextIcons(); + return ; +}; -const ChannelFilesNavButtonIcon = () => ( - -); +const ChannelFilesNavButtonIcon = () => { + const { IconFolder } = useComponentContextIcons(); + return ; +}; export const ChannelManagementNavButton = (props: SectionNavigatorNavButtonProps) => ( ( -
- -
{children}
-
-); +export const ChannelDetailEmptyList = ({ children }: PropsWithChildrenOnly) => { + const { IconSearch } = useComponentContextIcons(); + return ( +
+ +
{children}
+
+ ); +}; diff --git a/src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx b/src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx index ec591d60e5..4fce944e77 100644 --- a/src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx +++ b/src/plugins/ChannelDetail/ChannelDetailSearchInput.tsx @@ -1,8 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useTranslationContext } from '../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; import { TextInput } from '../../components/Form'; -import { IconSearch } from '../../components/Icons'; export type ChannelDetailSearchInputProps = { autoFocus?: boolean; @@ -12,6 +11,8 @@ export type ChannelDetailSearchInputProps = { export const ChannelDetailSearchInput = React.memo( ({ autoFocus, onSearchChange, resetKey }: ChannelDetailSearchInputProps) => { + const { IconSearch } = useComponentContextIcons(); + const { t } = useTranslationContext(); const [searchInput, setSearchInput] = useState(''); diff --git a/src/plugins/ChannelDetail/SectionNavigator/SectionNavigatorHeader.tsx b/src/plugins/ChannelDetail/SectionNavigator/SectionNavigatorHeader.tsx index ef4fc40cb4..30584e007e 100644 --- a/src/plugins/ChannelDetail/SectionNavigator/SectionNavigatorHeader.tsx +++ b/src/plugins/ChannelDetail/SectionNavigator/SectionNavigatorHeader.tsx @@ -1,10 +1,9 @@ import React, { useMemo } from 'react'; import { SECTION_NAVIGATOR_LAYOUT, useSectionNavigatorContext } from './SectionNavigator'; -import { useTranslationContext } from '../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../context'; import { Button } from '../../../components/Button'; import { Prompt, type PromptHeaderProps } from '../../../components/Dialog'; -import { IconMenu } from '../../../components/Icons'; export type SectionNavigatorHeaderProps = Omit; @@ -16,6 +15,8 @@ export type SectionNavigatorHeaderProps = Omit { + const { IconMenu } = useComponentContextIcons(); + const { t } = useTranslationContext('SectionNavigatorHeader'); const { layout, openNavigation } = useSectionNavigatorContext(); @@ -38,7 +39,7 @@ export const SectionNavigatorHeader = (props: SectionNavigatorHeaderProps) => { ); }; - }, [layout, openNavigation, props.goBack, t]); + }, [IconMenu, layout, openNavigation, props.goBack, t]); return ; }; diff --git a/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx index 83669b3e76..4176cb2686 100644 --- a/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesEmptyList.tsx @@ -1,7 +1,8 @@ -import { useTranslationContext } from '../../../../context'; -import { IconFolder } from '../../../../components/Icons'; +import { useComponentContextIcons, useTranslationContext } from '../../../../context'; export const ChannelFilesEmptyList = () => { + const { IconFolder } = useComponentContextIcons(); + const { t } = useTranslationContext('ChannelFilesEmptyList'); return ( diff --git a/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx index e113940113..1f20b6a8bc 100644 --- a/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx @@ -6,9 +6,12 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { useChatContext, + useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../../context'; +import * as DEFAULT_ICONS from '../../../../../components/Icons/icons'; import { useStateStore } from '../../../../../store'; import { ChannelDetailProvider } from '../../../ChannelDetailContext'; import { ChannelFilesView } from '../ChannelFilesView'; @@ -193,6 +196,9 @@ describe('ChannelFilesView', () => { tDateTimeParser: (input?: string | number | Date) => Dayjs(input), } as unknown as ReturnType); + vi.mocked(useComponentContext).mockReturnValue({}); + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); + vi.mocked(useChatContext).mockReturnValue({ client: { userID: 'user-1' }, } as ReturnType); diff --git a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx index 8c04cbeb38..53a7be2276 100644 --- a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementActions.defaults.tsx @@ -5,6 +5,7 @@ import type { Channel } from 'stream-chat'; import { useChatContext, useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../context'; @@ -14,13 +15,6 @@ import { useStateStore } from '../../../../store'; import { Alert } from '../../../../components/Dialog'; import { Button } from '../../../../components/Button'; import { Switch } from '../../../../components/Form'; -import { - IconAudio, - IconDelete, - IconLeave, - IconMute, - IconNoSign, -} from '../../../../components/Icons'; import { ListItemLayout } from '../../../../components/ListItemLayout'; import { GlobalModal } from '../../../../components/Modal'; import { useNotificationApi } from '../../../../components/Notifications'; @@ -45,21 +39,36 @@ const toError = (error: unknown) => const getDisplayName = (name?: string, fallback?: string) => name || fallback || ''; -const BlockUserActionIcon = () => ( - -); -const DeleteChatActionIcon = () => ( - -); -const MuteActionIcon = () => ( - -); -const MutedActionIcon = () => ( - -); -const LeaveChannelActionIcon = () => ( - -); +const BlockUserActionIcon = () => { + const { IconNoSign } = useComponentContextIcons(); + return ( + + ); +}; +const DeleteChatActionIcon = () => { + const { IconDelete } = useComponentContextIcons(); + return ( + + ); +}; +const MuteActionIcon = () => { + const { IconMute } = useComponentContextIcons(); + return ( + + ); +}; +const MutedActionIcon = () => { + const { IconAudio } = useComponentContextIcons(); + return ( + + ); +}; +const LeaveChannelActionIcon = () => { + const { IconLeave } = useComponentContextIcons(); + return ( + + ); +}; const channelManagementViewActionClassName = 'str-chat__channel-management-view-action'; diff --git a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx index b4979b38f1..0dd4b0a8d8 100644 --- a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx @@ -10,6 +10,7 @@ import React, { import { useChatContext, useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../context'; @@ -23,7 +24,6 @@ import { useChannelPreviewInfo, useIsUserMuted, } from '../../../../components/ChannelListItem'; -import { IconCheckmark, IconMute, IconPin } from '../../../../components/Icons'; import { useChannelMembershipState } from '../../../../components/ChannelList'; import { useIsChannelMuted } from '../../../../components/ChannelListItem/hooks/useIsChannelMuted'; import { useChannelHasMembersOnline } from '../../../../components/ChannelHeader/hooks/useChannelHasMembersOnline'; @@ -55,6 +55,8 @@ export type ChannelManagementInfoBodyProps = { export const ChannelManagementInfoBody = ({ actions, }: ChannelManagementInfoBodyProps) => { + const { IconMute, IconPin } = useComponentContextIcons(); + const { client } = useChatContext(); const { channel } = useChannelDetailContext(); const { Avatar = DefaultChannelAvatar } = useComponentContext(); @@ -313,6 +315,7 @@ const useChannelManagementEditForm = ({ }; export const ChannelManagementEditBody = (props: ChannelManagementEditBodyProps) => { + const { IconCheckmark } = useComponentContextIcons(); const { Avatar = DefaultChannelAvatar } = useComponentContext(); const { canSubmit, diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx index a03128e786..8f2cf2b5bb 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaEmptyList.tsx @@ -1,7 +1,8 @@ -import { useTranslationContext } from '../../../../context'; -import { IconImage } from '../../../../components/Icons'; +import { useComponentContextIcons, useTranslationContext } from '../../../../context'; export const ChannelMediaEmptyList = () => { + const { IconImage } = useComponentContextIcons(); + const { t } = useTranslationContext('ChannelMediaEmptyList'); return ( diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx index 5e1257e958..a1e2b583c3 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../context'; @@ -16,12 +17,6 @@ import { } from '../../../../components/BaseImage'; import { Prompt } from '../../../../components/Dialog'; import { Gallery as DefaultGallery, GalleryUI } from '../../../../components/Gallery'; -import { - IconChevronLeft, - IconChevronRight, - IconImage, - IconVideoFill, -} from '../../../../components/Icons'; import { GlobalModal } from '../../../../components/Modal'; import { SectionNavigatorHeader, @@ -54,6 +49,7 @@ const ChannelMediaGridItem = ({ const { t } = useTranslationContext('ChannelMediaView'); const { Avatar = DefaultAvatar, extractDisplayInfo = defaultExtractDisplayInfo } = useComponentContext(); + const { IconImage, IconVideoFill } = useComponentContextIcons(); const displayName = getUserDisplayName(item.user); const mediaSrc = item.type === 'video' @@ -120,6 +116,7 @@ const ChannelMediaPagination = ({ previousDisabled, }: ChannelMediaPaginationProps) => { const { t } = useTranslationContext('ChannelMediaView'); + const { IconChevronLeft, IconChevronRight } = useComponentContextIcons(); return (
diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx index 4ffc3e0e58..f08aa665ec 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx @@ -6,9 +6,11 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { useChatContext, useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../../context'; +import * as DEFAULT_ICONS from '../../../../../components/Icons/icons'; import { useStateStore } from '../../../../../store'; import { ChannelDetailProvider } from '../../../ChannelDetailContext'; import { ChannelMediaView } from '../ChannelMediaView'; @@ -137,6 +139,7 @@ describe('ChannelMediaView', () => { Modal: ({ children, open }: { children: React.ReactNode; open: boolean }) => open ?
{children}
: null, } as unknown as ReturnType); + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); vi.mocked(useStateStore).mockReturnValue({ hasNext: false, diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx index afcc3ff38e..27ed325281 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx @@ -15,6 +15,7 @@ import { useChannelListContext, useChatContext, useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../context'; @@ -23,13 +24,6 @@ import { useStateStore } from '../../../../store'; import { Alert } from '../../../../components/Dialog'; import { Button } from '../../../../components/Button'; import { Switch } from '../../../../components/Form'; -import { - IconAudio, - IconMessageBubble, - IconMute, - IconNoSign, - IconUserRemove, -} from '../../../../components/Icons'; import { ListItemLayout } from '../../../../components/ListItemLayout'; import { GlobalModal } from '../../../../components/Modal'; import { useNotificationApi } from '../../../../components/Notifications'; @@ -80,25 +74,38 @@ export const useChannelMemberActionContext = () => { const toError = (error: unknown) => error instanceof Error ? error : new Error('An unknown error occurred'); -const MemberMuteActionIcon = () => ( - -); +const MemberMuteActionIcon = () => { + const { IconMute } = useComponentContextIcons(); + return ( + + ); +}; -const MemberUnmuteActionIcon = () => ( - -); +const MemberUnmuteActionIcon = () => { + const { IconAudio } = useComponentContextIcons(); + return ( + + ); +}; -const SendDirectMessageActionIcon = () => ( - -); +const SendDirectMessageActionIcon = () => { + const { IconMessageBubble } = useComponentContextIcons(); + return ; +}; -const BlockUserActionIcon = () => ( - -); +const BlockUserActionIcon = () => { + const { IconNoSign } = useComponentContextIcons(); + return ( + + ); +}; -const RemoveUserActionIcon = () => ( - -); +const RemoveUserActionIcon = () => { + const { IconUserRemove } = useComponentContextIcons(); + return ( + + ); +}; const channelMemberDetailActionClassName = 'str-chat__channel-member-detail-action'; diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx index 522c6303e8..6bdf72b18a 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx @@ -6,9 +6,11 @@ import type { Channel, ChannelMemberResponse } from 'stream-chat'; import { useChatContext, useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../../context'; +import * as DEFAULT_ICONS from '../../../../../components/Icons/icons'; import { ChannelDetailProvider } from '../../../ChannelDetailContext'; import { ChannelMemberDetail } from '../ChannelMemberDetail'; @@ -88,6 +90,7 @@ describe('ChannelMemberDetail', () => { vi.mocked(useComponentContext).mockReturnValue( {} as ReturnType, ); + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); }); it("renders the provided member's details", () => { diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx index 5a5fe77b03..b2e3099c11 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersAddView.tsx @@ -4,13 +4,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useChatContext, useComponentContext, + useComponentContextIcons, useTranslationContext, } from '../../../../context'; import { useStateStore } from '../../../../store'; import { Avatar as DefaultAvatar } from '../../../../components/Avatar'; import { extractDisplayInfo as defaultExtractDisplayInfo } from '../../../../components/Avatar/utils'; import { Checkbox } from '../../../../components/Form'; -import { IconMute } from '../../../../components/Icons'; import { ListItemLayout } from '../../../../components/ListItemLayout'; import { VirtualizedList } from '../../VirtualizedList'; import { Prompt } from '../../../../components/Dialog'; @@ -38,9 +38,12 @@ const EMPTY_USERS: UserResponse[] = []; const computeUserItemKey = (_: number, user: UserResponse) => user.id; -const MuteIndicator = () => ( - -); +const MuteIndicator = () => { + const { IconMute } = useComponentContextIcons(); + return ( + + ); +}; const readOnlyRootProps = { className: 'str-chat__channel-detail__channel-members-view__list-item', diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx index 18f10ba5ff..0c63ffcfd6 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx @@ -4,11 +4,11 @@ import React, { useCallback, useMemo } from 'react'; import { useChatContext, useComponentContext, + useComponentContextIcons, useTranslationContext, } from '../../../../context'; import { Avatar as DefaultAvatar } from '../../../../components/Avatar'; import { extractDisplayInfo as defaultExtractDisplayInfo } from '../../../../components/Avatar/utils'; -import { IconMute } from '../../../../components/Icons'; import { ListItemLayout } from '../../../../components/ListItemLayout'; import { VirtualizedList } from '../../VirtualizedList'; import { Prompt } from '../../../../components/Dialog'; @@ -76,6 +76,7 @@ const ChannelMembersBrowseViewItem = ({ const TrailingSlot = useMemo( () => function MemberTrailingSlot() { + const { IconMute } = useComponentContextIcons(); return (
{roleTranslation ? ( diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx index e125f3c5d0..b558867a5a 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersHeaderActions.defaults.tsx @@ -1,7 +1,11 @@ import type { Channel } from 'stream-chat'; import React, { useMemo, useState } from 'react'; -import { useComponentContext, useTranslationContext } from '../../../../context'; +import { + useComponentContext, + useComponentContextIcons, + useTranslationContext, +} from '../../../../context'; import { Button } from '../../../../components/Button'; import { ContextMenu, @@ -15,7 +19,6 @@ import type { ChannelMembersHeaderActionsProps, ChannelMembersModeController, } from './ChannelMembersView'; -import { IconUserAdd } from '../../../../components/Icons'; export type ChannelMembersHeaderActionType = 'addMembers' | (string & {}); @@ -91,6 +94,7 @@ const AddMembersMenuAction = ({ modeController, }: ChannelMembersHeaderActionComponentProps) => { const { t } = useTranslationContext(); + const { IconUserAdd } = useComponentContextIcons(); if (modeController.mode !== 'browse') return null; diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx index 630656a724..6e382cc06f 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersAddView.test.tsx @@ -5,8 +5,10 @@ import type { UserResponse } from 'stream-chat'; import { useChatContext, useComponentContext, + useComponentContextIcons, useTranslationContext, } from '../../../../../context'; +import * as DEFAULT_ICONS from '../../../../../components/Icons/icons'; import { useStateStore } from '../../../../../store'; import { ChannelMembersAddView } from '../ChannelMembersAddView'; import { @@ -92,6 +94,8 @@ describe('ChannelMembersAddView', () => { options?.count ? `${key}:${options.count}` : key, } as ReturnType); + vi.mocked(useComponentContext).mockReturnValue({}); + vi.mocked(useChatContext).mockReturnValue({ client: { user: { id: 'user-1' } }, mutes: [], @@ -100,6 +104,7 @@ describe('ChannelMembersAddView', () => { vi.mocked(useComponentContext).mockReturnValue( {} as ReturnType, ); + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); vi.mocked(useStateStore).mockReturnValue({ isLoading: false, diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx index 2c6cf0afde..519bf1dfaf 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx @@ -5,8 +5,10 @@ import type { ChannelMemberResponse } from 'stream-chat'; import { useChatContext, useComponentContext, + useComponentContextIcons, useTranslationContext, } from '../../../../../context'; +import * as DEFAULT_ICONS from '../../../../../components/Icons/icons'; import { useStateStore } from '../../../../../store'; import { ChannelMembersBrowseView } from '../ChannelMembersBrowseView'; import { createChannel, emitChannelEvent, renderWithChannel } from './testUtils'; @@ -121,6 +123,7 @@ describe('ChannelMembersBrowseView', () => { return key; }, } as ReturnType); + vi.mocked(useComponentContext).mockReturnValue({}); vi.mocked(useChatContext).mockReturnValue({ mutes: [], } as ReturnType); @@ -128,6 +131,7 @@ describe('ChannelMembersBrowseView', () => { vi.mocked(useComponentContext).mockReturnValue( {} as ReturnType, ); + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); vi.mocked(useStateStore).mockReturnValue({ isLoading: false, diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx index 29d170ef5e..a052033300 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersView.test.tsx @@ -3,9 +3,11 @@ import React from 'react'; import { useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../../context'; +import * as DEFAULT_ICONS from '../../../../../components/Icons/icons'; import { type ChannelMembersModeViewProps, ChannelMembersView, @@ -219,6 +221,7 @@ describe('ChannelMembersView', () => { vi.mocked(useComponentContext).mockReturnValue( {} as ReturnType, ); + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); vi.mocked(useChannelMemberCount).mockReturnValue(2); }); diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx index 210c9610a2..9353e15fd7 100644 --- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesEmptyList.tsx @@ -1,7 +1,8 @@ -import { IconPin } from '../../../../components/Icons'; -import { useTranslationContext } from '../../../../context'; +import { useComponentContextIcons, useTranslationContext } from '../../../../context'; export const PinnedMessagesEmptyList = () => { + const { IconPin } = useComponentContextIcons(); + const { t } = useTranslationContext(); return ( diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx index bb676b3ccb..421f5fa5cd 100644 --- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx @@ -7,9 +7,11 @@ import { useChannelActionContext, useChatContext, useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../../context'; +import * as DEFAULT_ICONS from '../../../../../components/Icons/icons'; import { useStateStore } from '../../../../../store'; import { ChannelDetailProvider } from '../../../ChannelDetailContext'; import { PinnedMessagesView } from '../PinnedMessagesView'; @@ -211,6 +213,8 @@ describe('PinnedMessagesView', () => { tDateTimeParser: (input?: string | Date) => new Date(input ?? Date.now()), } as ReturnType); + vi.mocked(useComponentContext).mockReturnValue({}); + vi.mocked(useChatContext).mockReturnValue({ client: { userID: 'user-1' }, } as ReturnType); @@ -218,6 +222,7 @@ describe('PinnedMessagesView', () => { vi.mocked(useComponentContext).mockReturnValue( {} as ReturnType, ); + vi.mocked(useComponentContextIcons).mockReturnValue(DEFAULT_ICONS); vi.mocked(useModalContext).mockReturnValue({ close: vi.fn(), diff --git a/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx b/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx index 8618ff551c..7a0a0897aa 100644 --- a/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx +++ b/src/plugins/ChannelDetail/__tests__/ChannelManagementActions.defaults.test.tsx @@ -94,27 +94,31 @@ const mocks = vi.hoisted(() => { }; }); -vi.mock('../../../context', () => ({ - useChatContext: () => ({ - client: mocks.client, - mutes: mocks.mutes, - }), - useComponentContext: () => ({ - Modal: ({ - children, - open, - role, - }: { - children: React.ReactNode; - open: boolean; - role?: string; - }) => (open ?
{children}
: null), - }), - useModalContext: () => ({ close: mocks.close }), - useTranslationContext: () => ({ - t: mocks.useStableTranslationFunction ? mocks.t : (key: string) => mocks.t(key), - }), -})); +vi.mock('../../../context', async (importOriginal) => { + const actual = await importOriginal(); + return { + useChatContext: () => ({ + client: mocks.client, + mutes: mocks.mutes, + }), + useComponentContext: () => ({ + Modal: ({ + children, + open, + role, + }: { + children: React.ReactNode; + open: boolean; + role?: string; + }) => (open ?
{children}
: null), + }), + useComponentContextIcons: actual.useComponentContextIcons, + useModalContext: () => ({ close: mocks.close }), + useTranslationContext: () => ({ + t: mocks.useStableTranslationFunction ? mocks.t : (key: string) => mocks.t(key), + }), + }; +}); vi.mock('../../../components/Notifications', () => ({ useNotificationApi: () => ({ diff --git a/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx b/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx index a47c39c2bd..7f2d83d444 100644 --- a/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx +++ b/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx @@ -28,19 +28,23 @@ const mocks = vi.hoisted(() => ({ mutes: [] as Mute[], })); -vi.mock('../../../context', () => ({ - useChatContext: () => ({ - client: { - user: { id: 'own-user' }, - }, - mutes: mocks.mutes, - }), - useComponentContext: () => ({ - Avatar: () =>
, - }), - useModalContext: () => ({ close: mocks.close }), - useTranslationContext: () => ({ t: (key: string) => key }), -})); +vi.mock('../../../context', async (importOriginal) => { + const actual = await importOriginal(); + return { + useChatContext: () => ({ + client: { + user: { id: 'own-user' }, + }, + mutes: mocks.mutes, + }), + useComponentContext: () => ({ + Avatar: () =>
, + }), + useComponentContextIcons: actual.useComponentContextIcons, + useModalContext: () => ({ close: mocks.close }), + useTranslationContext: () => ({ t: (key: string) => key }), + }; +}); vi.mock('../../../context/ChatContext', () => ({ useChatContext: () => ({ @@ -132,8 +136,8 @@ vi.mock('../../../components/Dialog', () => ({ }, })); -vi.mock('../../../components/Icons', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../components/Icons/icons', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index 4972da647d..b4c734efd0 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -1,10 +1,13 @@ import React, { useEffect, useState } from 'react'; import PickerImport from '@emoji-mart/react'; -import { useMessageComposerContext, useTranslationContext } from '../../context'; +import { + useComponentContextIcons, + useMessageComposerContext, + useTranslationContext, +} from '../../context'; import { Button, - IconEmoji, type PopperLikePlacement, useMessageComposerController, } from '../../components'; @@ -22,6 +25,10 @@ const Picker = const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; export type EmojiPickerProps = { + /** + * @deprecated Use the `icons.IconEmoji` slot on `ComponentContext` (via ``) instead. + * Passing this prop still wins over the context slot for backwards compatibility. + */ ButtonIconComponent?: React.ComponentType; buttonClassName?: string; pickerContainerClassName?: string; @@ -72,7 +79,9 @@ export const EmojiPicker = (props: EmojiPickerProps) => { const { pickerContainerClassName, wrapperClassName } = classNames; - const { ButtonIconComponent = IconEmoji } = props; + const { IconEmoji } = useComponentContextIcons(); + const ResolvedButtonIconComponent = props.ButtonIconComponent ?? IconEmoji; + const pickerStyle = props.pickerProps?.style as React.CSSProperties | undefined; useEffect(() => { @@ -134,7 +143,7 @@ export const EmojiPicker = (props: EmojiPickerProps) => { type='button' variant='secondary' > - {ButtonIconComponent && } + {ResolvedButtonIconComponent && }
); From 1b8fa347c1373a26f1de9079127bc7d041e93be0 Mon Sep 17 00:00:00 2001 From: Anton Arnautov <43254280+arnautov-anton@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:27:19 +0200 Subject: [PATCH 3/9] feat: localized unread count (#3250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🎯 Goal Ref: GetStream/stream-chat-react-native#3679 ## Summary by CodeRabbit * **Bug Fixes** * Improved unread-count accuracy for channels using local unread tracking. * Marking messages as read now updates channel lists and delivery indicators consistently. * Read-state updates no longer incorrectly reset unread counts. * Hidden-tab unread indicators now stay synchronized with local unread activity. * Read actions continue to work when server read events are unavailable. --- src/components/Channel/Channel.tsx | 49 ++++++++++++------- .../ChannelListItem/ChannelListItem.tsx | 15 ++---- .../hooks/useMessageDeliveryStatus.ts | 2 + .../MessageList/hooks/useMarkRead.ts | 6 ++- 4 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index e070a39eaf..9f7c4b9a84 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -302,13 +302,22 @@ const ChannelInner = ( throttle( async (options?: MarkReadWrapperOptions) => { const { updateChannelUiUnreadState = true } = options ?? {}; - if (channel.disconnected || !channelConfig?.read_events) { - return; - } - - lastRead.current = new Date(); + if (channel.disconnected) return; + + if (!channelConfig?.read_events && client.options.isLocalUnreadCountEnabled) { + const event = channel.markReadLocally(); + + if (updateChannelUiUnreadState && event) { + lastRead.current = new Date(); + _setChannelUnreadUiState({ + last_read: lastRead.current, + last_read_message_id: event.last_read_message_id, + unread_messages: 0, + }); + } + } else if (channelConfig?.read_events) { + lastRead.current = new Date(); - try { if (doMarkReadRequest) { doMarkReadRequest( channel, @@ -316,7 +325,7 @@ const ChannelInner = ( ); } else { const markReadResponse = await channel.markRead(); - // markReadResponse.event can be null in case of a user that is not a member of a channel being marked read + // markReadResponse.event can be null in case of a user that is not a member of a channel being marked read // in that case event is null and we should not set unread UI if (updateChannelUiUnreadState && markReadResponse?.event) { _setChannelUnreadUiState({ @@ -326,14 +335,12 @@ const ChannelInner = ( }); } } + } - if (activeUnreadHandler) { - activeUnreadHandler(0, originalTitle.current); - } else if (originalTitle.current) { - document.title = originalTitle.current; - } - } catch (e) { - console.error(t('Failed to mark channel as read')); + if (activeUnreadHandler) { + activeUnreadHandler(0, originalTitle.current); + } else if (originalTitle.current) { + document.title = originalTitle.current; } }, 500, @@ -343,9 +350,9 @@ const ChannelInner = ( activeUnreadHandler, channel, channelConfig, + client, doMarkReadRequest, setChannelUnreadUiState, - t, ], ); @@ -381,7 +388,7 @@ const ChannelInner = ( if (mainChannelUpdated) { if ( document.hidden && - channelConfig?.read_events && + (channelConfig?.read_events || client.options.isLocalUnreadCountEnabled) && !channel.muteStatus().muted ) { const unread = channel.countUnread(lastRead.current); @@ -424,6 +431,10 @@ const ChannelInner = ( }); } + if (event.type === 'message.read_locally') { + return; + } + if (event.type === 'notification.mark_unread') _setChannelUnreadUiState((prev) => { if (!(event.last_read_at && event.user)) return prev; @@ -490,7 +501,11 @@ const ChannelInner = ( if (client.user?.id && channel.state.read[client.user.id]) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { user, ...ownReadState } = channel.state.read[client.user.id]; - _setChannelUnreadUiState(ownReadState); + _setChannelUnreadUiState((existingState) => { + // only set the initial state here, do not override existing + if (existingState) return existingState; + return ownReadState; + }); } /** * TODO: maybe pass last_read to the countUnread method to get proper value diff --git a/src/components/ChannelListItem/ChannelListItem.tsx b/src/components/ChannelListItem/ChannelListItem.tsx index 259f5cf573..dd94eb3926 100644 --- a/src/components/ChannelListItem/ChannelListItem.tsx +++ b/src/components/ChannelListItem/ChannelListItem.tsx @@ -117,25 +117,20 @@ export const ChannelListItem = (props: ChannelListItemProps) => { typeof active === 'undefined' ? activeChannel?.cid === channel.cid : active; const { muted } = useIsChannelMuted(channel); - useEffect(() => { - const handleEvent = (event: Event) => { - if (!event.cid) return setUnread(0); - if (channel.cid === event.cid) setUnread(0); - }; - - client.on('notification.mark_read', handleEvent); - return () => client.off('notification.mark_read', handleEvent); - }, [channel, client]); - useEffect(() => { const handleEvent = (event: Event) => { if (channel.cid !== event.cid) return; if (event.user?.id !== client.user?.id) return; setUnread(channel.countUnread()); }; + + client.on('notification.mark_read', handleEvent); channel.on('notification.mark_unread', handleEvent); + channel.on('message.read_locally', handleEvent); return () => { + client.off('notification.mark_read', handleEvent); channel.off('notification.mark_unread', handleEvent); + channel.off('message.read_locally', handleEvent); }; }, [channel, client]); diff --git a/src/components/ChannelListItem/hooks/useMessageDeliveryStatus.ts b/src/components/ChannelListItem/hooks/useMessageDeliveryStatus.ts index 92f98a4269..cfbc65d985 100644 --- a/src/components/ChannelListItem/hooks/useMessageDeliveryStatus.ts +++ b/src/components/ChannelListItem/hooks/useMessageDeliveryStatus.ts @@ -92,10 +92,12 @@ export const useMessageDeliveryStatus = ({ channel.on('message.delivered', handleMessageDelivered); channel.on('message.read', handleMarkRead); + channel.on('message.read_locally', handleMarkRead); return () => { channel.off('message.delivered', handleMessageDelivered); channel.off('message.read', handleMarkRead); + channel.off('message.read_locally', handleMarkRead); }; }, [channel, client, isOwnMessage, lastMessage]); diff --git a/src/components/MessageList/hooks/useMarkRead.ts b/src/components/MessageList/hooks/useMarkRead.ts index 5307f562ac..e67e0f2eef 100644 --- a/src/components/MessageList/hooks/useMarkRead.ts +++ b/src/components/MessageList/hooks/useMarkRead.ts @@ -37,7 +37,11 @@ export const useMarkRead = ({ const { channel } = useChannelStateContext('useMarkRead'); useEffect(() => { - if (!channel.getConfig()?.read_events) return; + const unreadNotificationSupported = + channel.getConfig()?.read_events || client.options.isLocalUnreadCountEnabled; + + if (!unreadNotificationSupported) return; + const shouldMarkRead = () => !document.hidden && !wasMarkedUnread && From 0820e4ccaf81b6669ae62332ed28f49998a5f9a4 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 5 Aug 2026 10:28:04 +0200 Subject: [PATCH 4/9] fix(EmojiPicker): drop @emoji-mart/react peer dependency (#3255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🎯 Goal `@emoji-mart/react` declares `react` as `^16.8 || ^17 || ^18` in its `peerDependencies` β€” React 19 is missing. Integrators on React 19 therefore hit peer-dependency resolution errors on install and have to add `package.json` overrides to get past them, even though the package works fine on React 19 in practice. The wrapper that package provides is ~20 lines of glue around the `emoji-mart` `Picker` custom element. Rather than asking every React 19 integrator to carry an override, we vendor it and drop the dependency. ### πŸ›  Implementation details **Vendored the wrapper** - new `src/plugins/Emojis/Picker.tsx`, taken from [`@emoji-mart/react`](https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/packages/emoji-mart-react/react.tsx) (MIT, Copyright (c) Missive). Behaviour is identical to upstream; --- package.json | 5 - src/plugins/Emojis/EmojiPicker.tsx | 12 +-- src/plugins/Emojis/Picker.tsx | 32 ++++++ src/plugins/Emojis/__tests__/Picker.test.tsx | 105 +++++++++++++++++++ yarn.lock | 14 --- 5 files changed, 139 insertions(+), 29 deletions(-) create mode 100644 src/plugins/Emojis/Picker.tsx create mode 100644 src/plugins/Emojis/__tests__/Picker.test.tsx diff --git a/package.json b/package.json index f3f38f1885..0b66b7a7d6 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,6 @@ "peerDependencies": { "@breezystack/lamejs": "^1.2.7", "@emoji-mart/data": "^1.1.0", - "@emoji-mart/react": "^1.1.0", "emoji-mart": "^5.4.0", "modern-normalize": "^3.0.1", "react": "^19.0.0 || ^18.0.0 || ^17.0.0", @@ -123,9 +122,6 @@ "@emoji-mart/data": { "optional": true }, - "@emoji-mart/react": { - "optional": true - }, "emoji-mart": { "optional": true }, @@ -144,7 +140,6 @@ "@commitlint/cli": "^21.0.1", "@commitlint/config-conventional": "^21.0.1", "@emoji-mart/data": "^1.2.1", - "@emoji-mart/react": "^1.1.1", "@eslint/js": "^9.39.4", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", diff --git a/src/plugins/Emojis/EmojiPicker.tsx b/src/plugins/Emojis/EmojiPicker.tsx index b4c734efd0..36414582da 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import PickerImport from '@emoji-mart/react'; +import { Picker, type PickerProps } from './Picker'; import { useComponentContextIcons, @@ -14,14 +14,6 @@ import { import { usePopoverPosition } from '../../components/Dialog/hooks/usePopoverPosition'; import { useIsCooldownActive } from '../../components/MessageComposer/hooks/useIsCooldownActive'; -// @emoji-mart/react ships as CJS with the component on `exports.default`. Under -// spec-strict ESM interop (e.g. Vite 8 / Rolldown, native Node ESM) a default -// import yields the module namespace `{ default }` instead of the component, -// which makes React throw "Element type is invalid ... got: object". Unwrap the -// default defensively so it works regardless of interop. -const Picker = - (PickerImport as unknown as { default?: typeof PickerImport }).default ?? PickerImport; - const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host; export type EmojiPickerProps = { @@ -38,7 +30,7 @@ export type EmojiPickerProps = { * Untyped [properties](https://github.com/missive/emoji-mart/tree/v5.5.2#options--props) to be * passed down to the [emoji-mart `Picker`](https://github.com/missive/emoji-mart/tree/v5.5.2#-picker) component */ - pickerProps?: Partial<{ theme: 'auto' | 'light' | 'dark' } & Record>; + pickerProps?: Partial<{ theme: 'auto' | 'light' | 'dark' } & PickerProps>; /** * Floating UI placement (default: 'top-end') for the picker popover */ diff --git a/src/plugins/Emojis/Picker.tsx b/src/plugins/Emojis/Picker.tsx new file mode 100644 index 0000000000..97e09ae7e3 --- /dev/null +++ b/src/plugins/Emojis/Picker.tsx @@ -0,0 +1,32 @@ +import { useEffect, useRef } from 'react'; +import { Picker as EmojiMartPicker } from 'emoji-mart'; + +/** + * Untyped [properties](https://github.com/missive/emoji-mart/tree/v5.5.2#options--props) forwarded + * to the emoji-mart `Picker` custom element. + */ +export type PickerProps = Record; + +// React wrapper around the emoji-mart `Picker` custom element. Taken and adjusted from +// @emoji-mart/react (MIT, Copyright (c) Missive): +// https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/packages/emoji-mart-react/react.tsx +// +// Vendored rather than depended upon because @emoji-mart/react does not declare React 19 in its +// peer dependencies, which forces consumers into `package.json` overrides. +export const Picker = (props: PickerProps) => { + const ref = useRef(null); + const instance = useRef(null); + if (instance.current) { + instance.current.update(props); + } + + useEffect(() => { + instance.current = new EmojiMartPicker({ ...props, ref }); + return () => { + instance.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return
; +}; diff --git a/src/plugins/Emojis/__tests__/Picker.test.tsx b/src/plugins/Emojis/__tests__/Picker.test.tsx new file mode 100644 index 0000000000..17d7ab146d --- /dev/null +++ b/src/plugins/Emojis/__tests__/Picker.test.tsx @@ -0,0 +1,105 @@ +import React, { StrictMode } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { Picker } from '../Picker'; + +// Minimal payload in the shape emoji-mart expects, so the picker can initialize without +// pulling in the full @emoji-mart/data set. +const data = { + aliases: {}, + categories: [{ emojis: ['grinning'], id: 'people' }], + emojis: { + grinning: { + id: 'grinning', + keywords: ['face', 'smile'], + name: 'Grinning Face', + skins: [{ native: 'πŸ˜€', unified: '1f600' }], + version: 1, + }, + }, + sheet: { cols: 60, rows: 60 }, +}; + +const pickerElements = (container: HTMLElement) => + container.querySelectorAll('em-emoji-picker'); + +const getRenderedPicker = async (container: HTMLElement) => { + await waitFor(() => expect(pickerElements(container)).toHaveLength(1)); + const element = container.querySelector('em-emoji-picker'); + // emoji-mart renders into a shadow root from an async `connectedCallback`, so wait for + // the UI itself rather than just the custom element wrapper. + await waitFor(() => + expect(element?.shadowRoot?.querySelector('input[type="search"]')).toBeTruthy(), + ); + return element; +}; + +describe('Emojis/Picker', () => { + const OriginalIntersectionObserver = globalThis.IntersectionObserver; + + beforeEach(() => { + // emoji-mart observes emoji category rows to lazy-render them; jsdom has no + // IntersectionObserver, and without a stub the picker's componentDidMount rejects. + // @ts-expect-error intersection observer stubs + globalThis.IntersectionObserver = class MockIntersectionObserver implements IntersectionObserver { + root = null; + rootMargin = ''; + thresholds = []; + disconnect = vi.fn(); + observe = vi.fn(); + takeRecords = vi.fn(() => []); + unobserve = vi.fn(); + }; + }); + + afterEach(() => { + globalThis.IntersectionObserver = OriginalIntersectionObserver; + }); + + it('mounts exactly one emoji-mart picker element', async () => { + const { container } = render(); + await getRenderedPicker(container); + }); + + it('mounts exactly one emoji-mart picker element under StrictMode', async () => { + // StrictMode double-invokes effects (mount -> cleanup -> mount), so the wrapper + // constructs a second emoji-mart Picker against the same container. It stays at one + // element only because emoji-mart clears the container (`ref.innerHTML = ''`) before + // appending. If that ever changes upstream, this catches the duplicated picker. + const { container } = render( + + + , + ); + + await getRenderedPicker(container); + }); + + it('updates the existing instance on re-render instead of remounting it', async () => { + const { container, rerender } = render(); + + const element = await getRenderedPicker(container); + expect(element?.shadowRoot?.querySelector('#root')).toHaveAttribute( + 'data-theme', + 'light', + ); + + rerender(); + + await waitFor(() => + expect(element?.shadowRoot?.querySelector('#root')).toHaveAttribute( + 'data-theme', + 'dark', + ), + ); + // the same custom element instance was updated in place, not torn down and rebuilt + expect(pickerElements(container)).toHaveLength(1); + expect(container.querySelector('em-emoji-picker')).toBe(element); + }); + + it('removes the picker element on unmount', async () => { + const { container, unmount } = render(); + await getRenderedPicker(container); + unmount(); + expect(pickerElements(container)).toHaveLength(0); + }); +}); diff --git a/yarn.lock b/yarn.lock index c0527a612c..6f9290cdc5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -621,16 +621,6 @@ __metadata: languageName: node linkType: hard -"@emoji-mart/react@npm:^1.1.1": - version: 1.1.1 - resolution: "@emoji-mart/react@npm:1.1.1" - peerDependencies: - emoji-mart: ^5.2 - react: ^16.8 || ^17 || ^18 - checksum: 10c0/88a9c8c24bbc5695f0ed2458734c9982c965a16db1999bc731c7cce77f9bf228f1871e899744f9a3f9fdd36a11db7ad6c0e049d710cb91c66c69a2cd4d2ee40a - languageName: node - linkType: hard - "@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1": version: 4.9.1 resolution: "@eslint-community/eslint-utils@npm:4.9.1" @@ -10042,7 +10032,6 @@ __metadata: "@commitlint/cli": "npm:^21.0.1" "@commitlint/config-conventional": "npm:^21.0.1" "@emoji-mart/data": "npm:^1.2.1" - "@emoji-mart/react": "npm:^1.1.1" "@eslint/js": "npm:^9.39.4" "@floating-ui/react": "npm:^0.27.19" "@react-aria/focus": "npm:^3.22.0" @@ -10119,7 +10108,6 @@ __metadata: peerDependencies: "@breezystack/lamejs": ^1.2.7 "@emoji-mart/data": ^1.1.0 - "@emoji-mart/react": ^1.1.0 emoji-mart: ^5.4.0 modern-normalize: ^3.0.1 react: ^19.0.0 || ^18.0.0 || ^17.0.0 @@ -10141,8 +10129,6 @@ __metadata: optional: true "@emoji-mart/data": optional: true - "@emoji-mart/react": - optional: true emoji-mart: optional: true modern-normalize: From 13b3f13ef69c3b2cde5f25da6fec861a34bbffe6 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 5 Aug 2026 10:28:27 +0200 Subject: [PATCH 5/9] docs: consolidate agent guidance into AGENTS.md, drop AI.md (#3256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🎯 Goal Agent-facing documentation in this repo was spread across three files that nothing verified, and all three had drifted. **`AI.md`** was a hand-maintained integration guide for AI assistants, shipped in the npm tarball. It duplicates what the official docs and tutorial already cover. Every item below is wrong on `master` today: | Claim in `AI.md` | Reality | | --- | --- | | `import 'stream-chat-react/dist/css/v2/index.css'` β€” **6 occurrences** | `build-styling` emits `dist/css/index.css`; there is no `dist/css/v2/`, so this import cannot resolve | | `examples/tutorial/src/4-custom-ui-components/` | `6-custom-ui-components` | | `examples/tutorial/src/7-livestream/` | `optional-livestream` | | `examples/tutorial/src/6-emoji-picker/` | `7-emoji-picker` | | `examples/vite/src/stream-imports-theme.scss` | does not exist (`examples/vite/src/index.scss`) | | `stream-chat`: `^9.27.2` | `^9.50.2` | | install `@emoji-mart/react`, add React 19 overrides | no longer a dependency (#3255) | An AI-facing guide that hands out a CSS import path which doesn't exist is worse than no guide β€” it produces confidently broken integrations. We now publish maintained [agent skills](https://getstream.io/agent-skills/docs/installation/) that cover integration properly and stay in sync across SDKs. **`CLAUDE.md` and `AGENTS.md`** covered overlapping ground with no shared source, so each drifted independently β€” `CLAUDE.md` still documented Jest, a Playwright e2e suite, and a `MessageInput` component, none of which exist. Two files describing one repo is the reason they were both wrong. **`yarn types`** silently checked nothing (details below), so the type errors a contributor expected it to catch went unreported. ### πŸ›  Implementation details #### 1. Deleted `AI.md` 423 lines, and removed from the `files` array in `package.json` β€” it was being published to npm, so this drops a file from the package tarball, not just from the repo. **Added a `Build with AI Agents` section to `README.md`**, directly after *React Chat Tutorial*. The tutorial is presented as the best way to get started, so the agent-driven path belongs beside it rather than buried further down. It documents the install (`curl -fsSL https://getstream.io/cli.sh | bash` + `getstream init`), links [`/stream-react`](https://getstream.io/agent-skills/docs/skills/stream-react/), and shows example invocations. Three entry points, so it's discoverable however someone scans the README: a `Quick Links` bullet at the top, the section itself, and a cross-reference from the existing "Using AI assistants" block at the bottom β€” that block points at `AGENTS.md`, which is about *contributing to this repo*, a different audience from someone integrating the SDK. Content came from the live docs pages rather than memory, so the install command, the four skill capabilities (scaffold / enhance / audit / migrate, including Sendbird β†’ Stream Chat) and the supported-agent list match what the docs actually say. #### 2. `AGENTS.md` is now the single source; `CLAUDE.md` imports it `CLAUDE.md` is reduced to a pointer ending in `@AGENTS.md`, which Claude Code expands inline. `AGENTS.md` absorbed the architecture content and keeps its own contribution rules, so there is one file to maintain for every agent that reads this repo β€” and `AGENTS.md` is already the filename Copilot, Cursor, Codex and Aider read. An import rather than a symlink: git symlinks degrade to a plain text file on Windows checkouts with `core.symlinks=false`, which would leave Claude Code with no guidance at all. Every claim in the merged file was re-derived from source rather than carried over. Corrections: | Was documented | Reality in `src` | | --- | --- | | "Run Jest tests", `yarn e2e`, `yarn e2e-fixtures` | Vitest only; no Jest, no Playwright suite in this repo | | `` + `MessageInput/hooks/` | Directory no longer exists β€” it's `MessageComposer`, backed by `stream-chat`'s `MessageComposer` class | | `` | `ChannelProps` carries no component slots; overrides go through `` | | `useStateStore(chatClient.state.channelsArray)` | A selector is required; shallow-compares selected keys | | 3 bundle entry points | 4 β€” `channel-detail` was added; `build-styling` emits 4 stylesheets | | `css-reset β†’ stream-new β†’ …` layers | `modern-normalize, stream-new, stream-new-plugins, stream-overrides, stream-app-overrides` | | `_global-theme-variables.scss` | `variable-tokens.scss` + `light.scss`/`dark.scss` | | Yarn binary pinned to `yarn-4.14.1.cjs` | Now unpinned by filename (it's 4.15.0 and moves) | | "Never commit directly to `main`" | Default branch is `master` | | Styling / Build / i18n sections duplicated verbatim | Deduplicated | Added, because it isn't discoverable without reading several files: composer state ownership and the `client.messageComposerCache` resolution order; the Vite 8 / Rolldown constraints in `vite.config.ts` and why they must not be "simplified" (hardcoded `es`/`cjs` output dirs, regex externals for subpath imports); the `npmMinimalAgeGate: 1d` / `enableScripts: false` dependency gates; the Vitest setup contract and `mock-builders` inventory; `src/a11y` primitives; i18n `keySeparator: false` (keys legitimately contain `/`); and the CI job list. The architectural sections that still held were each re-verified against source before being kept: the 500ms/200ms/500ms-leading/2000ms throttles in `Channel.tsx`, `PREPEND_OFFSET = 10 ** 7`, the `processMessages` ordering, the string-serialization memoization FIXME in `useCreateChannelStateContext`, `areMessageUIPropsEqual`'s cheap-prop ordering, and the `react-compat` ESLint block. #### 3. `yarn types` now type-checks `src` ```diff - "types": "tsc --emitDeclarationOnly false --noEmit", + "types": "tsc --project tsconfig.lib.json --noEmit", ``` Without `--project`, `tsc` resolves the root `tsconfig.json` β€” a solution-style config with `"files": []` and project references only. It therefore checked **no files**, exited in under a second and always passed. `--emitDeclarationOnly false` is dropped: it only existed to dodge the old TS5053 error when `--noEmit` met `emitDeclarationOnly`, and under this repo's TypeScript 6 the output is byte-identical without it (diffed both forms). `yarn types:tests` is left alone but is now documented honestly: it is **not run by CI** and is currently red repo-wide (~1300 errors), so `AGENTS.md` frames it as advisory against a baseline rather than a green gate. Worth a follow-up; out of scope here. ### βœ… Verification - `yarn lint` β€” exit 0 (prettier covers Markdown in this repo) - **`yarn types` was verified to actually check, not merely to run**: injected `const __typecheck_probe: number = "not a number"` into `src/utils/getChannel.ts`, confirmed it was reported as `TS2322`, then reverted. Before this change the same probe produced no output. - Nothing in CI invokes `yarn types`, so enabling it cannot turn CI red. The same `tsconfig.lib.json` is already compiled by `yarn build` in CI with `noEmitOnError`, so `src` type errors were failing CI before this change too β€” this only makes the check runnable locally under a memorable name. - `require()` of `package.json` to confirm the earlier `files` edit kept it valid; no remaining references to `AI.md` anywhere in the repo. ### 🎨 UI Changes None β€” documentation and one script definition. ## Summary by CodeRabbit * **Documentation** * Added an β€œAI Agent Skills” quick link and guidance for building, upgrading, integrating, auditing, and migrating Stream Chat React applications with AI agents. * Replaced outdated repository guidance with expanded documentation covering development workflows, architecture, testing, accessibility, styling, troubleshooting, and contribution practices. * Removed the obsolete AI integration guide and related references. * Updated published package contents to reflect the documentation changes. --- AGENTS.md | 507 +++++++++++++++++++++++++++++++++++++++++++-------- AI.md | 423 ------------------------------------------ CLAUDE.md | 386 +-------------------------------------- README.md | 21 ++- package.json | 5 +- 5 files changed, 450 insertions(+), 892 deletions(-) delete mode 100644 AI.md diff --git a/AGENTS.md b/AGENTS.md index ada19dcaa1..5dada4c5a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,119 +1,464 @@ -Guidance for AI coding agents (Copilot, Cursor, Aider, Claude, etc.) working in this repository. Human readers are welcome, but this file is written for tools. +# AGENTS.md -### Repository purpose +Guidance for AI coding agents (Claude Code, Copilot, Cursor, Codex, Aider, etc.) working in this repository. Human readers are welcome, but this file is written for tools. -This repo hosts Stream’s React Chat SDK. It provides UI component. +> **Single source of truth.** `CLAUDE.md` contains nothing but `@AGENTS.md`, which Claude Code expands into this file. Edit this file only β€” never fork guidance into `CLAUDE.md`. Agents should prioritize backwards compatibility, API stability, and high test coverage when changing code. -### Tech & toolchain +## Repository purpose -- Language: React (Typescript) -- Primary runtime: Node (use the version in .nvmrc via nvm use) -- Package manager: Yarn 4 (Berry). The binary lives at `.yarn/releases/yarn-4.14.1.cjs` and is activated via `yarnPath` in `.yarnrc.yml`. Any globally installed `yarn` (e.g. classic 1.x) acts only as a launcher β€” no Corepack required. -- Workspaces: Yarn workspaces monorepo. The published SDK lives at the repo root (`stream-chat-react`); `examples/*` are private workspaces consuming the SDK via `workspace:^`. -- Testing: Unit/integration: Vitest (+ React Testing Library). -- CI: GitHub Actions (assume PR validation on build + tests + lint) -- Lint/format: ESLint + Prettier (configs in repo root) -- Styles: Import Stream styles and override via CSS layers as described in README (don’t edit compiled CSS) -- Release discipline: Conventional Commits + automated release tooling (see commitlint/semantic-release configs). +Stream's React Chat SDK β€” React components, hooks and contexts for building chat UIs on the Stream Chat API. The published package (`stream-chat-react`) lives at the repo root; `examples/*` are private Yarn workspaces consuming it via `workspace:^`. -### Project layout (high level) +## Tech & toolchain -- src/ β€” Components, hooks, contexts, styles, and utilities (library source). -- scripts/ - Scripts run during the build process -- examples/ β€” Example apps as private Yarn workspaces. Currently `examples/tutorial` and `examples/vite`. -- developers/ β€” Dev notes & scripts. +- **Language:** TypeScript + React +- **Runtime:** Node 24 (`.nvmrc` β€” use `nvm use`) +- **Package manager:** Yarn 4 (Berry). The binary is committed under `.yarn/releases/` and activated via `yarnPath` in `.yarnrc.yml`. Any globally installed `yarn` (even classic 1.x) acts only as a launcher β€” no Corepack required. +- **Workspaces:** Yarn workspaces monorepo (`examples/*`) +- **Testing:** Vitest + React Testing Library (+ `vitest-axe` for a11y). There is no Jest and no Playwright/e2e suite in this repo. +- **Bundler:** Vite 8 / Rolldown (library mode); `tsc` emits declarations only +- **Styles:** Sass compiled to `dist/css/`. Consumers override via CSS layers (see README) β€” never edit compiled CSS. +- **Lint/format:** ESLint (flat config, `--max-warnings 0`) + Prettier +- **CI:** GitHub Actions β€” PR validation on lint + build/bundle-validation + tests +- **Release:** Conventional Commits + semantic-release (`commitlint.config.mjs`, `.releaserc.json`) -Use the closest folder’s patterns and conventions when editing. +### Root configuration files -### Configurations +`.nvmrc` Β· `.yarnrc.yml` Β· `eslint.config.mjs` Β· `.prettierrc` / `.prettierignore` Β· `tsconfig.json` (solution) + `tsconfig.lib.json` (src) + `tsconfig.test.json` (tests) Β· `vite.config.ts` Β· `vitest.config.ts` / `vitest.setup.ts` Β· `i18next.config.ts` Β· `commitlint.config.mjs` Β· `.releaserc.json` Β· `.lintstagedrc.json` / `.lintstagedrc.fix.json` Β· `codecov.yml` -Root configs: +Respect repo-specific rules. Do not suppress lint rules broadly; justify and scope every exception. -- .gitignore -- .lintstagedrc.fix.json -- .lintstagedrc.json -- .nvmrc -- .prettierignore -- .prettierrc -- .releaserc.json -- codecov.yml -- commitlint.config.mjs -- eslint.config.mjs, -- i18next.config.ts -- tsconfig.json +## Project layout -Respect any repo-specific rules. Do not suppress rules broadly; justify and scope exceptions. +- `src/` β€” library source: `components/`, `context/`, `store/`, `i18n/`, `styling/`, `a11y/`, `plugins/`, `utils/`, `mock-builders/` +- `scripts/` β€” build/validation scripts +- `examples/` β€” private example workspaces: `examples/tutorial`, `examples/vite` +- `developers/` β€” dev notes (`BRANCHES.md`, `COMMIT.md`, `DEPRECATIONS.md`, `PR.md`, `RELEASE.md`) -### Runbook (commands) +Use the closest folder's patterns and conventions when editing. -1. Install dependencies (root + all workspaces): yarn install -2. Build: yarn build -3. Typecheck: yarn types -4. Lint: yarn lint -5. Fix lint issues: yarn lint-fix -6. Unit tests: yarn test -7. Run an example: yarn start:tutorial or yarn start:vite -8. Build all examples: yarn examples:build +## Essential commands -### General rules +```bash +yarn install # Root + examples/* workspaces -#### Linting & formatting +# Build +yarn build # clean + 4 parallel steps (translations, vite, tsc types, sass) +yarn start # tsc -p tsconfig.lib.json --watch (emit .d.ts on change) +yarn start:css # watch + recompile SCSS -- Make sure the eslint and prettier configurations are followed. Run before committing: +# Tests +yarn test # vitest run (single pass) +yarn test MessageList # filter by file path substring +yarn test -t 'marks read' # filter by test name +yarn test:watch # watch mode +yarn coverage # v8 coverage (what CI runs) +# Lint / format +yarn lint # prettier --list-different + eslint --max-warnings 0 + validate-translations +yarn lint-fix # ALWAYS run this before committing +yarn fix-staged # auto-fix only staged files + +# Type checking +yarn types # src β€” the gate that matters (CI's build runs the same config) +yarn types:tests # tests + mock-builders; NOT run in CI, currently red (see below) + +# Bundle smoke tests (run in CI after build) +yarn validate-cjs # loads dist/cjs in Node + a browser-like context +yarn validate-esm # imports dist/es in Node + +# Examples +yarn start:tutorial # @stream-io/stream-chat-react-tutorial dev server +yarn start:vite # @stream-io/stream-chat-react-vite dev server +yarn examples:build # build all example workspaces +``` + +**`yarn types` checks `src`, and only recently started to.** It now runs `tsc --project tsconfig.lib.json --noEmit`. It previously ran bare `tsc --noEmit`, which resolved the root `tsconfig.json` β€” a solution-style config with `"files": []` and project references only β€” so it checked nothing and always passed in under a second. If you remember it as a no-op, that is fixed; if it returns instantly, something is wrong. + +**`src` is the enforced type gate.** CI never runs `types:tests`, but `yarn build` runs the same `tsconfig.lib.json` with `noEmitOnError`, so type errors under `src/` (excluding `__tests__` and `mock-builders`, which that config excludes) do fail CI. `yarn types:tests` is currently red repo-wide (~1300 errors, including some sourced from a sibling `../stream-chat-js` checkout when one is present) β€” treat its output as advisory and compare against a baseline rather than expecting zero. + +**Adding dependencies.** `.yarnrc.yml` sets `npmMinimalAgeGate: 1d`, so packages published within the last day are refused unless listed under `npmPreapprovedPackages`. `enableScripts: false` disables install scripts globally; per-package opt-ins live in `dependenciesMeta` in `package.json`. + +## Architecture: core concepts + +### Component hierarchy + +``` + # Root: client, theme, i18n, SearchController, notification filter + β”œβ”€ # Channel list + search + └─ # State container: messages, threads, WebSocket events + β”œβ”€ + β”‚ β”œβ”€ + β”‚ β”œβ”€ # or + β”‚ └─ # composer with attachments/mentions/polls/voice + └─ # threaded replies (renders its own MessageComposer) +``` + +`` + ``/`` provide the channels-vs-threads (inbox) view switching. + +### Context layers (17 contexts in `src/context/`) + +``` +ChatContext # client, active channel, theme, searchController, navigation +β”œβ”€ ChannelStateContext # read-only: messages, members, threads, loading states +β”œβ”€ ChannelActionContext # write: sendMessage, deleteMessage, openThread, markRead… +β”œβ”€ ComponentContext # ~100 customizable component slots + `icons` slot map +β”œβ”€ MessageContext # per-message: actions, reactions, status +β”œβ”€ MessageComposerContext # composer props/bindings +β”œβ”€ DialogManagerContext / ModalContext # dialog + modal orchestration +└─ TranslationContext, TypingContext, PollContext, MessageListContext, + VirtualizedMessageListContext, ChannelListContext, MessageBounceContext, + AttachmentSelectorContext, MessageTranslationViewContext +``` + +Each has a hook: `useChatContext()`, `useChannelStateContext()`, `useComponentContext()`, … Other contexts live next to their components (`SearchContext`, `ChannelDetailContext`, `ThreadContext`, `NotificationConfigurationContext`). + +### Customization: `WithComponents`, not component props + +`ChannelProps` **does not** accept component overrides. Slots come from `ComponentContext`, populated by ``, which merges over the parent context (and merges `icons` slot-by-slot): + +```tsx + + + + + + + +``` + +Icons are read via `useComponentContextIcons()`, which merges `DEFAULT_ICONS` (`src/components/Icons/icons`) under the override so every slot is guaranteed defined and callers destructure without fallbacks. Note the returned map is memoized with `[]` β€” icon overrides are read once and must be stable. + +`Channel` props are behavioral escape hatches instead: `doSendMessageRequest`, `doUpdateMessageRequest`, `doDeleteMessageRequest`, `doMarkReadRequest`, `channelQueryOptions`, `initializeOnMount`, `markReadOnMount`, `skipMessageDataMemoization`, `EmptyPlaceholder`. + +When adding a customizable component: add the slot to `ComponentContext` (`src/context/ComponentContext.tsx`), provide a default implementation, and read it through `useComponentContext()`. + +### State management (multi-layer) + +1. **Local state** (`useState`) β€” component UI state +2. **Reducer state** (`useReducer`) β€” `Channel` uses `makeChannelReducer` (`src/components/Channel/channelState.ts`) for message/thread state +3. **Context state** β€” shared across the tree +4. **External state** β€” `stream-chat`'s `StateStore`, consumed via `useStateStore` (`src/store/hooks/useStateStore.ts`) + +`useStateStore` **requires a selector** returning a flat object/array (it shallow-compares the selected keys). Define the selector at module scope so it stays referentially stable: + +```ts +import { useStateStore } from '../../store'; + +const selector = (nextValue: ThreadManagerState) => ({ + isLoading: nextValue.pagination.isLoading, + threads: nextValue.threads, +}); + +const { isLoading, threads } = useStateStore(client.threads.state, selector); ``` -yarn lint-fix + +### Composer state lives in `stream-chat` + +`useMessageComposerController()` resolves which `MessageComposer` instance (from `stream-chat`) backs the current UI, in this order: + +``` +edited message β†’ thread instance (thread.messageComposer) β†’ legacy thread parent β†’ channel.messageComposer +``` + +Composers for `message`/`legacy_thread` contexts are cached in `client.messageComposerCache` by `tag`, and `registerSubscriptions()` is bound to the component lifecycle. Draft/attachment/poll/command state is owned by the SDK class, not React state β€” read it with `useStateStore`. + +## Critical architectural patterns + +### 1. Optimistic updates & race conditions + +**Files:** `src/components/Channel/Channel.tsx`, `src/components/Channel/channelState.ts` + +- Messages enter local state IMMEDIATELY on send (optimistic) +- WebSocket events may arrive before or after the API response +- **Timestamp-based conflict resolution:** the newest version wins +- **Gotcha:** thread state is separate from channel state β€” both must be updated + +### 2. WebSocket event processing + +**File:** `src/components/Channel/Channel.tsx` (`handleEvent`) + +```ts +// Events are THROTTLED to 500ms to prevent excessive re-renders +const throttledCopyStateFromChannel = throttle( + () => dispatch({ channel, type: 'copyStateFromChannelOnEvent' }), + 500, + { leading: true, trailing: true }, +); ``` -#### Commit / PR conventions +- Some events are ignored (e.g. `user.watching.start/stop`) +- Unread UI state updates throttled separately (200ms) +- `markRead` throttled 500ms with `{ leading: true, trailing: false }` β€” fires on the FIRST call only +- `loadMore`/`loadMoreNewer` completion debounced 2000ms +- Message visibility in threads is decided by `parent_id` + `show_in_channel` + +### 3. Message enrichment pipeline + +**File:** `src/components/MessageList/utils.ts` (`processMessages`) + +Per message, in order: deleted messages filtered (`hideDeletedMessages`) β†’ giphy `ephemeral` preview extracted (`setGiphyPreviewMessage`, VirtualizedMessageList) β†’ unread separator (skipped for the current user's own messages) β†’ date separator inserted (first message, date change, or when hidden deleted messages shifted the last rendered date) β†’ `reviewProcessedMessage` hook may rewrite the emitted slice. + +Date separators are enabled in `MessageList` and disabled in `VirtualizedMessageList` and threads by default. Group styling (`getGroupStyles`) is applied separately, keyed on user ID + time gaps. + +**Gotcha:** with `hideDeletedMessages=true`, a date separator is still required when the next rendered message falls on a different date than the last separator. + +### 4. Virtualization strategy + +**Files:** `src/components/MessageList/VirtualizedMessageList.tsx`, `VirtualizedMessageListComponents.tsx` + +- Built on **react-virtuoso** with custom item sizing +- **Offset trick:** `PREPEND_OFFSET = 10 ** 7` lets prepended messages work without Virtuoso knowing (`calculateItemIndex` / `calculateFirstItemIndex`) +- Only visible items + overscan render +- `skipMessageDataMemoization` exists for channels with thousands of messages + +`ThreadList` and `ChannelDetail` lists are virtualized too β€” see `src/a11y/hooks/useVirtualizedListboxKeyboardNavigation.ts` for the keyboard-nav contract those lists must honor. + +### 5. Performance: memoization & throttling + +- `useCreateChannelStateContext` serializes message data to a **string** for comparison (type, `deleted_at`, reaction types, `pinned`, `reply_count`, `status`, `updated_at`, `user.updated_at`). **Any field not in that serialization will not trigger updates** β€” a known fragility, flagged with a FIXME in the source. +- `areMessageUIPropsEqual` (`src/components/Message/utils.tsx`) checks cheap props first (`highlighted`, `threadList`, `endOfGroup`, `mutes.length`, `readBy.length`, `deliveredTo.length`, `groupStyles`) before deep message comparison. + +## Critical gotchas & invariants + +### DO NOT: + +1. **Mutate `channel.state.messages` directly** β€” use `channel.state.addMessageSorted()` / `removeMessage()` +2. **Include `channel` in dependency arrays** β€” use `channel.cid` (stable), never `channel.state` (changes constantly) +3. **Modify reducer action types without updating all dispatchers** β€” they are tightly coupled +4. **Change message sort order** β€” the SDK maintains order; local changes conflict +5. **Forget to update both channel AND thread state** β€” thread messages must exist in main state too + +### Thread state synchronization + +- Main channel: `state.messages` (flat list) +- Threads: `channel.state.threads[parentId]` (keyed by parent message ID) +- **Invariant:** messages in threads MUST also exist in main channel state + +### React version compatibility + +The SDK supports **React 17, 18, 19**. Enforced by the `react-compat` block in `eslint.config.mjs` β€” forbidden in `src/`: -- Never commit directly to main, always create a feature branch. +- `useId` from `react` β†’ use `useStableId` from `src/components/UtilityComponents/useStableId` +- `useSyncExternalStore` from `react` β†’ use the shim from `use-sync-external-store/shim` +- `useEffectEvent`, `use()` β†’ React 19-only, not allowed +- `ref` in a prop type (`TSPropertySignature[key.name='ref']`) or destructured from props β†’ use `forwardRef` (React 17/18 only deliver `ref` to forwardRef'd components) + +Compatibility is lint-enforced only; there is no type/runtime matrix across React versions. + +### Context dependency gotcha + +```ts +useMemo( + () => ({ + /* value */ + }), + [ + channel.cid, // βœ… Stable - include this + deleteMessage, // βœ… Stable callback + // ❌ NOT channel.state.messages - causes infinite re-renders + // ❌ NOT channel.initialized - changes constantly + ], +); +``` + +## Testing + +**Policy:** add or extend tests in the matching module's `__tests__/` folder. Cover React components, hooks, and utility functions. Reuse the repo's fakes/mocks instead of hand-rolling new ones. + +**Runner:** Vitest (`vitest.config.ts`) β€” `globals: true` (no imports needed for `describe`/`it`/`expect`/`vi`), `jsdom`, `pool: 'forks'`, `testTimeout: 15000`, `css: false`, tests matched at `src/**/*.test.{js,jsx,ts,tsx}`. `vitest.setup.ts` forces `TZ=UTC`, registers `@testing-library/jest-dom/vitest` + `vitest-axe` matchers, and polyfills `crypto`, `structuredClone`, `File`, `FileReader`, `URL.createObjectURL`, `matchMedia`, and canvas `getContext`. + +Import test helpers from `src/mock-builders` (also aliased as `mock-builders`): + +```ts +// Fastest path: client + watched channels in one call +const { + client, + channels: [channel], +} = await initClientWithChannels(); + +// 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); +await channel.watch(); +``` + +- `src/mock-builders/generator/` β€” `generateChannel`, `generateMessage`, `generateUser`, `generateMember`, `generatePoll`, `generateMessageDraft`, `generateReminder`, `generateSharedLocation`, … +- `src/mock-builders/api/` β€” response builders (`getOrCreateChannelApi`, `queryChannelsApi`, `sendMessageApi`, `markReadApi`, `threadRepliesApi`, error helpers); `useMockedApis` spies on `client.axiosInstance` +- `src/mock-builders/event/` β€” `dispatchMessageNewEvent`, `dispatchNotificationMarkUnread`, … +- `src/mock-builders/context.ts` β€” `mockChatContext`, `mockChannelStateContext`, … built with `fromPartial` from `@total-typescript/shoehorn` +- `src/mock-builders/browser/` β€” `MediaRecorder`, `AudioContext`, `AnalyserNode`, `ResizeObserver`, `HTMLMediaElement` fakes +- Accessibility: `import { axe } from '/axe-helper'` (root `axe-helper.js` wraps `configureAxe`), then `expect(await axe(container)).toHaveNoViolations()` + +Component render shape: + +```tsx +render( + + + + + , +); +``` + +Mock modules with `vi.mock('../../EmptyStateIndicator', () => ({ … }))`; use `importOriginal()` to partially mock. Mock methods on the channel/client, never replace the whole object. + +## Build system + +`yarn build` = `yarn clean` + 4 steps in parallel via `concurrently`, each writing to a separate `dist/` subdirectory: + +1. **`build-translations`** β€” `i18next-cli extract` pulls `t()` calls from source into `src/i18n/*.json` +2. **`vite build`** β€” bundles 4 entry points as ESM (`dist/es/*.mjs`) + CJS (`dist/cjs/*.js`) +3. **`tsc -p tsconfig.lib.json`** β€” `.d.ts` only β†’ `dist/types/` +4. **`build-styling`** β€” Sass β†’ `dist/css/index.css`, `emoji-replacement.css`, `emoji-picker.css`, `channel-detail.css`, plus `cp -r src/styling/assets dist/css/assets` + +**Entry points** (`package.json` exports ↔ `vite.config.ts` `lib.entry`): + +| Import path | Source | +| ---------------------------------- | ----------------------------- | +| `stream-chat-react` | `src/index.ts` | +| `stream-chat-react/channel-detail` | `src/plugins/ChannelDetail/` | +| `stream-chat-react/emojis` | `src/plugins/Emojis/` | +| `stream-chat-react/mp3-encoder` | `src/plugins/encoders/mp3.ts` | +| `stream-chat-react/css/*` | `dist/css/*` | + +Vite 8 / Rolldown specifics baked into `vite.config.ts` (do not "simplify" these): + +- Output dirs are **hardcoded** to `es`/`cjs` β€” the `[format]` placeholder expands to `esm` under Rolldown, which would break `package.json` `exports` +- Externals are regexes (`^dep(\/.+)?$`) so **subpath** imports (`dayjs/locale/de`) stay external; otherwise CJS `require()` glue leaks into the ESM output +- No minification, sourcemaps on, target from `tsconfig.lib.json` (`es2020`), all deps/peerDeps externalized +- Rolldown's strict CJS interop means default-imported CJS deps may need `.default` unwrapping at the call site + +## Styling architecture + +All styles live in `src/styling/` (entry: `src/styling/index.scss`) and per-component `src/components/*/styling/index.scss`, `@use`d by the master stylesheet. Nothing is pulled from an external design-system package. Never edit compiled CSS. + +### CSS layers + +Consumers order layers so overrides win without `!important`. Reference implementation β€” `examples/vite/src/index.scss`: + +```scss +@layer modern-normalize, stream-new, stream-new-plugins, stream-overrides, stream-app-overrides; + +@import url('modern-normalize') layer(modern-normalize); +@import url('stream-chat-react/dist/css/index.css') layer(stream-new); +@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); +``` + +### Theming variables (3 tiers) + +1. **Primitives** β€” `src/styling/variables/` (fonts, shadows) + Figma-sourced palette tokens +2. **Semantic tokens** β€” `src/styling/variable-tokens.scss` with `light.scss` / `dark.scss` mappings (e.g. `--str-chat__primary-color`, `--str-chat__text-color`) +3. **Component tokens** β€” per-component SCSS (e.g. `--str-chat__message-bubble-background-color`) + +## i18n system + +- **12 locales** in `src/i18n/*.json`: de, en, es, fr, hi, it, ja, ko, nl, pt, ru, tr +- **Keys are English text**: `t('Mute')`, `t('{{ user }} is typing...')` +- `i18next.config.ts` sets `keySeparator: false` and `nsSeparator: false`, so keys may contain `/` and `:` literally (e.g. `timestamp/DateSeparator`). `timestamp/*` keys are listed under `preservePatterns` and are not pruned; `removeUnusedKeys: false` +- Extraction: `yarn build-translations` (scans `src/**/*.{ts,tsx}`, ignores `__tests__` and `mock-builders`) +- Validation: `yarn validate-translations` runs inside `yarn lint` and in CI β€” **zero tolerance for empty translation values** +- `Streami18n` (`src/i18n/Streami18n.ts`) wraps i18next + Dayjs with per-locale calendar formats; access `t` via `useTranslationContext()` (only works inside ``) +- Adding a string: use `t()` β†’ run `yarn build-translations` β†’ fill in all 12 files + +## Accessibility + +`src/a11y/` holds cross-component a11y primitives: `useAriaIdentifiers`, `useListboxKeyboardNavigation`, `useVirtualizedListboxKeyboardNavigation`, `useResolvedModalAriaProps`, plus `accessibleLabel.ts` / `a11yUtils.ts`. Related components: `Accessibility/`, `SkipNavigation/`, `VisuallyHidden/`. New interactive UI should reuse these hooks and ship an `axe` assertion in its tests. + +## Module boundaries & coupling + +**Tightest coupling:** + +1. `Message.tsx` ↔ `MessageContext` β€” every message needs actions +2. `Channel.tsx` ↔ `VirtualizedMessageList` β€” complex prop drilling +3. `useCreateChannelStateContext` ↔ message memoization β€” string-serialization fragility +4. `MessageComposer` ↔ `stream-chat`'s `MessageComposer` class + `client.messageComposerCache` + +**Integration risks:** reducer action changes ripple across dispatchers; message sorting changes conflict with SDK updates; thread state isolation is error-prone. + +## Code organization standards + +``` +ComponentName/ +β”œβ”€β”€ ComponentName.tsx +β”œβ”€β”€ hooks/ # Component-specific hooks +β”œβ”€β”€ styling/ # SCSS (index.scss aggregates) +β”œβ”€β”€ utils/ or utils.ts +β”œβ”€β”€ __tests__/ +└── index.ts +``` + +Component-specific hooks stay in the component's `hooks/`: `Channel/hooks/` (state context, typing, editing), `Message/hooks/` (delete, pin, flag, react, retry, reminders), `MessageComposer/hooks/` (controller, bindings, submit, attachments, cooldown), `MessageList/hooks/` (scroll, mark-read, last-read/delivered). + +Lint rules worth knowing (enforced with `--max-warnings 0`): `sort-keys`, `sort-destructure-keys`, `react/jsx-sort-props`, `@typescript-eslint/consistent-type-imports`, `react-hooks/exhaustive-deps` as **error**, no non-null assertions in `src/` (relaxed in tests). + +## Contribution rules + +### Linting & formatting + +Run `yarn lint-fix` before every commit. Follow the "zero warnings" policy β€” fix new warnings, never introduce any. + +### Commits + +[Conventional Commits](https://www.conventionalcommits.org/), enforced by commitlint via the `commit-msg` husky hook: + +``` +feat(MessageComposer): add audio recording support + +Implement MediaRecorder API integration with MP3 encoding. + +Closes #123 +``` + +- Avoid `BREAKING CHANGE` footers and `!` β€” ship changes as semver minors. +- Never commit directly to `master`; always create a feature branch (see `developers/BRANCHES.md`). - Never commit unless explicitly requested. -- Keep PRs small and focused; include tests. -- Follow the project’s β€œzero warnings” policyβ€”fix new warnings and avoid introducing any. -- For UI changes, attach comparison screenshots (before/after) where feasible. -- Ensure public API changes include docs. -- Follow the @.github/pull_request_template.md when opening PRs. -#### Testing policy +The **pre-commit hook** runs `lint-staged`: eslint (`--max-warnings 0`) on staged `src/**`, prettier `--list-different` on all supported files, and translation validation on `src/i18n/*.json`. `yarn fix-staged` attempts auto-fix. -Add/extend tests in the matching module’s `__tests__`/ folder. +### Pull requests -Cover: +Follow `.github/pull_request_template.md` (Goal / Implementation details / UI Changes). Keep PRs small and focused; include tests. -- React components -- React hooks -- Utility functions -- Use fakes/mocks from the test helpers provided by the repo when possible. +- [ ] `yarn lint-fix` passed +- [ ] `yarn test` passed +- [ ] `yarn types` passed (and no new errors from `yarn types:tests`) +- [ ] Tests added for changes +- [ ] No new warnings (zero tolerance) +- [ ] Screenshots (before/after) for UI changes +- [ ] Public API changes documented -#### Docs & samples +**CI** (`.github/workflows/ci.yml`): lint Β· build + `validate-cjs` + `validate-esm` + `validate-translations` Β· `yarn coverage` β†’ Codecov Β· deploy `examples/vite` to Vercel. -- When altering public API, update inline docs and any affected guide pages in the docs site where this repo is the source of truth. -- Keep sample/snippet code compilable. +**Release:** automated via semantic-release (`.releaserc.json`) from commit messages. -#### Security & credentials +### Deprecations -- Never commit API keys or customer data. -- Example code must use obvious placeholders (e.g., YOUR_STREAM_KEY). -- If you add scripts, ensure they fail closed on missing env vars. +Use the `@deprecated` JSDoc tag with a reason and docs link; commit under the `deprecate` type. Full process in `developers/DEPRECATIONS.md`. -#### When in doubt +### Docs & samples -- Mirror existing patterns in the nearest module. -- Prefer additive changes; avoid breaking public APIs. -- Ask maintainers (CODEOWNERS) through PR mentions for modules you touch. +When altering public API, update inline docs and any affected guide pages where this repo is the source of truth. Keep sample/snippet code compilable. ---- +### Security & credentials + +Never commit API keys or customer data. Example code must use obvious placeholders (e.g. `YOUR_STREAM_KEY`). Scripts must fail closed on missing env vars. + +### When in doubt -Quick agent checklist (per commit) +Mirror existing patterns in the nearest module. Prefer additive changes; avoid breaking public APIs. Ask maintainers (`CODEOWNERS`) through PR mentions for modules you touch. -- Build the src -- Run all tests and ensure green -- Run lint commands -- Update docs if public API changed -- Add/adjust tests -- No new warnings +## References + +- **Development guides:** `developers/` +- **Component docs:** https://getstream.io/chat/docs/sdk/react/ +- **Stream Chat API:** https://getstream.io/chat/docs/javascript/ +- **Stream agent skills** (installed via `getstream init`): https://getstream.io/agent-skills/docs/installation/ + +--- -End of machine guidance. Edit this file to refine agent behavior over time; keep human-facing details in README.md and docs. +End of machine guidance. Edit this file to refine agent behavior over time; keep human-facing details in `README.md` and the docs site. diff --git a/AI.md b/AI.md deleted file mode 100644 index 0fdb9ed29d..0000000000 --- a/AI.md +++ /dev/null @@ -1,423 +0,0 @@ -# Stream Chat React Integration Guide for AI Assistants - -This guide helps AI assistants provide accurate integration instructions when users ask to "integrate stream-chat-react" or similar vague commands. - -## Quick Start Integration Pattern - -When a user wants to integrate stream-chat-react, follow this standard pattern: - -### 1. Installation - -```bash -npm install stream-chat stream-chat-react -# or -yarn add stream-chat stream-chat-react -``` - -### 2. Get Your Credentials - -Before setting up the chat client, you'll need: - -- **API Key**: Get your API key from the [Stream Dashboard](https://dashboard.getstream.io/) -- **User Token**: For development purposes, you can generate a user token manually using the [Token Generator](https://getstream.io/chat/docs/php/tokens_and_authentication/#manually-generating-tokens) - - **Note**: Manual token generation is for development/testing only. For production, generate tokens server-side using your Stream API secret. - -### 3. Basic Setup (Minimal Working Example) - -The minimal integration requires: - -- Stream Chat client setup -- Chat component wrapper -- Channel component with basic UI - -```tsx -import { Chat, useCreateChatClient } from 'stream-chat-react'; -import 'stream-chat-react/dist/css/v2/index.css'; - -// Get your API key from: https://dashboard.getstream.io/ -// For development, generate a token at: https://getstream.io/chat/docs/php/tokens_and_authentication/#manually-generating-tokens -const apiKey = 'YOUR_API_KEY'; -const userId = 'YOUR_USER_ID'; -const userName = 'YOUR_USER_NAME'; -const userToken = 'YOUR_USER_TOKEN'; - -const App = () => { - const client = useCreateChatClient({ - apiKey, - tokenOrProvider: userToken, - userData: { id: userId, name: userName }, - }); - - if (!client) return
Setting up client & connection...
; - - return Chat with client is ready!; -}; -``` - -### 4. Complete Chat UI Setup - -For a full-featured chat interface: - -```tsx -import type { ChannelFilters, ChannelOptions, ChannelSort, User } from 'stream-chat'; -import { - Chat, - Channel, - ChannelHeader, - ChannelList, - MessageInput, - MessageList, - Thread, - Window, - useCreateChatClient, -} from 'stream-chat-react'; -import 'stream-chat-react/dist/css/v2/index.css'; - -// Get your API key from: https://dashboard.getstream.io/ -// For development, generate a token at: https://getstream.io/chat/docs/php/tokens_and_authentication/#manually-generating-tokens -const apiKey = 'YOUR_API_KEY'; -const userId = 'YOUR_USER_ID'; -const userName = 'YOUR_USER_NAME'; -const userToken = 'YOUR_USER_TOKEN'; - -const user: User = { - id: userId, - name: userName, - image: `https://getstream.io/random_png/?name=${userName}`, -}; - -const sort: ChannelSort = { last_message_at: -1 }; -const filters: ChannelFilters = { - type: 'messaging', - members: { $in: [userId] }, -}; -const options: ChannelOptions = { - limit: 10, -}; - -const App = () => { - const client = useCreateChatClient({ - apiKey, - tokenOrProvider: userToken, - userData: user, - }); - - if (!client) return
Setting up client & connection...
; - - return ( - - - - - - - - - - - - ); -}; -``` - -## Common Integration Scenarios - -### Scenario 1: New React App (Vite/CRA) - -**User intent**: "Add stream-chat-react to my React app" - -**Steps**: - -1. Install packages: `npm install stream-chat stream-chat-react` -2. Get credentials: - - API key from [Stream Dashboard](https://dashboard.getstream.io/) - - User token (for development): Generate at [Token Generator](https://getstream.io/chat/docs/php/tokens_and_authentication/#manually-generating-tokens) -3. Import CSS: `import 'stream-chat-react/dist/css/v2/index.css'` -4. Set up client using `useCreateChatClient` hook -5. Wrap app with `` component -6. Add `` with ``, ``, `` - -**Reference**: See `examples/tutorial/` for step-by-step examples - -### Scenario 2: Add Chat to Existing App - -**User intent**: "Integrate chat into my existing React application" - -**Steps**: - -1. Install packages -2. Get credentials: - - API key from [Stream Dashboard](https://dashboard.getstream.io/) - - User token (for development): Generate at [Token Generator](https://getstream.io/chat/docs/php/tokens_and_authentication/#manually-generating-tokens) -3. Import CSS (preferably in a CSS layer for proper override precedence) -4. Create a separate chat component or route -5. Initialize client once at app level (use `useCreateChatClient` only once) -6. Access client elsewhere using `useChatContext()` hook - -**Important**: The client should be created once and reused. Don't create multiple clients. - -### Scenario 3: Custom Styling - -**User intent**: "Customize the chat appearance" - -**Steps**: - -1. Import Stream CSS into a CSS layer -2. Create custom theme using CSS variables -3. Apply theme via `theme` prop on `` component - -```css -@layer base, theme; -@import 'stream-chat-react/dist/css/v2/index.css' layer(base); - -@layer theme { - .str-chat__theme-custom { - --str-chat__primary-color: #009688; - --str-chat__surface-color: #f5f5f5; - /* ... more variables */ - } -} -``` - -```tsx - - {/* ... */} - -``` - -**Reference**: See theming documentation and `examples/vite/src/stream-imports-theme.scss` - -### Scenario 4: Custom Components - -**User intent**: "Customize message or channel preview appearance" - -**Steps**: - -1. Create custom component matching the prop interface -2. Pass custom component via props (e.g., `Message`, `ChannelPreview`, `Attachment`) -3. Use hooks like `useMessageContext()` to access data - -```tsx -const CustomMessage = () => { - const { message } = useMessageContext(); - return ( -
- {message.user?.name}: {message.text} -
- ); -}; - -{/* ... */}; -``` - -**Reference**: See `examples/tutorial/src/4-custom-ui-components/` - -### Scenario 5: Livestream Chat - -**User intent**: "Create a livestream-style chat" - -**Steps**: - -1. Use `livestream` channel type (disables typing indicators, read receipts) -2. Use `VirtualizedMessageList` instead of `MessageList` for performance -3. Apply dark theme: `theme="str-chat__theme-dark"` -4. Set `live` prop on `ChannelHeader` - -```tsx - - - - - - - - - -``` - -**Reference**: See `examples/tutorial/src/7-livestream/` - -### Scenario 6: Emoji Support - -**User intent**: "Add emoji picker and autocomplete" - -**Steps**: - -1. Install emoji packages: `npm install emoji-mart @emoji-mart/react @emoji-mart/data` -2. Initialize emoji data: `init({ data })` from `emoji-mart` -3. Import `EmojiPicker` from `stream-chat-react/emojis` -4. Pass `EmojiPicker` and `emojiSearchIndex={SearchIndex}` to `Channel` - -```tsx -import { EmojiPicker } from 'stream-chat-react/emojis'; -import { init, SearchIndex } from 'emoji-mart'; -import data from '@emoji-mart/data'; - -init({ data }); - - - {/* ... */} -; -``` - -**Note**: For React 19, may need package.json overrides for `@emoji-mart/react` - -**Reference**: See `examples/tutorial/src/6-emoji-picker/` - -## TypeScript Setup - -For custom properties on channels, messages, attachments, etc., create a declaration file: - -```ts -// stream-chat.d.ts -import { DefaultChannelData, DefaultAttachmentData } from 'stream-chat-react'; - -declare module 'stream-chat' { - interface CustomChannelData extends DefaultChannelData { - image?: string; - name?: string; - } - - interface CustomAttachmentData extends DefaultAttachmentData { - image?: string; - name?: string; - url?: string; - } -} -``` - -## Layout Styling - -Basic layout CSS for proper component positioning: - -```css -html, -body, -#root { - height: 100%; -} -body { - margin: 0; -} -#root { - display: flex; -} - -.str-chat__channel-list { - width: 30%; -} -.str-chat__channel { - width: 100%; -} -.str-chat__thread { - width: 45%; -} -``` - -## Key Components Reference - -### Core Components - -- `` - Root provider, wraps entire chat app -- `` - Channel context provider -- `` - Displays list of channels -- `` - Displays messages in channel -- `` - Input for sending messages -- `` - Thread/reply view -- `` - Wrapper for channel UI -- `` - Virtualized message list for high volume - -### Utility Components - -- `` - Channel header with info -- `` - Renders message attachments - -### Hooks - -- `useCreateChatClient()` - Creates and connects client (use once per app) -- `useChatContext()` - Access client instance -- `useMessageContext()` - Access current message data -- `useChannelContext()` - Access current channel data - -## Common Issues & Solutions - -### Issue: Client not connecting - -**Solution**: Ensure `useCreateChatClient` returns a client before rendering ``. Show loading state while `client` is `null`. - -### Issue: Styles not applying - -**Solution**: - -- Import CSS: `import 'stream-chat-react/dist/css/v2/index.css'` -- Use CSS layers for proper override precedence -- Check CSS import order - -### Issue: Multiple clients created - -**Solution**: Use `useCreateChatClient` only once at app root. Use `useChatContext()` to access client elsewhere. - -### Issue: TypeScript errors for custom properties - -**Solution**: Create `stream-chat.d.ts` file with proper type declarations (see TypeScript Setup section). - -### Issue: Emoji picker not working - -**Solution**: - -- Ensure emoji packages are installed -- Initialize with `init({ data })` before rendering -- For React 19, add package.json overrides if needed - -## Resources - -- **Official Tutorial**: https://getstream.io/chat/react-chat/tutorial/ -- **Tutorial Source**: https://raw.githubusercontent.com/GetStream/getstream.io-tutorials/refs/heads/main/chat/tutorials/react-tutorial.mdx -- **Component Docs**: https://getstream.io/chat/docs/sdk/react/ -- **Examples in Repo**: `examples/tutorial/` (step-by-step), `examples/vite/` (complete example) -- **API Docs**: https://getstream.io/chat/docs/javascript/ -- **Get API Key**: https://dashboard.getstream.io/ -- **Token Generator (Development)**: https://getstream.io/chat/docs/php/tokens_and_authentication/#manually-generating-tokens - -## Package Information - -- **Package Name**: `stream-chat-react` -- **Peer Dependencies**: - - `react`: ^19.0.0 || ^18.0.0 || ^17.0.0 - - `react-dom`: ^19.0.0 || ^18.0.0 || ^17.0.0 - - `stream-chat`: ^9.27.2 -- **Optional Dependencies** (for emoji support): - - `emoji-mart`: ^5.4.0 - - `@emoji-mart/react`: ^1.1.0 - - `@emoji-mart/data`: ^1.1.0 - -## Best Practices - -1. **Client Creation**: Create client once at app root, reuse via context -2. **CSS Layers**: Use CSS layers for proper style override precedence -3. **Loading States**: Always check if client is ready before rendering chat components -4. **Type Safety**: Use TypeScript declaration files for custom properties -5. **Performance**: Use `VirtualizedMessageList` for high message volume scenarios -6. **Theming**: Use CSS variables and theme classes rather than direct CSS overrides -7. **Credentials**: Never hardcode credentials in production; use environment variables - -## Integration Checklist - -When helping users integrate, ensure: - -- [ ] Packages installed (`stream-chat`, `stream-chat-react`) -- [ ] API key obtained from [Stream Dashboard](https://dashboard.getstream.io/) -- [ ] User token generated (for development: use [Token Generator](https://getstream.io/chat/docs/php/tokens_and_authentication/#manually-generating-tokens)) -- [ ] CSS imported (`stream-chat-react/dist/css/v2/index.css`) -- [ ] Client created with `useCreateChatClient` (once, at app root) -- [ ] Loading state handled (check `if (!client)`) -- [ ] `` component wraps chat UI -- [ ] At minimum: `` with ``, ``, `` -- [ ] Layout CSS added if needed (for proper positioning) -- [ ] TypeScript declarations added if using custom properties -- [ ] Theme applied if customizing appearance -- [ ] Credentials properly configured (API key, user token, etc.) - ---- - -**Note for AI Assistants**: When users ask vague questions like "integrate stream-chat-react", start with the Quick Start Integration Pattern above. Ask clarifying questions about their use case (new app vs existing, styling needs, features required) to provide the most relevant scenario from Common Integration Scenarios. diff --git a/CLAUDE.md b/CLAUDE.md index 8f431c1dd5..c504304a45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,388 +2,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Quick Reference +All guidance lives in `AGENTS.md` β€” the single source shared with every other agent (Copilot, Cursor, Codex, …). The import below pulls it in; do not duplicate content here. -**Repository:** Stream's React Chat SDK - 40+ React components for building chat UIs with the Stream Chat API. - -**Key Files:** - -- `AI.md` - Integration patterns for users -- `AGENTS.md` - Repository structure & contribution workflow -- `developers/` - Detailed development guides - -## Essential Commands - -```bash -# Development (requires Node 24 β€” see .nvmrc) -# Yarn 4 is committed to .yarn/releases/ and activated via .yarnrc.yml -# (yarnPath). Any globally installed `yarn` shim launches it; no Corepack. -yarn install # Setup (installs root + examples/* workspaces) -yarn build # Full build (translations, Vite, types, SCSS) -yarn test # Run Jest tests -yarn test # Run specific test (e.g., yarn test Channel) -yarn lint-fix # Fix all lint/format issues (prettier + eslint) -yarn types # TypeScript type checking (noEmit mode) - -# Examples (workspaces under examples/*) -yarn start:tutorial # Start the tutorial example dev server -yarn start:vite # Start the vite example dev server -yarn examples:build # Build all examples - -# E2E -yarn e2e-fixtures # Generate e2e test fixtures -yarn e2e # Run Playwright tests - -# Before committing -yarn lint-fix # ALWAYS run this first -``` - -## Architecture: Core Concepts - -### Component Hierarchy - -``` - # Root: provides client, theme, i18n - └─ # State container: messages, threads, WebSocket events - β”œβ”€ # Renders messages (or ) - β”œβ”€ # Composer with attachments/mentions - └─ # Threaded replies -``` - -### Context Layers (14+ contexts) - -``` -ChatContext # Client, active channel, theme, navigation -β”œβ”€ ChannelStateContext # Read-only: messages, members, loading states -β”œβ”€ ChannelActionContext # Write: sendMessage, deleteMessage, openThread -β”œβ”€ ComponentContext # 100+ customizable component slots -└─ MessageContext # Per-message: actions, reactions, status -``` - -**All contexts have hooks:** `useChatContext()`, `useChannelStateContext()`, etc. - -### State Management (Multi-Layer) - -1. **Local state** (`useState`) - Component UI state -2. **Reducer state** (`useReducer`) - Channel uses `makeChannelReducer` for complex message state -3. **Context state** - Global shared state -4. **External state** - `stream-chat` SDK's StateStore via `useStateStore` hook (uses `useSyncExternalStore`) - -## Critical Architectural Patterns - -### 1. Optimistic Updates & Race Conditions - -**File:** `src/components/Channel/Channel.tsx` + `channelState.ts` - -- Messages are added to local state IMMEDIATELY when sending (optimistic) -- WebSocket events may arrive before/after API response -- **Timestamp-based conflict resolution:** Newest version always wins -- **Gotcha:** Thread state is separate from channel state - both must be updated - -### 2. WebSocket Event Processing - -**File:** `src/components/Channel/Channel.tsx` (`handleEvent` function) - -```ts -// Events are THROTTLED to 500ms to prevent excessive re-renders -throttledCopyStateFromChannel = throttle( - () => dispatch({ type: 'copyStateFromChannelOnEvent' }), - 500, - { leading: true, trailing: true }, -); -``` - -**Key behaviors:** - -- Some events ignored: `user.watching.start/stop` -- Unread updates throttled separately (200ms) -- Message filtering: `parent_id` + `show_in_channel` determine thread visibility - -### 3. Message Enrichment Pipeline - -**File:** `src/components/MessageList/utils.ts` - -Messages are processed in order: - -1. Date separator insertion (by date comparison) -2. Unread separator (only for other users' messages) -3. Deleted messages filtered/kept based on config -4. Giphy preview extraction (for VirtualizedMessageList) -5. Group styling applied (user ID + time gaps) - -**Gotcha:** If `hideDeletedMessages=true`, date separators still needed when next message has different date. - -### 4. Virtualization Strategy - -**Files:** `src/components/MessageList/VirtualizedMessageList.tsx` + `VirtualizedMessageListComponents.tsx` - -- Uses **react-virtuoso** with custom item sizing -- **Offset trick:** `PREPEND_OFFSET = 10^7` in `VirtualizedMessageListComponents.tsx` handles prepended messages without Virtuoso knowing -- Only visible items + overscan buffer rendered -- `skipMessageDataMemoization` prop exists for channels with 1000s of messages - -### 5. Performance: Memoization & Throttling - -**Critical memoization:** - -- Message data serialized to string for comparison (see `useCreateChannelStateContext`) -- `areMessageUIPropsEqual` checks cheap props first (highlighted, mutes.length) -- **Gotcha:** Any prop not in serialization won't trigger updates! - -**Throttling locations:** - -- WebSocket events: 500ms -- Unread counter updates: 200ms -- `markRead`: 500ms (leading: true, trailing: false - only fires on FIRST call) -- `loadMoreFinished`: 2000ms debounced - -## Critical Gotchas & Invariants - -### DO NOT: - -1. **Mutate `channel.state.messages` directly** - Use `channel.state.addMessageSorted()` / `removeMessage()` -2. **Include `channel` in dependency arrays** - Use `channel.cid` only (stable), not `channel.state` (changes constantly) -3. **Modify reducer action types without updating all dispatchers** - They're tightly coupled -4. **Change message sort order** - SDK maintains order; local changes will conflict -5. **Forget to update both channel AND thread state** - Thread messages must exist in main state too - -### Thread State Synchronization - -- Main channel: `state.messages` (flat list) -- Threads: `state.threads[parentId]` (keyed by parent message ID) -- **Invariant:** Messages in threads MUST also exist in main channel state - -### React Version Compatibility - -SDK supports **React 17, 18, 19**. - -**Forbidden in `src/`** (enforced by the `react-compat` block in `eslint.config.mjs`): - -- `useId` from `react` β†’ use `useStableId` from `src/components/UtilityComponents/useStableId` -- `useSyncExternalStore` from `react` β†’ use the shim from `use-sync-external-store/shim` -- `useEffectEvent`, `use()` β†’ not allowed (React 19-only) -- `ref` declared in a prop type or destructured from props β†’ use `forwardRef` (React 17/18 only deliver `ref` to forwardRef'd components) - -### Context Dependency Gotcha - -```ts -useMemo( - () => ({ - /* value */ - }), - [ - channel.cid, // βœ… Stable - include this - deleteMessage, // βœ… Stable callback - // ❌ NOT channel.state.messages - causes infinite re-renders - // ❌ NOT channel.initialized - changes constantly - ], -); -``` - -## Testing Patterns - -### Mock Builder Pattern - -**File:** `src/mock-builders/` - -```ts -// Standard test setup -const chatClient = await getTestClientWithUser({ id: 'test-user' }); -useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannelData)]); -const channel = chatClient.channel('messaging', channelId); -await channel.watch(); -``` - -**Key mocks:** - -- `client.connectionId = 'dummy_connection_id'` -- `client.wsPromise = Promise.resolve(true)` (mocks WebSocket) -- Mock methods on channel, not entire channel object - -### Component Test Structure - -```tsx -render( - - - - - , -); -``` - -## Module Boundaries & Coupling - -**Tightest Coupling:** - -1. `Message.tsx` ↔ `MessageContext` - Every message needs actions -2. `Channel.tsx` ↔ `VirtualizedMessageList` - Complex prop drilling -3. `useCreateChannelStateContext` ↔ Message memoization - String serialization fragility - -**Integration Risks:** - -- Modifying reducer actions requires updates in multiple dispatchers -- Changing message sorting conflicts with SDK updates -- Thread state isolation is error-prone - -## Code Organization Standards - -**Component structure:** - -``` -ComponentName/ -β”œβ”€β”€ ComponentName.tsx -β”œβ”€β”€ hooks/ # Component-specific hooks -β”œβ”€β”€ styling/ # SCSS files -β”œβ”€β”€ utils/ # Component utilities -β”œβ”€β”€ __tests__/ # Tests -└── index.ts -``` - -**Hook organization:** Component-specific hooks in `hooks/` subdirectories: - -- `Channel/hooks/` - Channel state, typing, editing -- `Message/hooks/` - Actions (delete, pin, flag, react, retry) -- `MessageInput/hooks/` - Input controls, attachments, submission -- `MessageList/hooks/` - Scroll, enrichment, notifications - -## Commit & PR Standards - -**Commit format:** [Conventional Commits](https://www.conventionalcommits.org/) (enforced by commitlint) - -``` -feat(MessageInput): add audio recording support - -Implement MediaRecorder API integration with MP3 encoding. - -Closes #123 -``` - -**PR Requirements:** - -- [ ] `yarn lint-fix` passed -- [ ] `yarn test` passed -- [ ] `yarn types` passed -- [ ] Tests added for changes -- [ ] No new warnings (zero tolerance) -- [ ] Screenshots for UI changes - -**Release:** Automated via semantic-release based on commit messages. - -### Deprecation Pattern - -When deprecating, use `@deprecated` JSDoc tag with reason and docs link. Commit under `deprecate` type. See `developers/DEPRECATIONS.md` for full process. - -## Build System - -The build runs 4 steps in parallel via `concurrently`: - -1. **`build-translations`** β€” Extracts `t()` calls from source via `i18next-cli` -2. **`vite build`** β€” Bundles 3 entry points (index, emojis, mp3-encoder) as CJS + ESM, no minification -3. **`tsc`** β€” Generates `.d.ts` type declarations only (`tsconfig.lib.json`) to `dist/types/` -4. **`build-styling`** β€” Compiles `src/styling/index.scss` β†’ `dist/css/index.css` - -All steps write to separate directories under `dist/` so they don't conflict. - -## Styling Architecture - -All component styles live in `src/styling/` (master entry: `src/styling/index.scss`) and in `src/components/*/styling/index.scss`. The Sass build compiles the tree to `dist/css/index.css`. There is no longer any step that pulls CSS/SCSS from an external design-system package. - -### CSS Layers (cascade order, low β†’ high) - -``` -css-reset β†’ stream-new (compiled index.css) β†’ stream-overrides β†’ stream-app-overrides -``` - -See `examples/vite/src/index.scss` for reference implementation. Layers eliminate the need for `!important`. - -### Theming Variables (3 tiers) - -1. **Primitives** (`src/styling/variables.css`) β€” Figma-sourced: `--slate-50`, `--blue-500`, etc. -2. **Semantic tokens** (`src/styling/_global-theme-variables.scss`) β€” `--str-chat__primary-color`, `--str-chat__text-color` with light/dark variants -3. **Component tokens** (per-component SCSS) β€” `--str-chat__message-bubble-background-color`, etc. - -## i18n System - -- **12 languages**: de, en, es, fr, hi, it, ja, ko, nl, pt, ru, tr (JSON files in `src/i18n/`) -- **Keys are English text**: `t('Mute')`, `t('{{ user }} is typing...')` -- **Extraction**: `i18next-cli extract` scans `t()` calls in source β†’ updates JSON files -- **Validation**: `yarn lint` runs `scripts/validate-translations.js` β€” fails on any empty translation string (zero tolerance) -- **Date/time**: `Streami18n` class wraps i18next + Dayjs with per-locale calendar formats -- **When adding translatable strings**: Use `t()` from `useTranslationContext()`, then run `yarn build-translations` to update JSON files. All 12 language files must have non-empty values. - -## Styling Architecture (Theming & Build Details) - -All styles live in `src/styling/` (master entry: `src/styling/index.scss`) and in `src/components/*/styling/index.scss`. Component styles are imported by the master stylesheet and compiled to `dist/css/index.css` via Sass. - -### CSS Layers & Theming - -CSS layers control cascade order (no `!important` needed): - -``` -css-reset β†’ stream-new (compiled SDK CSS) β†’ stream-overrides β†’ stream-app-overrides -``` - -See `examples/vite/src/index.scss` for the reference layer setup. - -**Theming uses a 3-tier CSS variable hierarchy:** - -1. **Primitives** (`src/styling/variables.css`) β€” Figma-sourced color palette tokens -2. **Semantic tokens** (`src/styling/_global-theme-variables.scss`) β€” Light/dark mode mappings (e.g., `--str-chat__primary-color`) -3. **Component tokens** (per-component SCSS) β€” e.g., `--str-chat__message-bubble-background-color` - -### Build System - -`yarn build` runs 4 tasks in parallel via `concurrently`: - -1. `yarn build-translations` β€” Extracts `t()` calls via `i18next-cli` -2. `vite build` β€” Bundles 3 entry points (index, emojis, mp3-encoder) as ESM + CJS -3. `tsc --project tsconfig.lib.json` β€” Generates `.d.ts` type declarations to `dist/types/` -4. `yarn build-styling` β€” Compiles SCSS to `dist/css/index.css` - -**Library entry points** (from `package.json` exports): - -- `stream-chat-react` β€” Main SDK (all components, hooks, contexts) -- `stream-chat-react/emojis` β€” Emoji picker plugin (`src/plugins/Emojis/`) -- `stream-chat-react/mp3-encoder` β€” MP3 encoding for voice messages (`src/plugins/encoders/mp3.ts`) - -Vite config: no minification, sourcemaps enabled, all deps externalized. Target: ES2020. - -### i18n System - -- 12 languages in `src/i18n/*.json` β€” **Natural language keys** (English text = key) -- `yarn build-translations` extracts `t()` calls from source via `i18next-cli extract` -- `yarn validate-translations` (runs during `yarn lint`) β€” **zero-tolerance: any empty string value fails the build** -- `Streami18n` class (`src/i18n/Streami18n.ts`) wraps i18next, integrates Dayjs for date/time formatting -- Interpolation: `t('Failed to update {{ field }}', { field })`, Plurals: `_one`/`_other` suffixes -- Access via `useTranslationContext()` hook β€” only works inside `` - -## Key Patterns for Development - -### Adding Custom Components - -1. Add to `ComponentContext` (`src/context/ComponentContext.tsx`) -2. Provide default implementation -3. Allow override via prop: `` -4. Access via `useComponentContext()` - -### Using StateStore (for reactive SDK state) - -```typescript -import { useStateStore } from './store'; -const channels = useStateStore(chatClient.state.channelsArray); -``` - -### Adding Translations - -1. Add strings to `src/i18n/` -2. Run `yarn build-translations` -3. Use: `const { t } = useTranslationContext();` - -## References - -- **Integration patterns:** See `AI.md` -- **Repo structure:** See `AGENTS.md` -- **Development guides:** See `developers/` -- **Component docs:** https://getstream.io/chat/docs/sdk/react/ -- **Stream Chat API:** https://getstream.io/chat/docs/javascript/ +@AGENTS.md diff --git a/README.md b/README.md index e78c2bbf91..3867bb3167 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - [Register](https://getstream.io/chat/trial/) to get an API key for Stream Chat - [React Chat Tutorial](https://getstream.io/chat/react-chat/tutorial/) +- [AI Agent Skills](#build-with-ai-agents) for Claude Code, Cursor, and Codex - [Demo Apps](https://getstream.io/chat/demos/) - [Component Docs](https://getstream.io/chat/docs/sdk/react/) - [Chat UI Kit](https://getstream.io/chat/ui-kit/) @@ -37,6 +38,24 @@ With our component library, you can build a variety of chat use cases, including The best way to get started is to follow the [React Chat Tutorial](https://getstream.io/chat/react-chat/tutorial/). It shows you how to use this SDK to build a fully functional chat application and includes common customizations. +## Build with AI Agents + +If you build with an AI coding agent, our [agent skills](https://getstream.io/agent-skills/docs/installation/) teach it how to use this SDK correctly. Install them once: + +```bash +curl -fsSL https://getstream.io/cli.sh | bash +getstream init +``` + +Then reach for the [`/stream-react`](https://getstream.io/agent-skills/docs/skills/stream-react/) skill: + +``` +/stream-react scaffold a Next.js chat app with a channel list and a message view +/stream-react upgrade stream-chat-react to the latest major version +``` + +It can scaffold a new Next.js app with the SDK wired up, add Stream to an app you already have, audit an existing integration, or migrate between SDK major versions (including from Sendbird). Works with Claude Code, Cursor, Codex, and any other agent that reads the universal `.agents` location. + ## Free for Makers Stream is free for most side and hobby projects. To qualify, your project/company must have no more than 5 team members and earn less than $10k in monthly revenue. @@ -119,5 +138,5 @@ You can obtain the source code for `lamejs` from the [lamejs repository](https:/ You can find the source code for LAME at https://lame.sourceforge.net and its license at: https://lame.sourceforge.net/license.txt Using AI assistants (Cursor/Codex/Copilot): -See [AI.md](./AI.md) for integration guide, rules and common pitfalls. See [AGENTS.md](./AGENTS.md) about repository and project structure, contribution guides. +To have an agent integrate this SDK into your own app, see [Build with AI Agents](#build-with-ai-agents). diff --git a/package.json b/package.json index 0b66b7a7d6..858af39247 100644 --- a/package.json +++ b/package.json @@ -132,8 +132,7 @@ "files": [ "dist", "package.json", - "README.md", - "AI.md" + "README.md" ], "devDependencies": { "@breezystack/lamejs": "^1.2.7", @@ -206,7 +205,7 @@ "postinstall": "node -e \"require('fs').existsSync('scripts/install-husky.mjs') && import('./scripts/install-husky.mjs')\"", "test": "vitest run", "test:watch": "vitest", - "types": "tsc --emitDeclarationOnly false --noEmit", + "types": "tsc --project tsconfig.lib.json --noEmit", "types:tests": "tsc --project tsconfig.test.json --noEmit", "validate-translations": "node scripts/validate-translations.js", "validate-cjs": "concurrently 'node scripts/validate-cjs-node-bundle.cjs' 'node scripts/validate-cjs-browser-bundle.cjs'", From f60273f2a157747d49f2eb20702d920b977078fd Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 6 Aug 2026 12:20:06 +0200 Subject: [PATCH 6/9] fix(Channel): guard render-phase channel.getConfig() against disconnected channels (#3257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🎯 Goal Fixes: #3254 `ChannelInner` called `channel.getConfig()` directly in the component body. That call throws `You can't use a channel after client.disconnect() was called` once the channel is disconnected β€” which happens when the current user is removed from a channel or the channel is deleted. The flag is flipped by an async WS event while `` is still mounted, so the throw landed in the render phase and tore down the surrounding subtree. Same failure class as #2393 and #3248. ### πŸ›  Implementation details Added an internal `getChannelConfig(channel)` helper that returns `undefined` for a disconnected channel instead of calling `getConfig()`, and applied it everywhere the config was read during render or in an effect: - `Channel.tsx` β€” the reported crash. Now also a lazy `useState` initializer, so the call no longer re-runs on every render. - `AttachmentSelector.tsx` and `useMessageComposerCommands.ts` - `useMarkRead.ts` `handleEvent` in `Channel.tsx` also early-returns for a disconnected channel, and the composer skips draft creation on unmount. `loadMoreNewer` picked up the `channel.disconnected` guard that `loadMore` already had β€” without it, scrolling to the bottom of a disconnected channel still queried a dead channel on every attempt (caught by the existing `try`/`catch`, so only log noise and a redundant dispatch). Fixing `Channel` alone is not enough: the crash relocates to `AttachmentSelector` once `ChannelInner` stops throwing and its subtree starts rendering. `undefined` is already part of `getConfig()`'s return type, so degradation is graceful β€” no read events, no commands, and the attachment selector renders nothing instead of crashing. > **Overlaps with #3249**, which adds the same `if (messageComposer.channel.disconnected) return;` line along with a more complete treatment of that effect (`.catch()` on `createDraft()`, a drafts-enabled check, and `preventClearingOnUnmount`). #3249 should own that effect β€” the line is kept here only so this PR stays independently mergeable. Whoever merges second should drop the duplicate. 9 tests added, each verified to fail against the unfixed code first. ### 🎨 UI Changes None. ## Summary by CodeRabbit - **Bug Fixes** - Improved stability when channels disconnect. - Prevented pagination, event handling, read receipts, attachment actions, and composer cleanup from triggering errors after disconnection. - Preserved channel configuration safely across re-renders and user deletion scenarios. - **Tests** - Added regression coverage for disconnected-channel behavior across messaging, composer, pagination, and read-state features. --- src/components/Channel/Channel.tsx | 8 +- .../Channel/__tests__/Channel.test.tsx | 86 ++++++++++++++++++- .../AttachmentSelector/AttachmentSelector.tsx | 3 +- .../MessageComposer/MessageComposer.tsx | 4 + .../__tests__/AttachmentSelector.test.tsx | 19 ++++ .../__tests__/MessageInput.test.tsx | 17 ++++ .../useMessageComposerCommands.test.tsx | 11 +++ .../hooks/useMessageComposerCommands.ts | 3 +- .../hooks/__tests__/useMarkRead.test.tsx | 20 +++++ .../MessageList/hooks/useMarkRead.ts | 3 +- src/utils/__tests__/getChannelConfig.test.ts | 27 ++++++ src/utils/getChannelConfig.ts | 9 ++ 12 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 src/utils/__tests__/getChannelConfig.test.ts create mode 100644 src/utils/getChannelConfig.ts diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 9f7c4b9a84..9d031d41cd 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -77,6 +77,7 @@ import { } from './utils'; import { useThreadContext } from '../Threads'; import { getChannel } from '../../utils'; +import { getChannelConfig } from '../../utils/getChannelConfig'; import type { ChannelUnreadUiState, ImageAttachmentSizeHandler, @@ -248,7 +249,7 @@ const ChannelInner = ( const windowsEmojiClass = useImageFlagEmojisOnWindowsClass(); const thread = useThreadContext(); - const [channelConfig, setChannelConfig] = useState(channel.getConfig()); + const [channelConfig, setChannelConfig] = useState(() => getChannelConfig(channel)); const [channelUnreadUiState, _setChannelUnreadUiState] = useState(); @@ -357,6 +358,10 @@ const ChannelInner = ( ); const handleEvent = async (event: Event) => { + // client-level subscriptions keep firing after disconnect, and reading from + // or querying a disconnected channel throws + if (channel.disconnected) return; + if (event.message) { dispatch({ channel, @@ -660,6 +665,7 @@ const ChannelInner = ( const loadMoreNewer = async (limit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE) => { if ( + channel.disconnected || !online.current || !window.navigator.onLine || !channel.state.messagePagination.hasNext diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index 862cd8f734..54b5690470 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -1,9 +1,12 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { nanoid } from 'nanoid'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { ErrorFromResponse, SearchController } from 'stream-chat'; import type { + ChannelAPIResponse, Channel as ChannelType, + Event, + GiphyVersions, LocalMessage, Message, MessageResponse, @@ -807,18 +810,99 @@ describe('Channel', () => { it('does not paginate (query) when the client is disconnected', async () => { let loadMore: ChannelActionContextValue['loadMore'] | undefined; + let loadMoreNewer: ChannelActionContextValue['loadMoreNewer'] | undefined; await renderComponent( { channel, channelQueryOptions: { messages: { limit: 25 } }, chatClient }, (c) => { loadMore = c.loadMore; + loadMoreNewer = c.loadMoreNewer; }, ); + // loadMoreNewer bails out early unless there is a newer page to fetch + channel.state.messagePagination.hasNext = true; + const querySpy = vi.spyOn(channel, 'query'); channel.disconnected = true; await act(async () => { await loadMore?.(); + await loadMoreNewer?.(); + }); + + expect(querySpy).not.toHaveBeenCalled(); + }); + + it('does not throw during render when the channel is disconnected (#3254)', async () => { + // initClient stubs getConfig; restore it so the disconnect guard is reachable + vi.mocked(channel.getConfig).mockRestore(); + + let channelConfig: ChannelStateContextValue['channelConfig'] | 'unset' = 'unset'; + const ConfigProbe = () => { + channelConfig = useChannelStateContext().channelConfig; + return
probe
; + }; + + let setGiphyVersion: (version: GiphyVersions) => void = () => {}; + const Wrapper = () => { + const [giphyVersion, _setGiphyVersion] = useState('fixed_height'); + setGiphyVersion = _setGiphyVersion; + return ( + + + + + + ); + }; + + await act(() => { + render(); + }); + await waitFor(() => expect(screen.getByText('probe')).toBeInTheDocument()); + + channel.disconnected = true; + + // changing a Channel prop bypasses React.memo and re-renders ChannelInner + expect(() => + act(() => { + setGiphyVersion('original'); + }), + ).not.toThrow(); + + expect(screen.getByText('probe')).toBeInTheDocument(); + expect(channelConfig).toEqual(expect.objectContaining({ read_events: true })); + }); + + it('provides an undefined channelConfig when mounting an already disconnected channel (#3254)', async () => { + // must be initialized, otherwise Channel queries on mount and errors instead + await channel.watch(); + vi.mocked(channel.getConfig).mockRestore(); + channel.disconnected = true; + + let channelConfig: ChannelStateContextValue['channelConfig'] | 'unset' = 'unset'; + const ConfigProbe = () => { + channelConfig = useChannelStateContext().channelConfig; + return
probe
; + }; + + await renderComponent({ channel, chatClient, children: }); + + await waitFor(() => expect(screen.getByText('probe')).toBeInTheDocument()); + expect(channelConfig).toBeUndefined(); + }); + + it('does not query a disconnected channel on user.deleted (#3254)', async () => { + await renderComponent({ channel, chatClient }); + + const querySpy = vi + .spyOn(channel, 'query') + .mockResolvedValue(fromPartial({})); + channel.disconnected = true; + + await act(async () => { + chatClient.dispatchEvent(fromPartial({ type: 'user.deleted' })); + await Promise.resolve(); }); expect(querySpy).not.toHaveBeenCalled(); diff --git a/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx b/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx index e1c3c16bef..06d9ea8001 100644 --- a/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx +++ b/src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx @@ -37,6 +37,7 @@ import { AttachmentSelectorContextProvider, useAttachmentSelectorContext, } from '../../../context/AttachmentSelectorContext'; +import { getChannelConfig } from '../../../utils/getChannelConfig'; import { useStableId } from '../../UtilityComponents/useStableId'; import { useInertWhenHidden } from '../../Accessibility'; import { useStateStore } from '../../../store'; @@ -283,7 +284,7 @@ const useAttachmentSelectorActionsFiltered = (original: AttachmentSelectorAction const { channelCapabilities } = useChannelStateContext(); const { isUploadEnabled } = useAttachmentManagerState(); const messageComposer = useMessageComposerController(); - const channelConfig = messageComposer.channel.getConfig(); + const channelConfig = getChannelConfig(messageComposer.channel); return useMemo( () => diff --git a/src/components/MessageComposer/MessageComposer.tsx b/src/components/MessageComposer/MessageComposer.tsx index 70888f629a..165239fe03 100644 --- a/src/components/MessageComposer/MessageComposer.tsx +++ b/src/components/MessageComposer/MessageComposer.tsx @@ -95,6 +95,10 @@ const MessageComposerProvider = (props: PropsWithChildren) useEffect( () => () => { + // both createDraft() and clear() reach channel.getConfig(), which throws + // for a disconnected channel + if (messageComposer.channel.disconnected) return; + messageComposer.createDraft().finally(() => messageComposer.clear()); }, [messageComposer], diff --git a/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx b/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx index 4da5a314eb..4032d66f7b 100644 --- a/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx +++ b/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx @@ -774,6 +774,25 @@ describe('AttachmentSelector', () => { expect(screen.getByTestId(SHARE_LOCATION_DIALOG_TEST_ID)).toBeInTheDocument(); }); }); + + it('does not throw when the channel disconnects while mounted (#3254)', async () => { + const { channel } = await renderComponent(); + + // initClientWithChannels stubs getConfig; restore it so the guard is reachable + vi.mocked(channel.getConfig).mockRestore(); + channel.disconnected = true; + + // opening the menu re-renders the selector, which re-reads the channel config + await expect(invokeMenu()).resolves.toBeUndefined(); + + // no config means no available actions, so the selector renders nothing + expect( + screen.queryByTestId(SIMPLE_ATTACHMENT_SELECTOR_TEST_ID), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(ATTACHMENT_SELECTOR__ACTIONS_MENU_TEST_ID), + ).not.toBeInTheDocument(); + }); }); const AttachmentSelectorInitiationButtonContents = () => ( diff --git a/src/components/MessageComposer/__tests__/MessageInput.test.tsx b/src/components/MessageComposer/__tests__/MessageInput.test.tsx index 528f807cb9..fcdd28b44b 100644 --- a/src/components/MessageComposer/__tests__/MessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageInput.test.tsx @@ -2130,3 +2130,20 @@ describe(`MessageInputFlat`, () => { }); }); }); + +describe('MessageComposer draft creation on unmount', () => { + afterEach(tearDown); + + it('does not create a draft for a disconnected channel (#3254)', async () => { + const { channel, unmount } = await renderComponent(); + const createDraftSpy = vi.spyOn(channel!.messageComposer, 'createDraft'); + + channel!.disconnected = true; + + await act(() => { + unmount(); + }); + + expect(createDraftSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx b/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx index d39cc518ff..26ce777ca8 100644 --- a/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx +++ b/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx @@ -116,4 +116,15 @@ describe('useMessageComposerCommands', () => { { command: expect.objectContaining({ name: 'ban' }), enabled: false }, ]); }); + it('returns no commands for a disconnected channel without calling getConfig (#3254)', () => { + vi.spyOn(messageComposer.channel, 'getConfig').mockImplementation(() => { + throw new Error("You can't use a channel after client.disconnect() was called"); + }); + (messageComposer.channel as { disconnected?: boolean }).disconnected = true; + + const { result } = renderHook(() => useMessageComposerCommands()); + + expect(result.current).toEqual([]); + expect(messageComposer.channel.getConfig).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/MessageComposer/hooks/useMessageComposerCommands.ts b/src/components/MessageComposer/hooks/useMessageComposerCommands.ts index 94e7b41fe5..706491ed52 100644 --- a/src/components/MessageComposer/hooks/useMessageComposerCommands.ts +++ b/src/components/MessageComposer/hooks/useMessageComposerCommands.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import type { CommandResponse, MessageComposerState } from 'stream-chat'; import { useStateStore } from '../../../store'; +import { getChannelConfig } from '../../../utils/getChannelConfig'; import { useMessageComposerController } from './useMessageComposerController'; const messageComposerStateSelector = ({ @@ -19,7 +20,7 @@ export type MessageComposerCommand = { export const useMessageComposerCommands = () => { const messageComposer = useMessageComposerController(); - const channelConfig = messageComposer.channel.getConfig(); + const channelConfig = getChannelConfig(messageComposer.channel); const { editedMessage, quotedMessage } = useStateStore( messageComposer.state, messageComposerStateSelector, diff --git a/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx b/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx index ac289b1183..6f7a8209d4 100644 --- a/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx +++ b/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx @@ -834,4 +834,24 @@ describe('useMarkRead', () => { }); }); }); + + it('does not throw when the channel is disconnected (#3254)', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); + // initClientWithChannels stubs getConfig; restore it so the guard is reachable + vi.mocked(channel.getConfig).mockRestore(); + channel.disconnected = true; + + expect(() => + render({ + channel, + client, + params: { isMessageListScrolledToBottom: true, messageListIsThread: false }, + }), + ).not.toThrow(); + + expect(markRead).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/MessageList/hooks/useMarkRead.ts b/src/components/MessageList/hooks/useMarkRead.ts index e67e0f2eef..c2c76a00f1 100644 --- a/src/components/MessageList/hooks/useMarkRead.ts +++ b/src/components/MessageList/hooks/useMarkRead.ts @@ -5,6 +5,7 @@ import { useChatContext, } from '../../../context'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; +import { getChannelConfig } from '../../../utils/getChannelConfig'; const hasReadLastMessage = (channel: Channel, userId: string) => { const latestMessageIdInChannel = channel.state.latestMessages.slice(-1)[0]?.id; @@ -38,7 +39,7 @@ export const useMarkRead = ({ useEffect(() => { const unreadNotificationSupported = - channel.getConfig()?.read_events || client.options.isLocalUnreadCountEnabled; + getChannelConfig(channel)?.read_events || client.options.isLocalUnreadCountEnabled; if (!unreadNotificationSupported) return; diff --git a/src/utils/__tests__/getChannelConfig.test.ts b/src/utils/__tests__/getChannelConfig.test.ts new file mode 100644 index 0000000000..bcffb20b23 --- /dev/null +++ b/src/utils/__tests__/getChannelConfig.test.ts @@ -0,0 +1,27 @@ +import { fromPartial } from '@total-typescript/shoehorn'; +import type { Channel, ChannelConfigWithInfo } from 'stream-chat'; +import { describe, expect, it, vi } from 'vitest'; +import { getChannelConfig } from '../getChannelConfig'; + +const config = fromPartial({ read_events: true }); + +describe('getChannelConfig', () => { + it('returns the channel config for a connected channel', () => { + const channel = fromPartial({ + disconnected: false, + getConfig: () => config, + }); + + expect(getChannelConfig(channel)).toBe(config); + }); + + it('returns undefined for a disconnected channel without calling getConfig', () => { + const getConfig = vi.fn(() => { + throw new Error("You can't use a channel after client.disconnect() was called"); + }); + const channel = fromPartial({ disconnected: true, getConfig }); + + expect(getChannelConfig(channel)).toBeUndefined(); + expect(getConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/getChannelConfig.ts b/src/utils/getChannelConfig.ts new file mode 100644 index 0000000000..6a319c1bb0 --- /dev/null +++ b/src/utils/getChannelConfig.ts @@ -0,0 +1,9 @@ +import type { Channel, ChannelConfigWithInfo } from 'stream-chat'; + +/** + * `channel.getConfig()` throws once the channel is disconnected (current user + * removed from the channel, channel deleted). Returns `undefined` instead, + * which is already part of `getConfig()`'s return type. + */ +export const getChannelConfig = (channel: Channel): ChannelConfigWithInfo | undefined => + channel.disconnected ? undefined : channel.getConfig(); From 5776c161615215b1894a307679d52c3e00e78e61 Mon Sep 17 00:00:00 2001 From: Anton Arnautov <43254280+arnautov-anton@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:13:36 +0200 Subject: [PATCH 7/9] feat(MessageComposer): introduce context for custom composers (#3249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🎯 Goal Fixes: #3248 Closes: REACT-1046 As a side feauture, adds `preventClearingOnUnmount` prop. ## Summary by CodeRabbit * **New Features** * Added inline message editing with an β€œEdit inline” action, prefilled composer, and cancel support. * Added support for preserving message composer content when it is removed from the screen. * Added flexible composer controller access for customized composition flows. * Improved composer selection across channels, threads, and parent messages. * **Bug Fixes** * Prevented unnecessary cleanup for disconnected channels. * Improved draft handling and composer state preservation during navigation and remounting. --- examples/vite/src/App.tsx | 2 + examples/vite/src/AppSettings/state.ts | 2 + .../tabs/MessageActions/MessageActionsTab.tsx | 27 ++ .../InlineEditMessage/InlineEditMessage.scss | 22 ++ .../InlineEditMessage/InlineEditMessage.tsx | 183 ++++++++++++ examples/vite/src/InlineEditMessage/index.ts | 1 + examples/vite/src/index.scss | 1 + .../MessageComposer/MessageComposer.tsx | 41 ++- .../useMessageComposerController.test.tsx | 281 ++++++++++++++++++ .../hooks/useMessageComposerController.ts | 14 +- .../__tests__/VirtualizedMessageList.test.tsx | 25 +- .../VirtualizedMessageList.test.tsx.snap | 63 ---- 12 files changed, 582 insertions(+), 80 deletions(-) create mode 100644 examples/vite/src/InlineEditMessage/InlineEditMessage.scss create mode 100644 examples/vite/src/InlineEditMessage/InlineEditMessage.tsx create mode 100644 examples/vite/src/InlineEditMessage/index.ts create mode 100644 src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx delete mode 100644 src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.tsx.snap diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index bdf11f91ce..7abfd4087b 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -68,6 +68,7 @@ import { SegmentedReactionsList, } from './CustomMessageUi'; import { ConfigurableMessageActions } from './CustomMessageActions'; +import { InlineEditableMessage } from './InlineEditMessage'; import { SidebarToggle } from './Sidebar/SidebarToggle.tsx'; import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx'; @@ -424,6 +425,7 @@ const App = () => { HeaderStartContent: SidebarToggle, MessageActions: ConfigurableMessageActions, AttachmentSelector: CommandModeAttachmentSelector, + Message: InlineEditableMessage, ...messageUiOverrides, }} > diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index 9cd77495d4..7281da3b18 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -25,6 +25,7 @@ export type MessageActionsSettingsState = { delete: { enableOptionConfiguration: boolean; }; + inlineEdit: boolean; markOwnUnread: boolean; viewMessageInfo: boolean; }; @@ -121,6 +122,7 @@ const defaultAppSettingsState: AppSettingsState = { delete: { enableOptionConfiguration: false, }, + inlineEdit: false, markOwnUnread: false, viewMessageInfo: false, }, diff --git a/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx b/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx index 8f76d0876e..3d0d8bb209 100644 --- a/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx +++ b/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx @@ -90,6 +90,33 @@ export const MessageActionsTab = ({ close }: MessageActionsTabProps) => { title='Show JSON viewer action in the message actions menu' />
+ +
+
+ Enable inline message editing +
+ + appSettingsStore.partialNext({ + messageActions: { + ...messageActions, + customMessageActions: { + ...customMessageActions, + inlineEdit: event.target.checked, + }, + }, + }) + } + title='Add an "Edit inline" action that swaps the message bubble for a MessageComposer in place' + /> +
+ Adds an “Edit inline” action that replaces the + message with a MessageComposer scoped to that message via + MessageComposerControllerProvider. +
+
); diff --git a/examples/vite/src/InlineEditMessage/InlineEditMessage.scss b/examples/vite/src/InlineEditMessage/InlineEditMessage.scss new file mode 100644 index 0000000000..89d7bd928c --- /dev/null +++ b/examples/vite/src/InlineEditMessage/InlineEditMessage.scss @@ -0,0 +1,22 @@ +.app__inline-edit-message { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem 0; + width: 100%; +} + +.app__inline-edit-message__cancel { + align-self: flex-end; + background: transparent; + border: 1px solid var(--str-chat__secondary-surface-color, #dbdde1); + border-radius: 999px; + color: var(--str-chat__text-color, inherit); + cursor: pointer; + font-size: 0.85rem; + padding: 0.25rem 0.75rem; + + &:hover { + background: var(--str-chat__secondary-surface-color, #f7f7f8); + } +} diff --git a/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx b/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx new file mode 100644 index 0000000000..615324075c --- /dev/null +++ b/examples/vite/src/InlineEditMessage/InlineEditMessage.tsx @@ -0,0 +1,183 @@ +import { + type ComponentProps, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { MessageComposer as MessageComposerController } from 'stream-chat'; +import type { MessageComposerState } from 'stream-chat'; +import { useChannelStateContext } from 'stream-chat-react'; +import { + ContextMenuButton, + defaultMessageActionSet, + MessageUI as DefaultMessageUI, + IconEdit, + MessageActions, + type MessageActionSetItem, + MessageComposer, + MessageComposerControllerProvider, + type MessageUIComponentProps, + useChatContext, + useComponentContext, + useContextMenuContext, + useMessageContext, + useStateStore, + useTranslationContext, + WithComponents, +} from 'stream-chat-react'; + +import { useAppSettingsSelector } from '../AppSettings'; + +type InlineEditContextValue = { + isEditing: boolean; + startEditing: () => void; + stopEditing: () => void; +}; + +const InlineEditContext = createContext(undefined); + +const useInlineEditContext = () => { + const value = useContext(InlineEditContext); + if (!value) { + throw new Error('useInlineEditContext must be used within an InlineEditableMessage'); + } + return value; +}; + +const InlineEditAction = () => { + const { closeMenu } = useContextMenuContext(); + const { startEditing } = useInlineEditContext(); + const { t } = useTranslationContext(); + + return ( + { + startEditing(); + closeMenu(); + }} + > + {t('Edit inline')} + + ); +}; + +const inlineEditActionSetItem: MessageActionSetItem = { + Component: InlineEditAction, + placement: 'dropdown', + type: 'editInline', +}; + +const insertInlineEditAction = ( + actionSet: MessageActionSetItem[], +): MessageActionSetItem[] => { + const editIndex = actionSet.findIndex((item) => 'type' in item && item.type === 'edit'); + + if (editIndex < 0) return [...actionSet, inlineEditActionSetItem]; + + return [ + ...actionSet.slice(0, editIndex), + inlineEditActionSetItem, + ...actionSet.slice(editIndex), + ]; +}; + +const InlineEditComposer = ({ onExit }: { onExit: () => void }) => { + const { t } = useTranslationContext(); + + return ( +
+ + +
+ ); +}; + +const selector = (state: MessageComposerState) => ({ + editing: state.editedMessage != null, +}); + +export const InlineEditableMessage = (props: MessageUIComponentProps) => { + const { client } = useChatContext(); + const { channel } = useChannelStateContext(); + const { message } = useMessageContext(); + const inlineEditEnabled = useAppSettingsSelector( + (state) => state.messageActions.customMessageActions, + ).inlineEdit; + + const { MessageActions: OuterMessageActions = MessageActions } = useComponentContext(); + + const [editingComposer] = useState( + () => + new MessageComposerController({ + compositionContext: channel, + client, + config: { drafts: { enabled: false } }, + }), + ); + + const { editing } = useStateStore(editingComposer.state, selector); + + // If the setting is turned off mid-edit, abandon the in-progress edit so the + // message doesn't stay stuck in composer view with no way to submit it. + useEffect(() => { + if (!inlineEditEnabled && editing) editingComposer.clear(); + }, [editing, editingComposer, inlineEditEnabled]); + + const startEditing = useCallback(() => { + editingComposer.initState({ composition: message }); + }, [editingComposer, message]); + const stopEditing = useCallback(() => { + editingComposer.clear(); + }, [editingComposer]); + + const contextValue = useMemo( + () => ({ isEditing: editing, startEditing, stopEditing }), + [editing, startEditing, stopEditing], + ); + + const MessageActionsWithInlineEdit = useMemo(() => { + const Component = (actionsProps: ComponentProps) => { + const messageActionSet = useMemo( + () => + insertInlineEditAction( + actionsProps.messageActionSet ?? defaultMessageActionSet, + ), + [actionsProps.messageActionSet], + ); + + return ( + + ); + }; + Component.displayName = 'MessageActionsWithInlineEdit'; + return Component; + }, [OuterMessageActions]); + + if (!inlineEditEnabled) { + return ; + } + + if (editing) { + return ( + + + + ); + } + + return ( + + + + + + ); +}; diff --git a/examples/vite/src/InlineEditMessage/index.ts b/examples/vite/src/InlineEditMessage/index.ts new file mode 100644 index 0000000000..32f21bf26a --- /dev/null +++ b/examples/vite/src/InlineEditMessage/index.ts @@ -0,0 +1 @@ +export { InlineEditableMessage } from './InlineEditMessage'; diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index 922a276e3d..a7bbf57236 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -9,6 +9,7 @@ @import url('./AppSettings/AppSettings.scss') layer(stream-app-overrides); @import url('./CustomMessageActions/CustomMessageActions.scss') layer(stream-app-overrides); +@import url('./InlineEditMessage/InlineEditMessage.scss') layer(stream-app-overrides); @import url('./SystemNotification/SystemNotification.scss') layer(stream-app-overrides); @import url('./AccessibilityNavigation/ReturnToSkipNavigation.scss') layer(stream-app-overrides); diff --git a/src/components/MessageComposer/MessageComposer.tsx b/src/components/MessageComposer/MessageComposer.tsx index 165239fe03..b0e1df6a09 100644 --- a/src/components/MessageComposer/MessageComposer.tsx +++ b/src/components/MessageComposer/MessageComposer.tsx @@ -1,5 +1,5 @@ import type { PropsWithChildren } from 'react'; -import React, { useEffect } from 'react'; +import React, { useContext, useEffect } from 'react'; import { MessageComposerUI as DefaultMessageComposerUI } from './MessageComposerUI'; import { useMessageComposerController } from './hooks'; @@ -11,11 +11,34 @@ import { MessageComposerContextProvider } from '../../context/MessageComposerCon import { DialogManagerProvider } from '../../context'; import { useStableId } from '../UtilityComponents/useStableId'; -import type { LocalMessage, Message, SendMessageOptions } from 'stream-chat'; +import type { + LocalMessage, + Message, + MessageComposer as MessageComposerController, + SendMessageOptions, +} from 'stream-chat'; import type { CustomAudioRecordingConfig } from '../MediaRecorder'; import { useRegisterDropHandlers } from './WithDragAndDropUpload'; +const MessageComposerControllerContext = React.createContext< + MessageComposerController | undefined +>(undefined); + +export const MessageComposerControllerProvider = ({ + children, + messageComposerController, +}: PropsWithChildren<{ + messageComposerController?: MessageComposerController; +}>) => ( + + {children} + +); + +export const useMessageComposerControllerContext = () => + useContext(MessageComposerControllerContext); + export type EmojiSearchIndexResult = { id: string; name: string; @@ -79,6 +102,10 @@ export type MessageComposerProps = { * ``` */ shouldSubmit?: (event: React.KeyboardEvent) => boolean; + /** + * When set to `true` disables clearing established state of the MessageComposerController upon component unmount. + */ + preventClearingOnUnmount?: boolean; }; const MessageComposerProvider = (props: PropsWithChildren) => { @@ -99,9 +126,15 @@ const MessageComposerProvider = (props: PropsWithChildren) // for a disconnected channel if (messageComposer.channel.disconnected) return; - messageComposer.createDraft().finally(() => messageComposer.clear()); + const promise = messageComposer.config.drafts.enabled + ? messageComposer.createDraft().catch(console.error) + : Promise.resolve(); + + if (props.preventClearingOnUnmount) return; + + promise.finally(() => messageComposer.clear()); }, - [messageComposer], + [messageComposer, props.preventClearingOnUnmount], ); useEffect(() => { diff --git a/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx b/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx new file mode 100644 index 0000000000..f9fef98766 --- /dev/null +++ b/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx @@ -0,0 +1,281 @@ +import React from 'react'; +import type { PropsWithChildren } from 'react'; +import { act, renderHook, type RenderHookResult } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import { + type Channel, + type LocalMessage, + MessageComposer as MessageComposerController, + type StreamChat, + type Thread, +} from 'stream-chat'; + +import { useMessageComposerController } from '../useMessageComposerController'; +import { Chat } from '../../../Chat'; +import { Channel as ChannelComponent } from '../../../Channel'; +import { LegacyThreadContext } from '../../../Thread/LegacyThreadContext'; +import { ThreadContext } from '../../../Threads'; +import { MessageComposerControllerProvider } from '../../MessageComposer'; +import { + generateMessage, + getOrCreateChannelApi, + getTestClientWithUser, + useMockedApis, +} from '../../../../mock-builders'; +import { generateChannel } from '../../../../mock-builders/generator'; + +const buildStandaloneComposer = ( + client: StreamChat, + channel: Channel, +): MessageComposerController => + new MessageComposerController({ client, compositionContext: channel }); + +const buildStubThreadInstance = (composer: MessageComposerController): Thread => + fromPartial({ messageComposer: composer }); + +type SetupOptions = { + channel: Channel; + client: StreamChat; + legacyThread?: LocalMessage; + overrideComposer?: MessageComposerController; + threadInstance?: Thread; +}; + +const setup = async ({ + channel, + client, + legacyThread, + overrideComposer, + threadInstance, +}: SetupOptions) => { + const wrapper = ({ children }: PropsWithChildren) => { + let content: React.ReactNode = children; + + if (overrideComposer !== undefined) { + content = ( + + {content} + + ); + } + + if (threadInstance !== undefined) { + content = ( + {content} + ); + } + + if (legacyThread !== undefined) { + content = ( + + {content} + + ); + } + + return ( + + {content} + + ); + }; + + let result!: RenderHookResult; + await act(() => { + result = renderHook(() => useMessageComposerController(), { wrapper }); + }); + return result; +}; + +describe('useMessageComposerController', () => { + let client: StreamChat; + let channel: Channel; + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'test-user' }); + const mockedChannelData = generateChannel(); + useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); + channel = client.channel('messaging', mockedChannelData.channel.id); + await channel.watch(); + }); + + describe('retrieval hierarchy', () => { + it('returns channel.messageComposer when no override, thread instance, or legacy thread is present', async () => { + const { result } = await setup({ channel, client }); + expect(result.current).toBe(channel.messageComposer); + }); + + it('returns the override composer when MessageComposerControllerProvider supplies one', async () => { + const overrideComposer = buildStandaloneComposer(client, channel); + const { result } = await setup({ channel, client, overrideComposer }); + expect(result.current).toBe(overrideComposer); + expect(result.current).not.toBe(channel.messageComposer); + }); + + it('override composer takes precedence over a thread instance', async () => { + const overrideComposer = buildStandaloneComposer(client, channel); + const threadComposer = buildStandaloneComposer(client, channel); + const threadInstance = buildStubThreadInstance(threadComposer); + + const { result } = await setup({ + channel, + client, + overrideComposer, + threadInstance, + }); + expect(result.current).toBe(overrideComposer); + }); + + it('override composer takes precedence over a legacy thread parent message', async () => { + const overrideComposer = buildStandaloneComposer(client, channel); + const legacyThread = generateMessage({ + cid: channel.cid, + }) as unknown as LocalMessage; + + const { result } = await setup({ + channel, + client, + legacyThread, + overrideComposer, + }); + expect(result.current).toBe(overrideComposer); + }); + + it('returns threadInstance.messageComposer when a thread instance is provided', async () => { + const threadComposer = buildStandaloneComposer(client, channel); + const threadInstance = buildStubThreadInstance(threadComposer); + + const { result } = await setup({ channel, client, threadInstance }); + expect(result.current).toBe(threadComposer); + expect(result.current).not.toBe(channel.messageComposer); + }); + + it('thread instance takes precedence over a legacy thread parent message', async () => { + const threadComposer = buildStandaloneComposer(client, channel); + const threadInstance = buildStubThreadInstance(threadComposer); + const legacyThread = generateMessage({ + cid: channel.cid, + }) as unknown as LocalMessage; + + const { result } = await setup({ + channel, + client, + legacyThread, + threadInstance, + }); + expect(result.current).toBe(threadComposer); + }); + + it('legacy thread parent takes precedence over the channel composer', async () => { + const legacyThread = generateMessage({ + cid: channel.cid, + }) as unknown as LocalMessage; + const { result } = await setup({ channel, client, legacyThread }); + expect(result.current).not.toBe(channel.messageComposer); + expect(result.current.contextType).toBe('legacy_thread'); + }); + }); + + describe('legacy thread composer', () => { + it('creates a new composer for a legacy thread parent when the cache is empty', async () => { + const legacyThread = generateMessage({ + cid: channel.cid, + }) as unknown as LocalMessage; + const { result } = await setup({ channel, client, legacyThread }); + + expect(result.current).toBeInstanceOf(MessageComposerController); + expect(result.current.contextType).toBe('legacy_thread'); + expect(result.current.tag).toBe( + MessageComposerController.constructTag({ + ...legacyThread, + legacyThreadId: legacyThread.id, + }), + ); + }); + + it('adds the created legacy-thread composer to the client message composer cache', async () => { + const legacyThread = generateMessage({ + cid: channel.cid, + }) as unknown as LocalMessage; + const { result } = await setup({ channel, client, legacyThread }); + + expect(client.messageComposerCache.peek(result.current.tag)).toBe(result.current); + }); + + it('reuses an already-cached composer for the same legacy thread parent id', async () => { + const legacyThread = generateMessage({ + cid: channel.cid, + }) as unknown as LocalMessage; + const compositionContext = { + ...legacyThread, + legacyThreadId: legacyThread.id, + }; + const preExistingComposer = new MessageComposerController({ + client, + compositionContext, + }); + client.messageComposerCache.add( + MessageComposerController.constructTag(compositionContext), + preExistingComposer, + ); + + const { result } = await setup({ channel, client, legacyThread }); + expect(result.current).toBe(preExistingComposer); + }); + + it('returns a stable composer reference across re-renders for the same legacy thread parent id', async () => { + const legacyThread = generateMessage({ + cid: channel.cid, + }) as unknown as LocalMessage; + const { rerender, result } = await setup({ channel, client, legacyThread }); + + const first = result.current; + await act(() => { + rerender(); + }); + expect(result.current).toBe(first); + }); + }); + + describe('cache membership', () => { + it('does not add the channel composer to the cache', async () => { + const { result } = await setup({ channel, client }); + expect(result.current.contextType).toBe('channel'); + expect(client.messageComposerCache.peek(result.current.tag)).toBeUndefined(); + }); + + it('does not add a thread-instance composer to the cache', async () => { + const threadComposer = buildStandaloneComposer(client, channel); + const threadInstance = buildStubThreadInstance(threadComposer); + + const { result } = await setup({ channel, client, threadInstance }); + expect(client.messageComposerCache.peek(result.current.tag)).toBeUndefined(); + }); + }); + + describe('subscriptions', () => { + it('registers subscriptions on the resolved composer and unsubscribes on unmount', async () => { + const unsubscribe = vi.fn(); + const registerSpy = vi + .spyOn(channel.messageComposer, 'registerSubscriptions') + .mockReturnValue(unsubscribe); + + const { unmount } = await setup({ channel, client }); + expect(registerSpy).toHaveBeenCalledTimes(1); + expect(unsubscribe).not.toHaveBeenCalled(); + + unmount(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('registers subscriptions on the override composer when one is supplied', async () => { + const overrideComposer = buildStandaloneComposer(client, channel); + const registerSpy = vi + .spyOn(overrideComposer, 'registerSubscriptions') + .mockReturnValue(vi.fn()); + + await setup({ channel, client, overrideComposer }); + expect(registerSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/components/MessageComposer/hooks/useMessageComposerController.ts b/src/components/MessageComposer/hooks/useMessageComposerController.ts index 9bf9224531..63ee64f49f 100644 --- a/src/components/MessageComposer/hooks/useMessageComposerController.ts +++ b/src/components/MessageComposer/hooks/useMessageComposerController.ts @@ -3,6 +3,7 @@ import { MessageComposer as MessageComposerController } from 'stream-chat'; import { useThreadContext } from '../../Threads'; import { useChannelStateContext, useChatContext } from '../../../context'; import { useLegacyThreadContext } from '../../Thread'; +import { useMessageComposerControllerContext } from '../MessageComposer'; export const useMessageComposerController = () => { const { client } = useChatContext(); @@ -10,6 +11,8 @@ export const useMessageComposerController = () => { const { channel } = useChannelStateContext(); const { legacyThread: parentMessage } = useLegacyThreadContext(); const threadInstance = useThreadContext(); + // custom supplied composer overriding default composer retrieval behavior + const composerFromOverrideContext = useMessageComposerControllerContext(); const cachedParentMessage = useMemo(() => { if (!parentMessage) return undefined; @@ -22,6 +25,8 @@ export const useMessageComposerController = () => { // edited message (always new) -> thread instance (own) -> thread message (always new) -> channel (own) // editedMessage ?? thread ?? parentMessage ?? channel; const messageComposer = useMemo(() => { + if (composerFromOverrideContext) return composerFromOverrideContext; + if (threadInstance) { return threadInstance.messageComposer; } else if (cachedParentMessage) { @@ -42,7 +47,14 @@ export const useMessageComposerController = () => { } else { return channel.messageComposer; } - }, [cachedParentMessage, channel, client, queueCache, threadInstance]); + }, [ + cachedParentMessage, + channel.messageComposer, + client, + composerFromOverrideContext, + queueCache, + threadInstance, + ]); if ( (['legacy_thread', 'message'] as MessageComposerController['contextType'][]).includes( diff --git a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx index 081162fb00..0354d77513 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx @@ -1,5 +1,5 @@ import React, { act } from 'react'; -import { cleanup, render, type RenderResult } from '@testing-library/react'; +import { cleanup, render } from '@testing-library/react'; import { nanoid } from 'nanoid'; import { @@ -87,17 +87,18 @@ describe('VirtualizedMessageList', () => { const { channel, client } = await createChannel(true); vi.mocked(nanoid).mockReturnValue('mockedId'); - let result: RenderResult; - await act(() => { - result = render( - - - - - , - ); - }); - expect(result.container).toMatchSnapshot(); + const { container, findByText } = render( + + + + + , + ); + + const emptyStateText = await findByText('Send a message to start the conversation'); + const virtualList = container.querySelector('.str-chat__virtual-list'); + expect(virtualList).toBeInTheDocument(); + expect(virtualList).toContainElement(emptyStateText); }); }); diff --git a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.tsx.snap b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.tsx.snap deleted file mode 100644 index a25b68ac93..0000000000 --- a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.tsx.snap +++ /dev/null @@ -1,63 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`VirtualizedMessageList > should render the list without any message 1`] = ` -
-
-
-
-
-
-
-
-
- -

- Send a message to start the conversation -

-
-
-
-
-
-
-
-
-`; From 9d6d3ddc980314c9f4e4489d693a3960fbf79398 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 7 Aug 2026 08:30:54 +0000 Subject: [PATCH 8/9] chore(release): 14.11.0 [skip ci] ## [14.11.0](https://github.com/GetStream/stream-chat-react/compare/v14.10.0...v14.11.0) (2026-08-07) ### Bug Fixes * **Channel:** guard render-phase channel.getConfig() against disconnected channels ([#3257](https://github.com/GetStream/stream-chat-react/issues/3257)) ([f60273f](https://github.com/GetStream/stream-chat-react/commit/f60273f2a157747d49f2eb20702d920b977078fd)), closes [#3254](https://github.com/GetStream/stream-chat-react/issues/3254) [#2393](https://github.com/GetStream/stream-chat-react/issues/2393) [#3249](https://github.com/GetStream/stream-chat-react/issues/3249) * **EmojiPicker:** drop @emoji-mart/react peer dependency ([#3255](https://github.com/GetStream/stream-chat-react/issues/3255)) ([0820e4c](https://github.com/GetStream/stream-chat-react/commit/0820e4ccaf81b6669ae62332ed28f49998a5f9a4)) ### Features * add icons to ComponentContext ([#3246](https://github.com/GetStream/stream-chat-react/issues/3246)) ([972b68c](https://github.com/GetStream/stream-chat-react/commit/972b68c6d08a89ec8667ad2dd13c7aa927f001a0)) * localized unread count ([#3250](https://github.com/GetStream/stream-chat-react/issues/3250)) ([1b8fa34](https://github.com/GetStream/stream-chat-react/commit/1b8fa347c1373a26f1de9079127bc7d041e93be0)), closes [GetStream/stream-chat-react-native#3679](https://github.com/GetStream/stream-chat-react-native/issues/3679) * **MessageComposer:** introduce context for custom composers ([#3249](https://github.com/GetStream/stream-chat-react/issues/3249)) ([5776c16](https://github.com/GetStream/stream-chat-react/commit/5776c161615215b1894a307679d52c3e00e78e61)), closes [#3248](https://github.com/GetStream/stream-chat-react/issues/3248) --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d0b3d3fc..2730dc2d90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## [14.11.0](https://github.com/GetStream/stream-chat-react/compare/v14.10.0...v14.11.0) (2026-08-07) + +### Bug Fixes + +* **Channel:** guard render-phase channel.getConfig() against disconnected channels ([#3257](https://github.com/GetStream/stream-chat-react/issues/3257)) ([f60273f](https://github.com/GetStream/stream-chat-react/commit/f60273f2a157747d49f2eb20702d920b977078fd)), closes [#3254](https://github.com/GetStream/stream-chat-react/issues/3254) [#2393](https://github.com/GetStream/stream-chat-react/issues/2393) [#3249](https://github.com/GetStream/stream-chat-react/issues/3249) +* **EmojiPicker:** drop @emoji-mart/react peer dependency ([#3255](https://github.com/GetStream/stream-chat-react/issues/3255)) ([0820e4c](https://github.com/GetStream/stream-chat-react/commit/0820e4ccaf81b6669ae62332ed28f49998a5f9a4)) + +### Features + +* add icons to ComponentContext ([#3246](https://github.com/GetStream/stream-chat-react/issues/3246)) ([972b68c](https://github.com/GetStream/stream-chat-react/commit/972b68c6d08a89ec8667ad2dd13c7aa927f001a0)) +* localized unread count ([#3250](https://github.com/GetStream/stream-chat-react/issues/3250)) ([1b8fa34](https://github.com/GetStream/stream-chat-react/commit/1b8fa347c1373a26f1de9079127bc7d041e93be0)), closes [GetStream/stream-chat-react-native#3679](https://github.com/GetStream/stream-chat-react-native/issues/3679) +* **MessageComposer:** introduce context for custom composers ([#3249](https://github.com/GetStream/stream-chat-react/issues/3249)) ([5776c16](https://github.com/GetStream/stream-chat-react/commit/5776c161615215b1894a307679d52c3e00e78e61)), closes [#3248](https://github.com/GetStream/stream-chat-react/issues/3248) + ## [14.10.0](https://github.com/GetStream/stream-chat-react/compare/v14.9.0...v14.10.0) (2026-07-22) ### Features From 0e9deafd3db92071b74f0d7601a25612667ac460 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 7 Aug 2026 13:50:38 +0200 Subject: [PATCH 9/9] test(Channel): cover #3254 disconnected-channel render safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit master's #3254 regression test asserted against ChannelStateContext and loadMore, both removed in v15, so it could not be carried through the merge. These replace it against the v15 architecture. - does not throw when re-rendering after the channel disconnects while mounted β€” the reported crash - does not read the config off the disconnected channel during render β€” locks the mechanism. useChannelConfig resolves from client.configsStore by cid, so the channel instance is never touched; the guarded getChannelConfig(channel) helper would satisfy this too, an unguarded channel.getConfig() would not - ignores events dispatched for a disconnected channel β€” covers the handleEvent early-return, since the user.deleted branch re-queries Each verified to fail against unfixed code first: reintroducing the render-phase channel.getConfig() fails the first two with the real "You can't use a channel after client.disconnect() was called", and removing the handleEvent guard fails the third with 2 unexpected query calls. --- .../Channel/__tests__/Channel.test.tsx | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index 94bd573352..646cbbc8dc 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -442,6 +442,101 @@ describe('Channel', () => { }); }); + describe('disconnected channel (#3254)', () => { + // `initClient` stubs `getConfig`. Restoring it puts the real implementation back, which + // routes through `getClient()` and throws + // `You can't use a channel after client.disconnect() was called` once + // `channel.disconnected` is set β€” the exact failure reported in #3254. `channel.disconnected` + // is flipped by an async WS event (current user removed from the channel, or the channel + // deleted) while `` is still mounted, so an unguarded read lands in the render phase + // and tears down the surrounding subtree. + const disconnect = (channel: ChannelType) => { + vi.mocked(channel.getConfig).mockRestore(); + channel.disconnected = true; + }; + + // `Channel` is deliberately not wrapped in `React.memo`, so re-rendering the parent is enough + // to re-render `ChannelInner`. + const renderWithRerender = async ( + channel: ChannelType, + chatClient: StreamChat, + ): Promise<() => void> => { + let rerender: () => void = () => {}; + const Wrapper = () => { + const [, setTick] = React.useState(0); + rerender = () => setTick((tick) => tick + 1); + return ( + + +
child
+
+
+ ); + }; + + await act(() => { + render(); + }); + await waitFor(() => expect(screen.getByText('child')).toBeInTheDocument()); + + return rerender; + }; + + it('does not throw when re-rendering after the channel disconnects while mounted', async () => { + const { channel, chatClient } = await setup(); + const rerender = await renderWithRerender(channel, chatClient); + + disconnect(channel); + + expect(() => + act(() => { + rerender(); + }), + ).not.toThrow(); + + // the subtree survived rather than being torn down + expect(screen.getByText('child')).toBeInTheDocument(); + }); + + it('does not read the config off the disconnected channel during render', async () => { + const { channel, chatClient } = await setup(); + const rerender = await renderWithRerender(channel, chatClient); + + disconnect(channel); + // calls through to the real (throwing) implementation, so a render-phase read fails the + // test whether or not the assertion below is reached + const getConfigSpy = vi.spyOn(channel, 'getConfig'); + + await act(() => { + rerender(); + }); + + // `useChannelConfig` resolves the config from `client.configsStore` by cid, so the + // disconnected channel instance is never touched. Reading it through the guarded + // `getChannelConfig(channel)` helper would satisfy this too β€” what must not come back is an + // unguarded `channel.getConfig()` in the component body. + expect(getConfigSpy).not.toHaveBeenCalled(); + }); + + it('ignores events dispatched for a disconnected channel', async () => { + const { channel, chatClient } = await setup(); + await renderWithRerender(channel, chatClient); + + disconnect(channel); + const querySpy = vi.spyOn(channel, 'query'); + + // `user.deleted` is the one `handleEvent` branch that re-queries the channel; querying a + // disconnected channel throws, so the handler has to bail out first + await act(async () => { + chatClient.dispatchEvent(fromPartial({ type: 'user.deleted' })); + await Promise.resolve(); + }); + + expect(querySpy).not.toHaveBeenCalled(); + expect(screen.getByText('child')).toBeInTheDocument(); + }); + }); + describe('Children that consume the contexts set in Channel', () => { describe('Sending/removing/updating messages', () => { it('should add a preview for messages that are sent to the channel state, so that they are rendered even without API response', async () => {