diff --git a/AGENTS.md b/AGENTS.md index ada19dcaa1..42706cae05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,119 +1,484 @@ -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) +``` + +`` (in `src/plugins/SlotLayout/`) + ``/`` provide the channels-vs-threads (inbox) view switching. + +### Context layers (17 contexts in `src/context/`) + +``` +ChatContext # client, active channel, theme, searchController, navigation +├─ ChannelInstanceContext # the LLC `channel` for this subtree (read via `useChannel()`) +├─ ComponentContext # ~100 customizable component slots + `icons` slot map +├─ MessageContext # per-message: actions, reactions, status +├─ MessageComposerContext # composer props/bindings +├─ AttachmentContext # giphyVersion + attachment size handlers +├─ WorkspaceNavigationContext # open/close channels and threads, active-slot queries +├─ DialogManagerContext / ModalContext # dialog + modal orchestration +└─ TranslationContext, TypingContext, PollContext, MessageListContext, + VirtualizedMessageListContext, ChannelListContext, MessageBounceContext, + AttachmentSelectorContext, MessageTranslationViewContext ``` -yarn lint-fix + +Each has a hook: `useChatContext()`, `useChannel()`, `useComponentContext()`, … Other contexts live next to their components (`SearchContext`, `ChannelDetailContext`, `ThreadContext`, `NotificationConfigurationContext`). + +> **Removed in v15:** `ChannelStateContext` / `ChannelActionContext` and their +> `useChannelStateContext()` / `useCreateChannelStateContext` / `useCreateChannelActionContext` +> builders. Message and channel state is no longer copied into a React context — components read it +> directly from the LLC (`useChannel()` + `useStateStore(channel.messagePaginator.state, …)`), and +> actions are invoked on the LLC channel or on the navigation adapters. + +### 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 + +1. **Local state** (`useState`) — component UI state, plus `Channel`'s own lifecycle flags + (`isBootstrapping`, `bootstrapError`). There is no reducer and no React-held message list. +2. **External LLC state** — the message list, thread replies and pinned messages live on + `channel.messagePaginator` / `thread.messagePaginator` / `channel.pinnedMessagesPaginator` + (`StateStore`s), consumed via `useStateStore` (`src/store/hooks/useStateStore.ts`). This is the + primary re-render driver. +3. **Context state** — the channel instance and component-slot overrides (see Context layers). + +`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); ``` -#### Commit / PR conventions +### 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 + +**Owner:** the LLC (`stream-chat`), not React state. + +- Optimistic sends go through `channel.sendMessage`, which ingests the pending message into + `channel.messagePaginator` immediately (`ingestItem`, dedupe-by-id + sorted insert). `MessageList` + subscribes to that paginator `StateStore`, so the message renders at once — there is no + React-local copy. The React SDK only customizes the request via + `channel.configState.requestHandlers` (see `Channel/hooks/useChannelRequestHandlers.ts`). +- WebSocket events may arrive before or after the API response; **conflict resolution lives in the + paginator / LLC** (newest version wins, dedupe by id). +- **Gotcha:** thread replies live in a separate paginator (`thread.messagePaginator`) from + `channel.messagePaginator` — they are not dual-written; each is updated by its own event handling. + +### 2. WebSocket event processing + +**File:** `src/components/Channel/Channel.tsx` (`handleEvent`, registered in the bootstrap effect) + +Re-renders on events are **not** driven from `Channel`. The LLC's own event handlers write into the +paginators' `StateStore`s and components re-render through their `useStateStore` subscriptions. There +is **no** throttled `copyStateFromChannelOnEvent` dispatch any more — the reducer and its 500ms +throttle were removed in v15. + +`Channel.handleEvent` now performs only **side effects**: an early return for a disconnected channel, +online-status tracking, document-title / unread-count updates on `message.new`, latest-message +bookkeeping, and a full `channel.query(...)` re-fetch on `user.deleted`. + +- Some events are ignored (e.g. `user.watching.start/stop`) +- 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 + +- `useStateStore(store, selector)` selectors: return a small flat object — the hook shallow-compares + the selected slice and only re-renders on a real change. This is what scopes paginator-state + updates (e.g. `MessageList` selects `{ messages, hasMoreNewer, isLoading }`). +- `areMessageUIPropsEqual` (`src/components/Message/utils.tsx`) checks cheap props first (`highlighted`, `threadList`, `endOfGroup`, `mutes.length`, `readBy.length`, `deliveredTo.length`, `groupStyles`) before deep message comparison. +- **Gotcha:** a change that neither the selector nor `areMessageUIPropsEqual` observes will not + trigger a re-render. + +> The old event throttling (500ms `copyStateFromChannelOnEvent`, 200ms unread, `markRead` 500ms, +> debounced `loadMoreFinished`) and the `useCreateChannelStateContext` string-serialization +> memoization are **gone** — re-rendering is driven by `StateStore` subscriptions, not a throttled +> reducer copy. + +## Critical gotchas & invariants + +### DO NOT: + +1. **Push messages into `channel.state`** — messages, thread replies and pinned messages are owned by the LLC paginators (`channel.messagePaginator`, `thread.messagePaginator`, `channel.pinnedMessagesPaginator`). Read them reactively via `useStateStore(channel.messagePaginator.state, …)`; the SDK's own event handlers perform the writes. `channel.state.addMessageSorted()` / `removeMessage()` were removed in v15. +2. **Include `channel` in dependency arrays** — use `channel.cid` (stable), never `channel.state` (changes constantly) +3. **Change message sort order** — the paginator maintains order; local changes conflict +4. **Assume thread replies live in the channel's message list** — they are an independent paginator and are not mirrored into `channel.messagePaginator` + +### Thread vs. channel messages + +- Main channel messages: `channel.messagePaginator` (LLC). +- Thread replies: `thread.messagePaginator`, owned by the `Thread` object (resolve via `client.threads`) — **independent** of the channel's message list. +- **No cross-store invariant:** a reply is not required to exist in the channel's message list. Whether a reply also shows in the channel is the server's `show_in_channel` flag, applied when the message is ingested. + +### React version compatibility -- Never commit directly to main, always create a feature branch. +The SDK supports **React 17, 18, 19**. Enforced by the `react-compat` block in `eslint.config.mjs` — forbidden in `src/`: + +- `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. `useStateStore` selectors ↔ message memoization — a selector returning an unstable or over-broad slice defeats the shallow-compare and re-renders the whole list +4. `MessageComposer` ↔ `stream-chat`'s `MessageComposer` class + `client.messageComposerCache` + +**Integration risks:** message sorting changes conflict with the LLC paginator's ordering; reading LLC message state through anything other than `useStateStore` on the paginator yields stale copies. + +## 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/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 diff --git a/CLAUDE.md b/CLAUDE.md index b53764d91f..c504304a45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,398 +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 - └─ # Bootstrap + WS side-effects; provides the LLC channel instance - ├─ # Renders messages (or ) - ├─ # Composer with attachments/mentions - └─ # Threaded replies -``` - -### Context Layers - -``` -ChatContext # Client, active channel, theme, navigation -├─ ChannelInstanceContext # The LLC `channel` for this subtree (read via `useChannel()`) -├─ ComponentContext # Customizable component slots -└─ MessageContext # Per-message: actions, reactions, status -``` - -**Hooks:** `useChatContext()`, `useChannel()`, `useMessagePaginator()`, `useComponentContext()`, etc. - -> **Removed in v15:** `ChannelStateContext` / `ChannelActionContext` and their -> `useChannelStateContext()` / `useCreateChannelStateContext` / `useCreateChannelActionContext` -> builders. Message/channel state is no longer copied into a React context — components read it -> directly from the LLC (`useChannel()` + `useStateStore(channel.messagePaginator.state, …)`), and -> actions are invoked on the LLC channel / navigation adapters. - -### State Management - -1. **Local state** (`useState`) - only UI/lifecycle flags on `Channel` (`isBootstrapping`, - `bootstrapError`); no reducer, no React-held message list. -2. **External LLC state** - the message list, thread replies, and pinned messages live on - `channel.messagePaginator` / `thread.messagePaginator` / `channel.pinnedMessagesPaginator` - (`StateStore`s). Components subscribe via the `useStateStore` hook (backed by - `useSyncExternalStore`) — this is the primary re-render driver. -3. **Context** - the channel instance and component-slot overrides (see Context Layers). - -## Critical Architectural Patterns - -### 1. Optimistic Updates & Race Conditions - -**Owner:** the LLC (`stream-chat`), not React state. - -- Optimistic sends go through `channel.sendMessage`, which ingests the pending message into - `channel.messagePaginator` immediately (`ingestItem`, dedupe-by-id + sorted insert). Because - `MessageList` subscribes to the paginator `StateStore`, the message renders at once — no React-local - copy. The React SDK only customizes the request via `channel.configState.requestHandlers` (see - `Channel/hooks/useChannelRequestHandlers.ts`). -- WebSocket events may arrive before/after the API response; **conflict resolution is in the paginator - / LLC** (newest version wins, dedupe by id). -- **Gotcha:** thread replies are a separate paginator (`thread.messagePaginator`) from the channel's - `channel.messagePaginator` — they are not dual-written; each is updated by its own event handling. - -### 2. WebSocket Event Processing - -**File:** `src/components/Channel/Channel.tsx` (`handleEvent`, registered in the bootstrap effect). - -Re-renders on events are **not** driven from `Channel`. The LLC's own event handlers write into the -paginators' `StateStore`s, and components re-render via their `useStateStore` subscriptions -(`useSyncExternalStore`). There is **no** throttled `copyStateFromChannelOnEvent` dispatch anymore -(the old reducer + 500ms throttle were removed). - -`Channel.handleEvent` now performs only **side effects**: online-status tracking, document-title / -unread-count updates on `message.new`, latest-message bookkeeping, and a full `channel.query(...)` -re-fetch on `user.deleted`. - -- 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 - -**Critical memoization:** - -- `useStateStore(store, selector)` selectors: return small flat objects — the hook shallow-compares - the selected slice and only re-renders on a real change. This is what scopes paginator-state - updates (e.g. `MessageList` selects `{ messages, hasMoreNewer, isLoading }`). -- `areMessageUIPropsEqual` (`src/components/Message/utils.tsx`) — per-message `React.memo` comparator; - checks cheap props first (highlighted, mutes.length). -- **Gotcha:** a change the selector or `areMessageUIPropsEqual` doesn't observe won't trigger a - re-render. - -> The old event throttling (500ms `copyStateFromChannelOnEvent`, 200ms unread, `markRead` 500ms, -> `loadMoreFinished` debounce) and the `useCreateChannelStateContext` string-serialization memoization -> are **gone** — re-rendering is now driven by `StateStore` subscriptions, not a throttled reducer -> copy. - -## Critical Gotchas & Invariants - -### DO NOT: - -1. **Push messages into `channel.state`** - messages/threads/pinned are owned by the LLC paginators (`channel.messagePaginator`, `thread.messagePaginator`, `channel.pinnedMessagesPaginator`). Read them reactively via `useStateStore(channel.messagePaginator.state, …)`; the SDK's own event handlers perform the writes. There is no `channel.state.addMessageSorted()` / `removeMessage()` (removed in v15). -2. **Include `channel` in dependency arrays** - Use `channel.cid` only (stable), not `channel.state` (changes constantly) -3. **Change message sort order** - the paginator maintains order; local changes will conflict -4. **Assume thread replies live in the channel's message list** - a thread's replies are an independent paginator (`thread.messagePaginator`); they are not mirrored into `channel.messagePaginator` - -### Thread vs. channel messages - -- Main channel messages: `channel.messagePaginator` (LLC). -- Thread replies: `thread.messagePaginator`, owned by the `Thread` object (resolve via `client.threads`) — **independent** of the channel's message list. -- **No cross-store invariant:** a reply is not required to exist in the channel's message list. Whether a reply also shows in the channel is the server's `show_in_channel` flag, applied when the message is ingested. - -### 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.messagePaginator.state.items - changes constantly (subscribe via useStateStore) - // ❌ 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. `useStateStore` selectors ↔ Message memoization - a selector that returns an unstable/over-broad - slice defeats the shallow-compare and re-renders the list - -**Integration Risks:** - -- Changing message sorting conflicts with the LLC paginator's ordering -- Reading LLC message state through anything other than `useStateStore` on the paginator (stale copies) - -## 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/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 34f159342a..e78871263f 100644 --- a/examples/tutorial/src/2-core-component-setup/App.tsx +++ b/examples/tutorial/src/3-core-component-setup/App.tsx @@ -13,7 +13,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 97% rename from examples/tutorial/src/3-channel-list/App.tsx rename to examples/tutorial/src/5-theming/App.tsx index 2f4655780b..7330aab35d 100644 --- a/examples/tutorial/src/3-channel-list/App.tsx +++ b/examples/tutorial/src/5-theming/App.tsx @@ -14,7 +14,7 @@ import { import { ChatView, useSlotChannels } from 'stream-chat-react/slot-layout'; 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 46274e525d..a7d38d619f 100644 --- a/examples/tutorial/src/4-custom-ui-components/App.tsx +++ b/examples/tutorial/src/6-custom-ui-components/App.tsx @@ -21,7 +21,7 @@ import { } from 'stream-chat-react/slot-layout'; 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, @@ -158,7 +158,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 97% rename from examples/tutorial/src/6-emoji-picker/App.tsx rename to examples/tutorial/src/7-emoji-picker/App.tsx index d8df79a709..ae68215b7e 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 f695985734..63e7630db2 100644 --- a/examples/tutorial/src/5-custom-attachment-type/App.tsx +++ b/examples/tutorial/src/optional-custom-attachment-type/App.tsx @@ -18,7 +18,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, @@ -92,7 +92,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 740aefcc77..4fc9df9e5f 100644 --- a/examples/tutorial/src/7-livestream/App.tsx +++ b/examples/tutorial/src/optional-livestream/App.tsx @@ -10,7 +10,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'], + }, }); diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 24794d22af..27892f3e8e 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -90,6 +90,7 @@ import { SegmentedReactionsList, } from './CustomMessageUi'; import { ConfigurableMessageActions } from './CustomMessageActions'; +import { InlineEditableMessage } from './InlineEditMessage'; import { SidebarToggle } from './Sidebar/SidebarToggle.tsx'; import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx'; @@ -562,6 +563,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 974fdbe453..afa299f09b 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; }; @@ -130,6 +131,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 0c62927adf..d664f73d6c 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/package.json b/package.json index e5eb958cf7..82143de660 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,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", @@ -141,9 +140,6 @@ "@emoji-mart/data": { "optional": true }, - "@emoji-mart/react": { - "optional": true - }, "emoji-mart": { "optional": true }, @@ -154,15 +150,13 @@ "files": [ "dist", "package.json", - "README.md", - "AI.md" + "README.md" ], "devDependencies": { "@breezystack/lamejs": "^1.2.7", "@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", @@ -229,7 +223,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'", diff --git a/src/components/Attachment/Geolocation.tsx b/src/components/Attachment/Geolocation.tsx index 9f73f905a8..1f82cedba7 100644 --- a/src/components/Attachment/Geolocation.tsx +++ b/src/components/Attachment/Geolocation.tsx @@ -3,9 +3,13 @@ import { useEffect } from 'react'; import { useRef, useState } from 'react'; import React from 'react'; import type { Coords, SharedLocationResponseData } from 'stream-chat'; -import { useChannel, useChatContext, useTranslationContext } from '../../context'; +import { + useChannel, + useChatContext, + useComponentContextIcons, + useTranslationContext, +} from '../../context'; import { ExternalLinkIcon } from './icons'; -import { IconLocation } from '../Icons'; import { Button } from '../Button'; export type GeolocationMapProps = Coords; @@ -103,6 +107,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 a660ba6a32..01d15d2e84 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 { useAttachmentContext } from '../../../context/AttachmentContext'; +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 1657704d9c..18d65462a4 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'; import { useThreadContext } from '../../Threads'; @@ -22,22 +21,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 3301c6e969..2290d8f923 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, LocalVoiceRecordingAttachment } 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]; const { duration } = (attachment as LocalVoiceRecordingAttachment).custom ?? {}; 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 3aaf128d91..1b1ae82be2 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 { PollOptionResponseData, PollState } from 'stream-chat'; -import { IconTrophy } from '../../../Icons'; type PollStateSelectorReturnValue = { maxVotedOptionIds: string[]; @@ -21,6 +24,7 @@ export const PollResultOptionVoteCounter = ({ optionId, }: PollResultOptionVoteCounterProps) => { const { t } = useTranslationContext(); + const { IconTrophy } = useComponentContextIcons(); const { poll } = usePollContext(); const { maxVotedOptionIds, vote_counts_by_option } = useStateStore( poll.state, 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 8f2f15531d..001b5d1788 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 { useSendMessageFn } from '../../MessageComposer/hooks/useSendMessageFn'; import { useNotificationApi } from '../../Notifications'; @@ -16,6 +19,8 @@ export type PollCreationDialogControlsProps = { export const PollCreationDialogControls = ({ close, }: PollCreationDialogControlsProps) => { + const { IconSend } = useComponentContextIcons(); + const { t } = useTranslationContext('PollCreationDialogControls'); const { 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 f3052eee15..ab3a13bc52 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 3e1dd76893..8e26f08c09 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); // MUST match the id `MessageActions` derives via `ReactionSelector.getDialogId` — it // uses that to keep `.str-chat__message-options--active` applied while the reaction @@ -61,7 +67,7 @@ export const ReactionSelectorWithButton = ({ onClick={() => dialog?.toggle()} ref={buttonRef} > - + ); diff --git a/src/components/Reactions/__tests__/ReactionSelectorWithButton.test.tsx b/src/components/Reactions/__tests__/ReactionSelectorWithButton.test.tsx index c822c86c68..7df42d3187 100644 --- a/src/components/Reactions/__tests__/ReactionSelectorWithButton.test.tsx +++ b/src/components/Reactions/__tests__/ReactionSelectorWithButton.test.tsx @@ -15,6 +15,7 @@ const capturedDialogIds: string[] = []; vi.mock('../../../context', () => ({ useComponentContext: () => ({}), + useComponentContextIcons: () => ({ IconEmoji: () => null }), useMessageContext: () => ({ isMyMessage: () => false, message: { id: 'message-1' }, diff --git a/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx b/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx index 9944918c6c..ab09fc12b9 100644 --- a/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx +++ b/src/components/SummarizedMessagePreview/SummarizedMessagePreview.tsx @@ -6,21 +6,7 @@ import { useLatestMessagePreview, type UseLatestMessagePreviewParams, } from './hooks/useLatestMessagePreview'; -import { - IconCamera, - IconCheckmark1Small, - IconChecks, - IconClock, - IconExclamationCircleFill, - IconFile, - IconGiphy, - IconLink, - IconLocation, - IconNoSign, - IconUnsupportedAttachment, - IconVideo, - IconVoice, -} from '../Icons'; +import { useComponentContextIcons } from '../../context'; /** * Props for {@link SummarizedMessagePreview}. Override the component via `ComponentContext` @@ -29,33 +15,50 @@ import { */ export type SummarizedMessagePreviewProps = UseLatestMessagePreviewParams; -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, -}; - export const SummarizedMessagePreview = ({ latestMessage, messageDeliveryStatus, participantCount, -}: UseLatestMessagePreviewParams) => { +}: SummarizedMessagePreviewProps) => { + 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 664155baf9..687b27535d 100644 --- a/src/components/Thread/ThreadHeader.tsx +++ b/src/components/Thread/ThreadHeader.tsx @@ -14,8 +14,7 @@ import { useComponentContext } from '../../context/ComponentContext'; import type { EventPayload, LocalMessage } from 'stream-chat'; import type { TextComposerState, ThreadState } from 'stream-chat'; import { Button } from '../Button'; -import { IconXmark } from '../Icons'; -import { useWorkspaceNavigation } from '../../context'; +import { useComponentContextIcons, useWorkspaceNavigation } from '../../context'; const threadStateSelector = ({ replyCount }: ThreadState) => ({ replyCount }); const textComposerTypingSelector = ({ typing }: TextComposerState) => ({ typing }); @@ -77,6 +76,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 27dde1df37..a3d2d91566 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -70,6 +70,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'; @@ -120,6 +121,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 803a0dc71a..fa45d998ad 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -4,6 +4,7 @@ export * from './ChannelListContext'; export * from './ChannelInstanceContext'; 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 04b3d0cc26..b2e29c731c 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'; @@ -196,6 +199,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 90ef9fb37a..0cf3dc1b55 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 429ebf83ab..79a28d382d 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(); @@ -318,6 +320,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 2afdc62256..c06465471a 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'; @@ -143,6 +145,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 b959f6f380..3f798ded04 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx @@ -13,6 +13,7 @@ import type { ChannelMemberResponse } from 'stream-chat'; import { useChatContext, useComponentContext, + useComponentContextIcons, useModalContext, useTranslationContext, } from '../../../../context'; @@ -22,13 +23,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'; @@ -79,25 +73,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 88e4f26c89..a2aca2e00a 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 477bffc0a4..d2d3692f66 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 20a437fc35..500b63c9c9 100644 --- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx @@ -15,9 +15,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 { PinnedMessagesView } from '../PinnedMessagesView'; @@ -231,6 +233,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); @@ -238,6 +242,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 17e75dc177..95dd084a8b 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 e877d8df1a..382f524df4 100644 --- a/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx +++ b/src/plugins/ChannelDetail/__tests__/ChannelManagementView.test.tsx @@ -29,19 +29,23 @@ const mocks = vi.hoisted(() => ({ mutes: [] as UserMuteResponse[], })); -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: () => ({ @@ -133,8 +137,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..36414582da 100644 --- a/src/plugins/Emojis/EmojiPicker.tsx +++ b/src/plugins/Emojis/EmojiPicker.tsx @@ -1,27 +1,26 @@ import React, { useEffect, useState } from 'react'; -import PickerImport from '@emoji-mart/react'; +import { Picker, type PickerProps } from './Picker'; -import { useMessageComposerContext, useTranslationContext } from '../../context'; +import { + useComponentContextIcons, + useMessageComposerContext, + useTranslationContext, +} from '../../context'; import { Button, - IconEmoji, type PopperLikePlacement, useMessageComposerController, } from '../../components'; 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 = { + /** + * @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; @@ -31,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 */ @@ -72,7 +71,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 +135,7 @@ export const EmojiPicker = (props: EmojiPickerProps) => { type='button' variant='secondary' > - {ButtonIconComponent && } + {ResolvedButtonIconComponent && }
); 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/src/plugins/SlotLayout/ChatView.tsx b/src/plugins/SlotLayout/ChatView.tsx index 25bf8bb663..0db1f4a158 100644 --- a/src/plugins/SlotLayout/ChatView.tsx +++ b/src/plugins/SlotLayout/ChatView.tsx @@ -12,16 +12,11 @@ import React, { import { useStableId } from '../../components/UtilityComponents/useStableId'; import { Button, type ButtonProps } from '../../components/Button'; -import { - IconMessageBubble, - IconMessageBubbleFill, - IconThread, - IconThreadFill, -} from '../../components/Icons'; import { UnreadCountBadge } from '../../components/Threads/UnreadCountBadge'; import { DialogManagerProvider, useChatContext, + useComponentContextIcons, useTranslationContext, } from '../../context'; import { useStateStore } from '../../store'; @@ -651,6 +646,7 @@ export const ChatViewChannelsSelectorButton = ({ }: ChatViewSelectorItemProps) => { const { activeView, setActiveView } = useChatViewContext(); const { t } = useTranslationContext(); + const { IconMessageBubble, IconMessageBubbleFill } = useComponentContextIcons(); const isActive = activeView === 'channels'; @@ -680,6 +676,7 @@ export const ChatViewThreadsSelectorButton = ({ }; const { activeView, setActiveView } = useChatViewContext(); const { t } = useTranslationContext(); + const { IconThread, IconThreadFill } = useComponentContextIcons(); const isActive = activeView === 'threads'; const label = 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(); diff --git a/yarn.lock b/yarn.lock index d010e2e78e..acf4fb0d68 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" @@ -10049,7 +10039,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" @@ -10126,7 +10115,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 @@ -10148,8 +10136,6 @@ __metadata: optional: true "@emoji-mart/data": optional: true - "@emoji-mart/react": - optional: true emoji-mart: optional: true modern-normalize: