diff --git a/.agents/skills/react-ui-cookbook/SKILL.md b/.agents/skills/react-ui-cookbook/SKILL.md new file mode 100644 index 0000000000..458cb2fa41 --- /dev/null +++ b/.agents/skills/react-ui-cookbook/SKILL.md @@ -0,0 +1,206 @@ +--- +name: react-ui-cookbook +description: + Write, port or update a recipe for the React v15 docs' UI Cookbooks group, with its code in + examples/cookbook and its screenshots taken from that app. Use whenever a task adds or changes a + UI Cookbook recipe, its CSS, its screenshots, the cookbook base app or its demo data, so every + recipe keeps the same look, cast and style rules. +--- + +# React UI Cookbook + +Every UI Cookbook recipe in the React v15 docs (getstream.io, `content/docs/chat-sdk/react/v15-rc/`, +URLs under `/chat/docs/sdk/react/v15/ui-cookbooks/`) is built on one base app, follows one set of +style rules, and is shot in one frame with one cast. The runnable code lives here, in +`examples/cookbook`; the docs page shows that same code. + +## The look + +- **Theme:** the SDK's default theme, light, with its default font: the base app loads no theme + of its own, and no recipe changes the theme's colors or font. A recipe's CSS reads the + `--str-chat__*` tokens, so it follows whatever theme an app sets. Only the Theming recipe + (`src/recipes/theming/brand-theme-and-dark-mode/`, shown on the UI › Customizing › Theming docs + page) loads a theme, from its own variants: `theme.css`, a green brand theme in light and dark + (an emerald accent, `#047857`, on green-tinted surfaces, solid green own bubbles, `#1a7f5a`, + with white text, links and mentions, rounder bubbles and cards, Plus Jakarta Sans and soft + green-tinted shadows), with every text at 4.5:1 and every border and icon at 3:1. Change the + green theme only there. Recipes are shot in the light theme; only the Theming recipe adds dark + shots. +- **Frame:** desktop 1280×720 at DPR 1 (`ChatView`: channel list 320px on the left, the open + channel in the middle, a thread on the right when the recipe needs one). Phone recipes use + 390×844. A shot with four columns (the channel list, the channel, a thread and the side panel) + uses 1600×720, set with `viewport` in `shots.json`, so the messages don't wrap word by word. + Crop each shot to the part the recipe changes, with a little surrounding UI. +- **Side panel:** a recipe that shows content in a panel (details, a profile, info) opens it in the + base app's one side panel (`src/base/SidePanel.tsx`), never in a column of its own: call + `useSidePanel().openSidePanel({ id, title, body })`, or `openUserProfile(userId)` for a profile + (the SDK's `userProfile` kind). The panel draws the title row and the close button; the recipe + writes only the body, and its section headings use `.cookbook-side-panel__section-title`. It + never closes the thread, and opening another panel replaces it: other content and closing + release the whole slot, so an earlier profile never comes back. The panel moves focus to its + title on open and gives it back to the opener on close; a recipe adds no focus code of its own. + A recipe that only changes the message lists or the profile body sets `RecipeConfig.MessageList` + and `RecipeConfig.sidePanelProps` instead of copying the channels view. The Overview shows its + code. Likewise, a recipe that adds a view beside the channels view sets `RecipeConfig.views` and + `RecipeConfig.layouts`, which the base app merges into its own. +- **Cast:** the user is Cleo; the others are Bob, Cara and Dan, with initials avatars and no + photos. Channels: Design review, Weekend trip, Book club, Garden plot, General, and a direct + message with Bob. All of it, with fixed message ids, is in `src/base/demo-data.json`. +- **Content:** neutral and friendly. The demo app is public: no real names, no personal data, no + brand names. +- **Read-only:** the base app blocks every request that would change the demo app (sending, + editing, deleting, reacting, flagging, pinning, adding members, updating channels); a blocked + request never settles, so the UI keeps its optimistic state. Reads, watching, typing and the + user's read state still go through (`src/base/readOnly.ts`). Data changes go in the seed, never + in a recipe or a screenshot helper. `?writes=on` turns the guard off, for an app of your own. + +## Style rules + +`yarn workspace @stream-io/stream-chat-react-cookbook check:css` checks them: literal colors and +sizes, `var()`s of anything but `--str-chat__` tokens, CSS outside the layer, the prefix, and +layout properties on SDK markup. It can't judge whether a token fits; review that by eye. + +1. **Tokens only.** Colors, spacing, radii and type come from `--str-chat__*` semantic tokens: + `--str-chat__text-secondary`, `--str-chat__spacing-sm`, `--str-chat__radius-md`, + `--str-chat__font-caption-default`, … No hex, `rgb()` or named colors, and no `px`/`rem`/`em` + for spacing, radius or type. Border widths and component sizes may use `px`. +2. **One layer.** All recipe CSS sits inside `@layer stream-overrides { … }`. The base app declares + the order (`modern-normalize, stream, cookbook-base, stream-overrides`), so a recipe never needs + extra specificity or `!important`. +3. **One prefix.** Classes a recipe adds start with `.cookbook-`, followed by a name for the part it + styles, for example `.cookbook-pinned-by` or `.cookbook-channel-header__title`. SDK classes + (`.str-chat…`) may be restyled. +4. **Leave the SDK's layout alone.** Never set `display`, `position`, `flex`, `grid`, `float` or + offsets on a rule whose target is an SDK class. Style your own markup, or change sizes through a + component variable such as `--str-chat__channel-list-width`. Where the SDK has no variable, a + comment starting `cookbook-allow-layout:` right above the rule says why; known case: the width of + a thread beside a channel. + +## Where things are + +``` +examples/cookbook/ + src/base/ the base app (BaseApp.tsx), its CSS (base.css), client, demo data, + the side panel (SidePanel.tsx), URL helpers (OpenFromUrl: + ?channel=&thread=; Simulate: ?simulate=typing&as=), and useClickInTurn + for screenshot helpers that click through the UI + src/recipes/// + *.tsx, *.css exactly the recipe code the docs page shows + .recipe.tsx default-exports a RecipeConfig: what the recipe changes in the base app + (ChannelHeader, Wrapper, MessageList, sidePanelProps, views and + layouts merged into the base ones, chatViewProps, chatViewChildren, + chatProps, openFromUrl, theme), and imports the recipe's CSS + shots.json the screenshots the docs page uses (format in scripts/shots.mjs) + demo-data.json optional: users and channels only this recipe needs, in the shape of + src/base/demo-data.json; seeded and listed with the base data + scripts/seed.mjs creates or brings back in line the cast, channels, members, messages, + replies and pins; sets a message's attachments, mentions and custom + fields after sending it; sets read state and receipts, invites, and + the current user's pinned and archived channels, reminders, saved + messages and drafts (fields below) + scripts/shots.mjs takes every shot from the running app, writes them to --out + scripts/check-css.mjs the style-rule check +``` + +`` and `` match the docs URL: `/ui-cookbooks/headers/channel-header/` lives in +`src/recipes/headers/channel-header/`. A page with two solutions has two variants, for example +`css.recipe.tsx` and `component.recipe.tsx`. Open a recipe at +`http://localhost:5176/?recipe=/&variant=`; `?recipe=base` is the base app +alone, and no parameter lists every recipe. + +## Adding or changing a recipe + +1. Read the recipe's docs page and the pages it links to. Check every API it uses against + `stream-chat-react` `master` and `stream-chat` `release-v10`. +2. Write the code in `src/recipes///`, following the style rules, and add a + `.recipe.tsx` for each solution the page shows. +3. Put demo data only this recipe needs in its folder's `demo-data.json` (new ids, never reuse + one); change `src/base/demo-data.json` only for data every recipe shares. A message may carry + `attachments` (for example image URLs), which the seed sets after sending, since the demo app + refuses uploads. A message may also carry `mentions` (user ids), which the seed sets after + sending, since the demo app refuses a new message with mentions. A message may carry + `reminderForCurrentUser`: `{}` saves it for later, `{ "remindAt": "" }` sets a fixed + time, and `{ "remindAt": "+2m" }` a time that many minutes after the first seed, kept on later + runs, which is how a reminder becomes overdue (the server refuses a past time). The demo app's + `messaging` type has `user_message_reminders` off, so the seed reports that and skips them. + A channel may carry `draftsForCurrentUser`, the current user's unsent drafts in it: + `{ "text" }` for the channel's draft, with `"parent"` (a main-list message id) for a thread's + draft and `"quotedMessage"` (a message id) for one that quotes a message. Each is saved only + when the stored draft differs. A draft in a base channel would show in that channel's composer + in every recipe, so drafts go in the recipe's own channels. + + The other fields the seed reads, each set only when the stored state differs: + - On a channel: `createdBy` (a cast id that creates it instead of the current user), + `invites` (user ids left with a pending invite), `readBy` and `deliveredTo` (members who + read or received its last message), `readUpTo` (`{ userId: messageId }`), `unreadFrom` (a + message id the current user's unread state starts at), and `pinnedForCurrentUser` / + `archivedForCurrentUser`. + - On a message: `pinnedBy` (its author), `custom` (custom fields, such as + `{ "ai_generated": true }`), and `unreadForCurrentUser` on a thread's parent (its latest + reply stays unread). + + Then run `seed`. +4. List the shots in `shots.json`, reusing the file names the page already references. +5. Run the checks and the shots (below), then look at every image. +6. Copy the code into the docs page so it matches the folder exactly, formatted with the docs' + prettier (double quotes, width 80), apart from the base app's setup, which the page either + shows in full or links to on the UI Cookbooks Overview. Two rewrites are allowed: imports of + the shared side panel and the New conversation dialog become `./SidePanel` and + `./NewConversationDialog`, and the `cookbook-allow-layout:` prefix is dropped from comments. +7. In getstream.io, copy the images into `content/docs/chat-sdk/react/v15-rc/_assets/` and run the + docs checks. + +## The docs page + +Every recipe page has the same shape: + +1. What it builds: a one- or two-sentence intro, then the features in a note: `` that opens with the bold line **What you'll build**, then one short bullet per + feature: what the user gets, never how it's done (the steps cover that). Then the main + screenshot, captioned **Preview**. +2. Which level it uses, from the Overview's order: restyle, replace one part, compose, or change + behaviour in the state layer. +3. "Start from the base app", linking to the Overview. +4. The steps, each with its code and, where it helps, a screenshot next to it. +5. How it works. +6. Related pages. + +Every screenshot has a caption: a short bold line right above the image, **Preview** for the main +one and a few words saying what the image shows for the others (the site can't render image +captions below an image). Each captioned screenshot sits between two horizontal rules (`---`, with a +blank line on each side); consecutive screenshots share the rule between them. + +A component and its stylesheet go in one `` block, the code tab labelled `React` and the +stylesheet tab `CSS`, each with its `title` (see the Pin indicator page). + +Docs prose follows the v15 docs conventions: name methods as `Class.method()`, name every field with +its owner (`channel.state.typing`), say "paginator" for the object that loads items, and put example +scenarios in an `` titled **Example**. + +## Commands + +Run from the repository root unless noted. + +```bash +yarn install && yarn build # the SDK the cookbook imports +yarn workspace @stream-io/stream-chat-react-cookbook seed # demo data (idempotent) +yarn workspace @stream-io/stream-chat-react-cookbook dev # http://localhost:5176 +yarn workspace @stream-io/stream-chat-react-cookbook shots --out /content/docs/chat-sdk/react/v15-rc/_assets [--only /] +yarn workspace @stream-io/stream-chat-react-cookbook check:css +yarn workspace @stream-io/stream-chat-react-cookbook build # type-check + build, as CI does +``` + +`shots` needs the dev server running and Playwright's Chromium (`npx playwright install chromium`). +A relative `--out` is resolved from the repository root; an absolute path is safest. Shots use the `en-US` locale +and the UTC time zone, so dates look the same on every machine. File names must be unique across +all recipes. + +## Done when + +- [ ] `check:css` passes. +- [ ] `build` passes, and `npx eslint --max-warnings 0` and `npx prettier --check` pass for the + changed files. +- [ ] Every shot in `shots.json` is taken, looked at, and shows what the page says. +- [ ] The docs page's code matches the recipe folder. +- [ ] In getstream.io: the docs checks and the sample checker pass on the page, and the site builds. +- [ ] Anything that only renders wrong because of the SDK is reported, not hidden with CSS. diff --git a/.claude/skills/react-ui-cookbook b/.claude/skills/react-ui-cookbook new file mode 120000 index 0000000000..ab78786dce --- /dev/null +++ b/.claude/skills/react-ui-cookbook @@ -0,0 +1 @@ +../../.agents/skills/react-ui-cookbook \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1260e5a65..a313c35095 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: run: git diff --exit-code -- src/i18n/keys.ts - name: Build the example apps - # Type-checks and bundles every `examples/*` workspace (vite, tutorial, ai-chatbot) against + # Type-checks and bundles every `examples/*` workspace (vite, tutorial, ai-chatbot, cookbook) against # the `dist` built above, so an SDK change that breaks an example (or a stream-chat API one # relies on) fails here. None of the builds need Stream credentials. env: diff --git a/.gitignore b/.gitignore index b83ec27e19..7b971ec522 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,8 @@ typings/ /.env.local /.env.development /.env.production +# Local env files in the example apps, such as the cookbook's Giphy key +examples/*/.env*.local # next.js build output .next diff --git a/AGENTS.md b/AGENTS.md index 19e6fd0a2b..04bbdc4241 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ Respect repo-specific rules. Do not suppress lint rules broadly; justify and sco - `src/` — library source: `components/`, `context/`, `hooks/`, `store/`, `i18n/`, `styling/`, `a11y/`, `plugins/`, `utils/`, `types/`, `constants/`, `mock-builders/` - `src/plugins/` — separately exported entry points: `AIComponents`, `ChannelDetail`, `Emojis`, `SlotGeometry`, `SlotLayout`, `encoders` - `scripts/` — build/validation scripts -- `examples/` — private example workspaces: `examples/tutorial` (one folder per tutorial step), `examples/vite` (complete app), `examples/ai-chatbot` (Next.js AI chatbot) +- `examples/` — private example workspaces: `examples/tutorial` (one folder per tutorial step), `examples/vite` (complete app), `examples/ai-chatbot` (Next.js AI chatbot), `examples/cookbook` (the base app and code of the docs' UI Cookbook recipes; see `.agents/skills/react-ui-cookbook/SKILL.md`) - `ai-docs/` — integrator-facing migration guides (`ai-migration-v14-v15.md`, `i18n-v15-migration.md`, `instance-configuration.md`, …) and the hand-reviewed `i18n-v15-key-map.json` - `developers/` — dev notes (`BRANCHES.md`, `COMMIT.md`, `DEPRECATIONS.md`, `DEVELOPMENT.md`, `DOCUMENTATION.md`, `PR.md`, `RELEASE.md`) @@ -84,6 +84,7 @@ yarn validate-entry-isolation # fails if the main entry's ESM graph reaches ai-c # Examples yarn start:tutorial # @stream-io/stream-chat-react-tutorial dev server yarn start:vite # @stream-io/stream-chat-react-vite dev server +yarn start:cookbook # @stream-io/stream-chat-react-cookbook dev server (UI Cookbook recipes) yarn start:ai-chatbot # @stream-io/stream-chat-react-ai-chatbot dev server (run yarn build first) yarn examples:build # build all example workspaces ``` diff --git a/examples/cookbook/index.html b/examples/cookbook/index.html new file mode 100644 index 0000000000..67a3c0deba --- /dev/null +++ b/examples/cookbook/index.html @@ -0,0 +1,12 @@ + + + + + + UI Cookbook + + +
+ + + diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json new file mode 100644 index 0000000000..a1ba21760f --- /dev/null +++ b/examples/cookbook/package.json @@ -0,0 +1,34 @@ +{ + "name": "@stream-io/stream-chat-react-cookbook", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 5176 --strictPort", + "build": "tsc -b && vite build", + "preview": "vite preview --port 5176 --strictPort", + "check:css": "node scripts/check-css.mjs", + "seed": "node scripts/seed.mjs", + "shots": "node scripts/shots.mjs" + }, + "dependencies": { + "@stream-io/state-store": "^1.1.6", + "leaflet": "^1.9.4", + "lucide-react": "^1.61.0", + "modern-normalize": "^3.0.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-leaflet": "^5.0.0", + "stream-chat": "10.0.0-rc.25", + "stream-chat-react": "workspace:^" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@types/leaflet": "^1.9.20", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "typescript": "^6.0.3", + "vite": "^8.1.3" + } +} diff --git a/examples/cookbook/scripts/check-css.mjs b/examples/cookbook/scripts/check-css.mjs new file mode 100644 index 0000000000..e46e6f76d5 --- /dev/null +++ b/examples/cookbook/scripts/check-css.mjs @@ -0,0 +1,296 @@ +/** + * Checks every recipe stylesheet against the cookbook's style rules: + * + * layer every rule sits inside `@layer stream-overrides { … }`; no other layer, no @import + * colors colors come from --str-chat__ tokens: no hex, rgb()/hsl()…, named colors, or var()s + * of other custom properties. Setting a --str-chat__ token itself (theming) is allowed + * scale spacing, radius and type use --str-chat__ tokens, not px/rem/em values + * prefix classes the recipe adds start with `cookbook-` (SDK classes `str-chat…` may be restyled) + * layout no display/position/flex/grid/alignment/offsets on a rule that styles SDK markup, + * unless a comment right above the rule contains `cookbook-allow-layout:` and says why + * + * yarn workspace @stream-io/stream-chat-react-cookbook check:css + */ +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.dirname(fileURLToPath(new URL('../package.json', import.meta.url))); +const recipesDir = path.join(root, 'src/recipes'); + +const COLOR_PROPERTIES = + /^(color|background|background-color|background-image|border(-(top|right|bottom|left|block|inline)(-(start|end))?)?(-color)?|outline(-color)?|fill|stroke|box-shadow|text-shadow|text-decoration(-color)?|caret-color|accent-color|column-rule(-color)?)$/; +const SCALE_PROPERTIES = + /^(padding|margin|gap|row-gap|column-gap|border(-[a-z-]+)?-radius|font|font-size|line-height|letter-spacing)(-.+)?$/; +const LAYOUT_PROPERTIES = + /^(display|position|float|order|inset(-.+)?|top|right|bottom|left|flex(-.+)?|grid(-.+)?|place-.+|align-.+|justify-.+)$/; +const LITERAL_COLOR = /#[0-9a-f]{3,8}\b|\b(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(/i; +const SCALE_LITERAL = /(^|[\s(,])-?\d*\.?\d+(px|rem|em)\b/; +// CSS named colors (the keywords a literal color can hide behind). +const NAMED_COLORS = new Set( + `aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet + brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue + darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange + darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise + darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen + fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred + indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan + lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen + lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta + maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue + mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin + navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen + paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red + rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue + slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white + whitesmoke yellow yellowgreen`.split(/\s+/), +); + +const listCssFiles = async (dir) => { + const files = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) files.push(...(await listCssFiles(full))); + else if (entry.name.endsWith('.css')) files.push(full); + } + return files; +}; + +const problems = []; +const report = (file, line, rule, message) => + problems.push(`${path.relative(root, file)}:${line} [${rule}] ${message}`); + +/** + * Replaces comments with spaces (so offsets and line numbers stay put) and returns them with their + * end offsets, so a rule can find the comments written right above it. + */ +const extractComments = (css) => { + const comments = []; + let stripped = ''; + let i = 0; + while (i < css.length) { + if (css.startsWith('/*', i)) { + const end = css.indexOf('*/', i + 2); + if (end === -1) return { error: i }; + comments.push({ end: end + 2, text: css.slice(i + 2, end) }); + stripped += css.slice(i, end + 2).replace(/[^\n]/g, ' '); + i = end + 2; + } else { + stripped += css[i++]; + } + } + return { comments, stripped }; +}; + +/** Splits comment-free CSS into blocks, and statements outside any block. */ +const parseBlocks = (css) => { + const blocks = []; + const statements = []; + const stack = []; + let start = 0; + const lineOf = (index) => css.slice(0, index).split('\n').length; + for (let i = 0; i < css.length; i++) { + const char = css[i]; + if (char === '{') { + const raw = css.slice(start, i); + const block = { + declarations: [], + depth: stack.length, + line: lineOf(start + raw.search(/\S/)), + parent: stack[stack.length - 1], + selector: raw.trim().replace(/\s+/g, ' '), + selectorStart: start + raw.search(/\S/), + }; + blocks.push(block); + stack.push(block); + start = i + 1; + } else if (char === '}' || char === ';') { + const text = css.slice(start, i).trim(); + if (text) { + if (stack.length) stack[stack.length - 1].declarations.push(text); + else + statements.push({ + line: lineOf(start + css.slice(start, i).search(/\S/)), + text, + }); + } + if (char === '}') stack.pop(); + start = i + 1; + } + } + return { blocks, statements }; +}; + +/** The comments between the previous rule boundary and this block's selector. */ +const commentsAbove = (comments, original, block) => { + const before = original.slice(0, block.selectorStart); + const boundary = Math.max( + before.lastIndexOf('{'), + before.lastIndexOf('}'), + before.lastIndexOf(';'), + ); + return comments.filter((c) => c.end > boundary && c.end <= block.selectorStart); +}; + +/** The parts of a value outside var(): what's left once every var(--name, fallback) is removed. */ +const outsideVars = (value) => { + let result = ''; + let depth = 0; + for (let i = 0; i < value.length; i++) { + if (value.startsWith('var(', i) && depth === 0) { + depth = 1; + i += 3; + continue; + } + if (depth) { + if (value[i] === '(') depth++; + else if (value[i] === ')') depth--; + continue; + } + result += value[i]; + } + return result; +}; + +const varNames = (value) => [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1]); +const varFallbacks = (value) => + [...value.matchAll(/var\(\s*--[\w-]+\s*,([^)]*)\)/g)].map((m) => m[1]); + +const hasLiteralColor = (text) => + LITERAL_COLOR.test(text) || + (text.match(/[a-z]+/gi) ?? []).some((word) => NAMED_COLORS.has(word.toLowerCase())); + +// The rule styles SDK markup when its selector involves an SDK class and the element it targets +// (the last compound of the selector) isn't one the recipe added. +const stylesSdkMarkup = (selector) => + selector.split(',').some((part) => { + const compounds = part.trim().split(/\s*[\s>+~]\s*/); + const target = compounds[compounds.length - 1]; + return /\.str-chat/.test(part) && !/\.cookbook-/.test(target); + }); + +for (const file of await listCssFiles(recipesDir)) { + const original = await readFile(file, 'utf8'); + const extracted = extractComments(original); + if (extracted.error !== undefined) { + report( + file, + original.slice(0, extracted.error).split('\n').length, + 'syntax', + 'unterminated comment', + ); + continue; + } + const { comments, stripped } = extracted; + const { blocks, statements } = parseBlocks(stripped); + + for (const statement of statements) { + report( + file, + statement.line, + 'layer', + `"${statement.text}" is outside @layer stream-overrides`, + ); + } + + for (const block of blocks) { + if (block.depth === 0) { + if (block.selector !== '@layer stream-overrides') { + report( + file, + block.line, + 'layer', + `"${block.selector}" is outside @layer stream-overrides`, + ); + } + continue; + } + if (block.selector.startsWith('@layer') || block.selector.startsWith('@import')) { + report( + file, + block.line, + 'layer', + `recipes don't add layers of their own: "${block.selector}"`, + ); + continue; + } + if (block.selector.startsWith('@')) continue; + + for (const className of block.selector.match(/\.[a-zA-Z_-][\w-]*/g) ?? []) { + const name = className.slice(1); + if (!name.startsWith('cookbook-') && !name.startsWith('str-chat')) { + report(file, block.line, 'prefix', `.${name} should start with .cookbook-`); + } + } + + const sdkMarkup = stylesSdkMarkup(block.selector); + const layoutAllowed = commentsAbove(comments, original, block).some((c) => + /cookbook-allow-layout:\s*\S/.test(c.text), + ); + + for (const declaration of block.declarations) { + const separator = declaration.indexOf(':'); + if (separator === -1) continue; + const property = declaration.slice(0, separator).trim().toLowerCase(); + const value = declaration.slice(separator + 1).trim(); + const nonTokenVars = varNames(value).filter( + (name) => !name.startsWith('--str-chat__'), + ); + const literalText = [outsideVars(value), ...varFallbacks(value)].join(' '); + + // Setting an SDK token is theming, so `--str-chat__*` may hold literal values. A recipe's own + // custom properties may hold sizes, never colors. + if (property.startsWith('--')) { + if (!property.startsWith('--str-chat__') && hasLiteralColor(literalText)) { + report( + file, + block.line, + 'colors', + `${property}: ${value} — use a --str-chat__ color token`, + ); + } + continue; + } + + if ( + COLOR_PROPERTIES.test(property) && + (hasLiteralColor(literalText) || nonTokenVars.length) + ) { + report( + file, + block.line, + 'colors', + `${property}: ${value} — use a --str-chat__ color token`, + ); + } + if ( + SCALE_PROPERTIES.test(property) && + (SCALE_LITERAL.test(literalText) || nonTokenVars.length) + ) { + report( + file, + block.line, + 'scale', + `${property}: ${value} — use a --str-chat__ spacing, radius or font token`, + ); + } + if (sdkMarkup && LAYOUT_PROPERTIES.test(property) && !layoutAllowed) { + report( + file, + block.line, + 'layout', + `${property} on "${block.selector}" changes the SDK's layout; style your own markup, use a component variable, or justify it with a cookbook-allow-layout: comment`, + ); + } + } + } +} + +if (problems.length) { + console.error(problems.join('\n')); + console.error( + `\n${problems.length} problem(s). The rules are in .agents/skills/react-ui-cookbook/SKILL.md.`, + ); + process.exit(1); +} +console.log('Recipe CSS follows the cookbook style rules.'); diff --git a/examples/cookbook/scripts/seed.mjs b/examples/cookbook/scripts/seed.mjs new file mode 100644 index 0000000000..8961727a8b --- /dev/null +++ b/examples/cookbook/scripts/seed.mjs @@ -0,0 +1,528 @@ +/** + * Seeds into Stream's public demo app, from src/base/demo-data.json, the cookbook's users, + * channels, members, messages and their custom fields, the users a message mentions, attachments, thread replies, pins, + * receipts, members' read positions, the current user's channel and thread read state, and the + * channels the current user pinned or archived, channels another cast member created with the + * current user's invite still pending, the current user's reminders and messages saved for + * later, and the current user's unsent drafts in channels and threads. Running it again brings + * the app back in line with the file: missing channels, members, invites and messages are added (existing messages are looked up + * first, so only the missing ones are sent), changed names, texts and mentions are updated, and + * nothing that already matches is touched, so a re-run against unchanged data changes nothing. + * + * yarn workspace @stream-io/stream-chat-react-cookbook seed + */ +import { existsSync } from 'node:fs'; +import { readdir, readFile } from 'node:fs/promises'; +import { nsToMs, StreamChat } from 'stream-chat'; + +const readJson = async (url) => JSON.parse(await readFile(url, 'utf8')); +const demoData = await readJson(new URL('../src/base/demo-data.json', import.meta.url)); + +// A recipe that needs data of its own keeps it in src/recipes///demo-data.json, in +// the same shape (`users` and `channels`); it is seeded together with the base data. +const recipesDir = new URL('../src/recipes/', import.meta.url); +for (const group of await readdir(recipesDir, { withFileTypes: true })) { + if (!group.isDirectory()) continue; + for (const recipe of await readdir(new URL(`${group.name}/`, recipesDir), { + withFileTypes: true, + })) { + const file = new URL(`${group.name}/${recipe.name}/demo-data.json`, recipesDir); + if (!recipe.isDirectory() || !existsSync(file)) continue; + const extra = await readJson(file); + for (const user of extra.users ?? []) { + if (!demoData.users.some(({ id }) => id === user.id)) demoData.users.push(user); + } + demoData.channels.push(...(extra.channels ?? [])); + } +} + +const fetchToken = async (userId) => { + const url = new URL('https://pronto.getstream.io/api/auth/create-token'); + url.searchParams.set('environment', demoData.tokenEnvironment); + url.searchParams.set('user_id', userId); + const response = await fetch(url); + if (!response.ok) + throw new Error(`Token request for ${userId} failed: ${response.status}`); + return (await response.json()).token; +}; + +const isAlreadyExists = (error) => + /already exists/i.test(String(error?.message ?? error)); + +// One connected client per demo user. Connecting also creates the user with its name. +const clients = new Map(); + +try { + for (const user of demoData.users) { + const client = new StreamChat(demoData.apiKey, { + client: { allowServerSideConnect: true }, + }); + clients.set(user.id, client); + await client.connectUser(user, () => fetchToken(user.id)); + console.log(`user ${user.id} (${user.name})`); + } + + const owner = clients.get(demoData.currentUserId); + + for (const channelData of demoData.channels) { + // `createdBy` creates the channel as another cast member, for a channel the current user + // doesn't belong to, such as one they're only invited to. Every other channel is the current + // user's. An existing channel keeps its creator. + if (channelData.createdBy && !clients.has(channelData.createdBy)) + console.warn( + ` createdBy ${channelData.createdBy} of ${channelData.id} skipped: not a cast member`, + ); + const creator = clients.get(channelData.createdBy) ?? owner; + const channel = await creator + .channel('messaging', channelData.id, { + // Custom fields such as the name go under `custom`; the SDK reads `data.custom.name`. + ...(channelData.name && { custom: { name: channelData.name } }), + members: channelData.members.map((user_id) => ({ user_id })), + }) + .watch(); + console.log(`channel ${channelData.id}`); + + // An existing channel keeps its data and members, so reconcile both with the file. + if (channelData.name && channel.data?.custom?.name !== channelData.name) { + await channel.updatePartial({ set: { 'custom.name': channelData.name } }); + console.log(` name → ${channelData.name}`); + } + const { members } = channel.state.getLatestValue(); + const missingMembers = channelData.members.filter((id) => !members[id]); + if (missingMembers.length) { + await channel.addMembers({ members: missingMembers }); + console.log(` members + ${missingMembers.join(', ')}`); + } + + // `invites` lists users the creator invited and who haven't answered yet, for a recipe that + // shows pending invites. A user with a pending invite is left alone, so a re-run changes + // nothing. A user who accepted or declined is removed and invited again, which makes the invite + // pending once more. A user who is also in `members`, or is the creator, is skipped with a + // warning: they would join the channel without an invite. + for (const userId of channelData.invites ?? []) { + if (channelData.members.includes(userId) || clients.get(userId) === creator) { + console.warn( + ` invite of ${userId} skipped: already a member of ${channelData.id}`, + ); + continue; + } + if (!clients.has(userId)) { + console.warn(` invite of ${userId} skipped: not a cast member`); + continue; + } + const member = members[userId]; + const isPending = + member?.invited && !member.invite_accepted_at && !member.invite_rejected_at; + if (isPending) continue; + if (member) await channel.removeMembers({ members: [userId] }); + await channel.inviteMembers({ members: [userId] }); + console.log(` invited ${userId}`); + } + + // The demo app allows each user only a few messages a minute, and a send that fails because + // the message already exists still counts. So the messages already in the channel, thread + // replies included, are looked up by id first, in batches of 100, and only the missing ones + // are sent. + const storedById = new Map(); + const messageIds = channelData.messages.map(({ id }) => id); + for (let start = 0; start < messageIds.length; start += 100) { + const { messages: stored } = await channel.getMessagesById( + messageIds.slice(start, start + 100), + ); + for (const storedMessage of stored) storedById.set(storedMessage.id, storedMessage); + } + + // Messages go out in order, each from its author, so the list reads as a conversation. + // Receipts are for the main list, so the last message that isn't a thread reply is kept. + let lastSent; + for (const message of channelData.messages) { + const author = clients.get(message.user); + let sent = storedById.get(message.id); + if (!sent) { + try { + sent = ( + await author.channel('messaging', channelData.id).sendMessage({ + message: { + id: message.id, + text: message.text, + ...(message.parent && { parent_id: message.parent }), + }, + }) + ).message; + console.log(` message ${message.id}`); + } catch (error) { + // The id is taken outside this channel, or the lookup missed it: read it as it is. + if (!isAlreadyExists(error)) throw error; + sent = (await author.getMessage({ id: message.id })).message; + } + } + // An existing message whose text differs from the file gets the file's text. + if (sent.text !== message.text) { + await author.updateMessagePartial( + { id: message.id }, + { set: { text: message.text } }, + ); + console.log(` text of ${message.id} updated`); + } + + // `custom` holds a message's custom fields, for example `ai_generated` on an assistant's + // reply. They are set after it's sent, by its author, and only the ones whose stored value + // differs, so a re-run changes nothing. A message without `custom` keeps whatever it has. + // A key that names a top-level field (`text`, `id`, `user`) would overwrite that field, so + // it is skipped with a warning. + const reservedKeys = ['text', 'id', 'user']; + const changedCustom = Object.entries(message.custom ?? {}).filter( + ([key, value]) => { + if (reservedKeys.includes(key)) { + console.warn( + ` custom field "${key}" of ${message.id} skipped: it's a reserved field`, + ); + return false; + } + return ( + JSON.stringify(sent.custom?.[key] ?? sent[key]) !== JSON.stringify(value) + ); + }, + ); + if (changedCustom.length) { + await author.updateMessagePartial( + { id: message.id }, + { + set: Object.fromEntries( + changedCustom.map(([key, value]) => [`custom.${key}`, value]), + ), + }, + ); + console.log(` custom fields of ${message.id} set`); + } + + // `mentions` lists the ids of the users the text mentions (`@Cleo`), for a recipe that shows + // mentions; the server stores them as `mentioned_users`. The demo app refuses a new message + // with mentions, so they are set after it's sent, by its author, and only when the stored ids + // differ, so a re-run changes nothing. A message without `mentions` keeps whatever it has. + // An id that isn't a cast member and a member of the channel is skipped with a warning. + const mentions = (message.mentions ?? []).filter((userId) => { + const known = clients.has(userId) && channelData.members.includes(userId); + if (!known) + console.warn( + ` mention of ${userId} in ${message.id} skipped: not a cast member and a member of ${channelData.id}`, + ); + return known; + }); + const sameIds = (a, b) => + JSON.stringify([...a].sort()) === JSON.stringify([...b].sort()); + const mentionedIds = (sent.mentioned_users ?? []).map(({ id }) => id); + if (message.mentions && !sameIds(mentionedIds, mentions)) { + await author.updateMessagePartial( + { id: message.id }, + { set: { mentioned_users: mentions } }, + ); + console.log(` mentions of ${message.id} set`); + } + + // The demo app refuses uploads, so a message's attachments (image URLs and the like) are set + // on it after it's sent, by its author. Compared by value, so a re-run changes nothing. + // The server adds fields of its own to each attachment, so only the fields the file sets are compared. + const sameAttachments = (stored = []) => + stored.length === message.attachments.length && + message.attachments.every((attachment, index) => + Object.entries(attachment).every( + ([key, value]) => + // Fields the attachment type doesn't define come back under `custom`. + JSON.stringify(stored[index]?.[key] ?? stored[index]?.custom?.[key]) === + JSON.stringify(value), + ), + ); + if (message.attachments && !sameAttachments(sent.attachments)) { + await author.updateMessagePartial( + { id: message.id }, + { set: { attachments: message.attachments } }, + ); + console.log(` attachments of ${message.id} set`); + } + + // Only the author may pin their message in the demo app. An existing pin keeps its date, + // so a screenshot showing it doesn't change on every seed. + if (message.pinnedBy && !sent.pinned) { + await clients.get(message.pinnedBy).pinMessage({ id: message.id }); + console.log(` pinned ${message.id}`); + } + if (!message.parent) lastSent = sent; + } + + // `reminderForCurrentUser` gives a message a reminder of the current user's, for a recipe that + // lists saved messages and reminders: `{}` saves the message for later with no time, + // `{ remindAt: }` sets a fixed time, and `{ remindAt: "+2m" }` a time that many + // minutes after the run that creates it. The server refuses a time in the past, so a relative + // time is how a reminder becomes overdue: it is created once and then kept, like a pin's date, + // so its label stays the same on later runs. Reminders are looked up first and set only when + // one is missing or differs from the file (deleted and created again, which also turns a + // reminder into a saved message and back), so a re-run changes nothing. A message without the + // field keeps whatever reminder it has. A channel type with reminders turned off refuses them + // (`user_message_reminders`), which is reported once per channel and skipped. + const withReminder = channelData.messages.filter( + (message) => message.reminderForCurrentUser, + ); + if (withReminder.length && !channelData.members.includes(demoData.currentUserId)) { + console.warn( + ` reminders skipped: ${demoData.currentUserId} is not a member of ${channelData.id}`, + ); + } else if (withReminder.length) { + const { reminders: storedReminders } = await owner.queryReminders({ + filter: { message_id: { $in: withReminder.map(({ id }) => id) } }, + }); + for (const message of withReminder) { + const { remindAt } = message.reminderForCurrentUser; + const relativeMinutes = remindAt?.match(/^\+(\d+)m$/)?.[1]; + const stored = storedReminders.find( + ({ message_id }) => message_id === message.id, + ); + const storedMs = stored?.remind_at != null ? nsToMs(stored.remind_at) : null; + const isInLine = relativeMinutes + ? storedMs !== null + : storedMs === (remindAt ? Date.parse(remindAt) : null); + if (stored && isInLine) continue; + const wantedMs = relativeMinutes + ? Date.now() + Number(relativeMinutes) * 60 * 1000 + : remindAt && Date.parse(remindAt); + try { + if (stored) await owner.deleteReminder({ message_id: message.id }); + await owner.createReminder( + { message_id: message.id }, + wantedMs ? { remind_at: new Date(wantedMs) } : {}, + ); + console.log(` reminder of ${message.id} ${remindAt ?? 'saved for later'}`); + } catch (error) { + console.warn(` reminders skipped in ${channelData.id}: ${error.message}`); + break; + } + } + } + + // A member listed in both `readBy` and `readUpTo` would be marked read by one step and unread + // by the other on every run, and the current user's read state is set further down, so both + // are left out with a warning. That keeps a re-run from changing anything. + const readUpToIds = Object.keys(channelData.readUpTo ?? {}); + const readStateConflict = (userId, step) => { + if (userId === demoData.currentUserId) { + console.warn( + ` ${step} by ${userId} skipped: the seed sets the current user's own read state (read, or unreadFrom)`, + ); + return true; + } + if ((channelData.readBy ?? []).includes(userId) && readUpToIds.includes(userId)) { + console.warn( + ` ${step} by ${userId} skipped: in both readBy and readUpTo of ${channelData.id}`, + ); + return true; + } + return false; + }; + + // Receipts for a recipe that shows them: `readBy` users read the channel up to its last + // message, `deliveredTo` users only received it. Each is marked once, as that user, so a re-run + // keeps the times a screenshot shows. A channel without a message in its main list has nothing + // to mark. + const receipts = [ + ...(channelData.readBy ?? []) + .filter((id) => !readStateConflict(id, 'read')) + .map((id) => [id, 'read']), + ...(channelData.deliveredTo ?? []).map((id) => [id, 'delivered']), + ]; + if (receipts.length && !lastSent) + console.warn(` receipts skipped: ${channelData.id} has no message to mark`); + for (const [userId, kind] of lastSent ? receipts : []) { + const isMember = channelData.members.includes(userId) && clients.has(userId); + if (!isMember) { + console.warn(` ${kind} by ${userId} skipped: not a member of ${channelData.id}`); + continue; + } + const memberChannel = clients.get(userId).channel('messaging', channelData.id); + await memberChannel.watch(); + const own = memberChannel.state.read[userId]; + const reached = kind === 'read' ? own?.last_read : own?.last_delivered_at; + if (reached && reached >= lastSent.created_at) continue; + if (kind === 'read') await memberChannel.markRead(); + else + await clients.get(userId).markChannelsDelivered({ + latest_delivered_messages: [{ cid: memberChannel.cid, id: lastSent.id }], + }); + console.log(` ${kind} by ${userId}`); + } + + // `readUpTo` maps a member to the last main-list message they've read, for a recipe that shows + // who read how far. As that member, the channel is marked unread from the next main-list + // message, or read when it's the last one. Like the receipts above, it's marked only when the + // member's read state differs: the last read message id, or for the last message the time of the + // last read, so a re-run changes nothing. + const mainList = channelData.messages.filter((message) => !message.parent); + for (const [userId, messageId] of Object.entries(channelData.readUpTo ?? {})) { + if (readStateConflict(userId, 'read up to')) continue; + const isMember = channelData.members.includes(userId) && clients.has(userId); + if (!isMember) { + console.warn( + ` read up to by ${userId} skipped: not a member of ${channelData.id}`, + ); + continue; + } + const readIndex = mainList.findIndex(({ id }) => id === messageId); + if (readIndex === -1) { + console.warn( + ` read up to by ${userId} skipped: ${messageId} is not a main-list message of ${channelData.id}`, + ); + continue; + } + const nextMessage = mainList[readIndex + 1]; + const memberChannel = clients.get(userId).channel('messaging', channelData.id); + await memberChannel.watch(); + const own = memberChannel.state.read[userId]; + const isReadUpTo = nextMessage + ? own?.unread_messages > 0 && own.last_read_message_id === messageId + : !!own?.last_read && own.last_read >= lastSent.created_at; + if (isReadUpTo) continue; + if (nextMessage) await memberChannel.markUnread({ message_id: nextMessage.id }); + else await memberChannel.markRead(); + console.log(` read up to ${messageId} by ${userId}`); + } + + // The current user has read everything, so no badge depends on when the seed ran. A channel + // with `unreadFrom` (a message id) is the exception: it stays unread from that message on, for + // a recipe that shows unread state. A query returns no `first_unread_message_id`, so the check + // is that the last read message is the one before it. It is marked only when that differs, so + // a re-run changes nothing, and a run after the channel was read in the app marks it again. + const unreadIndex = channelData.unreadFrom + ? mainList.findIndex(({ id }) => id === channelData.unreadFrom) + : -1; + // On a channel another cast member created, `channel` is that member's, so the current user's + // read state is left alone. + if (creator !== owner) { + if (channelData.unreadFrom) + console.warn(` unread from skipped: ${channelData.id} has createdBy`); + } else if (channelData.unreadFrom && unreadIndex === -1) { + console.warn( + ` unread from skipped: ${channelData.unreadFrom} is not a main-list message of ${channelData.id}`, + ); + } else if (channelData.unreadFrom) { + const lastReadId = mainList[unreadIndex - 1]?.id; + const ownRead = channel.state.read[demoData.currentUserId]; + const isUnreadFrom = + ownRead?.unread_messages > 0 && ownRead.last_read_message_id === lastReadId; + if (!isUnreadFrom) { + await channel.markUnread({ message_id: channelData.unreadFrom }); + console.log(` unread from ${channelData.unreadFrom}`); + } + } else { + await channel.markRead().catch(() => undefined); + } + + // A parent message with `unreadForCurrentUser` keeps its thread unread for the current user, + // for a recipe that shows unread threads: opening a thread in the app marks it read on the + // server. `Channel.markUnread({ thread_id })` marks only the thread's latest reply unread (the + // server takes no message id with a thread id), so the thread ends up with one unread reply + // whatever its history. It is marked only when the server counts a different number, so a + // re-run changes nothing. + const unreadThreadIds = channelData.messages + .filter((message) => message.unreadForCurrentUser) + .map(({ id }) => id); + if (unreadThreadIds.length) { + const { threads = [] } = await owner.unreadCounts(); + for (const threadId of unreadThreadIds) { + const hasReplies = channelData.messages.some(({ parent }) => parent === threadId); + if (!mainList.some(({ id }) => id === threadId) || !hasReplies) { + console.warn( + ` unread thread ${threadId} skipped: not a parent message with replies in ${channelData.id}`, + ); + continue; + } + const unreadCount = + threads.find(({ parent_message_id }) => parent_message_id === threadId) + ?.unread_count ?? 0; + if (unreadCount === 1) continue; + // More than one unread reply: read the thread first, so only the latest one stays unread. + if (unreadCount > 1) await channel.markRead({ thread_id: threadId }); + await channel.markUnread({ thread_id: threadId }); + console.log(` thread ${threadId} unread`); + } + } + + // `pinnedForCurrentUser` and `archivedForCurrentUser` pin or archive the channel for the current + // user, for a recipe that shows pinned and archived channels; a channel without them is neither. + // Both live on the user's own membership, so they need the user to be a member. Each is changed + // only when the membership differs, so a re-run keeps the pin date a sorted list shows. + const membershipFlags = [ + ['pinned', channelData.pinnedForCurrentUser, 'pinned_at', 'pin', 'unpin'], + [ + 'archived', + channelData.archivedForCurrentUser, + 'archived_at', + 'archive', + 'unarchive', + ], + ]; + // On a channel another cast member created, `channel` is that member's, so its membership + // isn't the current user's and is left alone, like the read state above. + const isOwnMember = channelData.members.includes(demoData.currentUserId); + for (const [label, wanted, dateField, setMethod, unsetMethod] of membershipFlags) { + if (creator !== owner) { + if (wanted) console.warn(` ${label} skipped: ${channelData.id} has createdBy`); + continue; + } + const isSet = channel.state.membership?.[dateField] != null; + if (!!wanted === isSet) continue; + if (!isOwnMember) { + console.warn( + ` ${label} skipped: ${demoData.currentUserId} is not a member of ${channelData.id}`, + ); + continue; + } + await channel[wanted ? setMethod : unsetMethod](); + console.log(` ${wanted ? label : `not ${label}`} for ${demoData.currentUserId}`); + } + + // `draftsForCurrentUser` lists the current user's unsent drafts in the channel, for a recipe + // that lists drafts: `{ text }` for the channel's own draft, with `parent` (a main-list message + // id) for a thread's draft, and `quotedMessage` (a message id) for a draft that quotes one. + // Drafts are per user, so they are saved as the current user, and only when the stored draft + // differs, so a re-run keeps the time a screenshot shows. A channel or thread without an entry + // keeps whatever draft it has. A refused draft is reported and skipped. + for (const draft of channelData.draftsForCurrentUser ?? []) { + const where = draft.parent ? `thread ${draft.parent}` : channelData.id; + if (creator !== owner || !isOwnMember) { + console.warn(` draft in ${where} skipped: not the current user's own channel`); + continue; + } + const unknownId = [draft.parent, draft.quotedMessage].find( + (id) => id && !mainList.some((message) => message.id === id), + ); + if (unknownId) { + console.warn( + ` draft in ${where} skipped: ${unknownId} is not a main-list message of ${channelData.id}`, + ); + continue; + } + // A channel or thread without a draft answers with an error. + const stored = await channel + .getDraft({ parent_id: draft.parent }) + .then(({ draft: found }) => found) + .catch(() => undefined); + const isSame = + stored?.message.text === draft.text && + (stored.message.quoted_message_id ?? undefined) === draft.quotedMessage; + if (isSame) continue; + try { + await channel.createDraft({ + message: { + text: draft.text, + ...(draft.parent && { parent_id: draft.parent }), + ...(draft.quotedMessage && { quoted_message_id: draft.quotedMessage }), + }, + }); + console.log(` draft in ${where} saved`); + } catch (error) { + console.warn(` draft in ${where} skipped: ${error.message}`); + } + } + } +} finally { + for (const client of clients.values()) await client.disconnectUser(); +} diff --git a/examples/cookbook/scripts/shots.mjs b/examples/cookbook/scripts/shots.mjs new file mode 100644 index 0000000000..accae8fa8f --- /dev/null +++ b/examples/cookbook/scripts/shots.mjs @@ -0,0 +1,157 @@ +/** + * Takes the screenshots every recipe's shots.json lists, from the running cookbook app, and writes + * them where the docs read them. + * + * yarn workspace @stream-io/stream-chat-react-cookbook dev # in another terminal + * yarn workspace @stream-io/stream-chat-react-cookbook shots \ + * --out /path/to/getstream.io/content/docs/chat-sdk/react/v15-rc/_assets [--only headers/channel-header] + * + * A relative --out is resolved from the repository root. + * + * A shot in shots.json: + * file output file name (PNG) + * recipe recipe id, or "base" for the base app alone; defaults to the folder's recipe + * variant recipe variant (file name before .recipe.tsx); defaults to "default" + * query extra URL parameters, e.g. { "channel": "...", "thread": "..." } + * viewport { width, height }; defaults to the 1280×720 frame + * waitFor selector that must be visible before the shot (default: the message list) + * hover selector to hover first + * clip selector of the element to capture; omit for the whole frame + * hasText only the clip element containing this text + * padding pixels around the clip element (default 8) + * settleMs extra wait after everything is visible (default 800) + */ +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from '@playwright/test'; + +const root = path.dirname(fileURLToPath(new URL('../package.json', import.meta.url))); +const recipesDir = path.join(root, 'src/recipes'); +const args = process.argv.slice(2); +// Accepts `--name value` and `--name=value`. +const argValue = (name) => { + const inline = args.find((arg) => arg.startsWith(`${name}=`)); + if (inline) return inline.slice(name.length + 1); + const index = args.indexOf(name); + return index === -1 ? undefined : args[index + 1]; +}; + +// `yarn workspace … shots` runs in examples/cookbook, and Yarn reports no other starting folder, +// so a relative --out is resolved from the repository root (PROJECT_CWD) for a predictable result. +const outArg = argValue('--out'); +const outDir = outArg && path.resolve(process.env.PROJECT_CWD ?? process.cwd(), outArg); +const only = argValue('--only'); +const baseUrl = argValue('--url') ?? 'http://localhost:5176'; +if (!outDir) { + console.error('Pass --out , e.g. the docs v15-rc/_assets folder.'); + process.exit(1); +} + +const findShotFiles = async () => { + const files = []; + for (const group of await readdir(recipesDir, { withFileTypes: true })) { + if (!group.isDirectory()) continue; + for (const recipe of await readdir(path.join(recipesDir, group.name), { + withFileTypes: true, + })) { + if (!recipe.isDirectory()) continue; + const id = `${group.name}/${recipe.name}`; + if (only && id !== only) continue; + const file = path.join(recipesDir, id, 'shots.json'); + try { + files.push({ id, shots: JSON.parse(await readFile(file, 'utf8')) }); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + } + return files; +}; + +const shotFiles = await findShotFiles(); +if (!shotFiles.length) { + console.error(only ? `No recipe ${only} with a shots.json.` : 'No shots.json found.'); + process.exit(1); +} +const seen = new Map(); +for (const { id, shots } of shotFiles) { + for (const { file } of shots) { + if (seen.has(file)) { + console.error( + `${file} is listed by both ${seen.get(file)} and ${id}; names must be unique.`, + ); + process.exit(1); + } + seen.set(file, id); + } +} + +const browser = await chromium.launch(); +let failures = 0; + +try { + for (const { id, shots } of shotFiles) { + for (const shot of shots) { + const viewport = shot.viewport ?? { height: 720, width: 1280 }; + // A fixed locale and time zone, so dates in a shot don't depend on the machine taking it. + const page = await browser.newPage({ + deviceScaleFactor: 1, + locale: 'en-US', + timezoneId: 'UTC', + viewport, + }); + try { + const params = new URLSearchParams({ + recipe: shot.recipe ?? id, + variant: shot.variant ?? 'default', + ...shot.query, + }); + await page.goto(`${baseUrl}/?${params}`); + await page + .locator(shot.waitFor ?? '.str-chat__message-list') + .first() + .waitFor(); + if (shot.hover) await page.locator(shot.hover).first().hover(); + await page.waitForTimeout(shot.settleMs ?? 800); + + const target = path.join(outDir, shot.file); + if (shot.clip) { + let locator = page.locator(shot.clip); + if (shot.hasText) locator = locator.filter({ hasText: shot.hasText }); + const element = locator.first(); + if (!(await element.count())) throw new Error(`nothing matches ${shot.clip}`); + await element.scrollIntoViewIfNeeded(); + const box = await element.boundingBox(); + if (!box || box.width <= 0 || box.height <= 0) { + throw new Error(`${shot.clip} isn't visible`); + } + const padding = shot.padding ?? 8; + const x = Math.max(0, box.x - padding); + const y = Math.max(0, box.y - padding); + await page.screenshot({ + clip: { + height: Math.min(viewport.height, box.y + box.height + padding) - y, + width: Math.min(viewport.width, box.x + box.width + padding) - x, + x, + y, + }, + path: target, + }); + } else { + await page.screenshot({ path: target }); + } + console.log(`✓ ${id}: ${shot.file}`); + } catch (error) { + failures++; + console.error(`✗ ${id}: ${shot.file}: ${error.message}`); + } finally { + await page.close(); + } + } + } +} finally { + await browser.close(); +} + +process.exit(failures ? 1 : 0); diff --git a/examples/cookbook/src/base/BaseApp.tsx b/examples/cookbook/src/base/BaseApp.tsx new file mode 100644 index 0000000000..935f8eecfb --- /dev/null +++ b/examples/cookbook/src/base/BaseApp.tsx @@ -0,0 +1,143 @@ +import { Fragment, useEffect, useMemo } from 'react'; +import { ChannelPaginator } from 'stream-chat'; +import type { SortParamRequest } from 'stream-chat'; +import { + ChannelHeader, + ChannelNavigation, + Chat, + MessageComposer, + MessageList, + ThreadHeader, + useCreateChatClient, + useThreadContext, +} from 'stream-chat-react'; +import { + ChannelSlot, + ChatView, + ThreadSlot, + useActiveThread, +} from 'stream-chat-react/slot-layout'; +import type { LayoutDescriptor } from 'stream-chat-react/slot-layout'; +import { apiKey, channelFilters, currentUser, tokenProvider } from './client'; +import { OpenFromUrl } from './OpenFromUrl'; +import { makeReadOnly } from './readOnly'; +import type { RecipeConfig } from './recipe'; +import { createDemoSearchController } from './searchController'; +import { SIDE_PANEL_SLOT, SidePanel, SidePanelProvider } from './SidePanel'; +import { Simulate } from './Simulate'; + +const sort: SortParamRequest[] = [{ direction: -1, field: 'last_message_at' }]; + +// The channel, a thread opened from it, then the side panel recipes open their content in. +const defaultLayouts: LayoutDescriptor[] = [ + { id: 'channels', slots: ['channel', 'thread', SIDE_PANEL_SLOT] }, +]; + +// The base layouts with the recipe's: one with the same id replaces the base one, a new id is added. +const mergeLayouts = (base: LayoutDescriptor[], added: LayoutDescriptor[] = []) => [ + ...base.map((layout) => added.find(({ id }) => id === layout.id) ?? layout), + ...added.filter(({ id }) => !base.some((layout) => layout.id === id)), +]; + +// Marks the open thread as on screen, so its new replies are marked read. +const ActiveThread = () => { + const thread = useThreadContext(); + useActiveThread({ activeThread: thread }); + return null; +}; + +/** + * The app every recipe starts from: `Chat`, `ChatView` with the channel list, the open channel, a + * thread beside it and the side panel after them, in the SDK's default theme. A recipe changes it + * through `RecipeConfig`. + */ +export const BaseApp = ({ recipe }: { recipe: RecipeConfig }) => { + const client = useCreateChatClient({ + apiKey, + tokenOrProvider: tokenProvider, + userData: currentUser, + }); + + // Nothing a visitor clicks in the cookbook changes the shared demo app. + useEffect(() => (client ? makeReadOnly(client) : undefined), [client]); + + useEffect(() => { + if (!client || recipe.registerChannelPaginator === false) return; + const paginator = new ChannelPaginator({ + client, + filters: channelFilters, + id: 'cookbook', + // Presence keeps members' online status current, which several recipes show. + requestOptions: { presence: true, state: true }, + sort, + }); + client.channelManager.insertPaginator({ paginator }); + return () => { + client.channelManager.removePaginator(paginator); + }; + }, [client, recipe]); + + const chatProps = useMemo( + () => + client + ? { + searchController: createDemoSearchController(client), + ...recipe.chatProps?.(client), + } + : {}, + [client, recipe], + ); + + if (!client) return
Connecting…
; + + const RecipeChannelHeader = recipe.ChannelHeader ?? ChannelHeader; + const RecipeChannelNavigation = recipe.ChannelNavigation ?? ChannelNavigation; + const RecipeMessageList = recipe.MessageList ?? MessageList; + const Wrapper = recipe.Wrapper ?? Fragment; + + const defaultChannelsView = ( + <> + + + + + + + + + + + + + + + ); + + return ( +
+ + + + + {recipe.openFromUrl !== false && } + + {recipe.chatViewChildren} + + + + +
+ ); +}; diff --git a/examples/cookbook/src/base/OpenFromUrl.tsx b/examples/cookbook/src/base/OpenFromUrl.tsx new file mode 100644 index 0000000000..55ba3b95ab --- /dev/null +++ b/examples/cookbook/src/base/OpenFromUrl.tsx @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { useChatContext, useWorkspaceNavigation } from 'stream-chat-react'; +import { isDemoChannelId } from './client'; + +/** + * `?channel=` opens that channel, and `&thread=` also opens the thread + * beside it, so a screenshot can start from the exact state its recipe shows. + */ +export const OpenFromUrl = () => { + const { client } = useChatContext(); + const { openChannel, openThread } = useWorkspaceNavigation(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const channelId = params.get('channel'); + const threadParentId = params.get('thread'); + if (!channelId) return; + if (!isDemoChannelId(channelId)) { + console.error( + `?channel=${channelId} isn't one of the cookbook's channels; not opening it.`, + ); + return; + } + + let cancelled = false; + void (async () => { + try { + const channel = await client.channel('messaging', channelId).watch(); + if (cancelled) return; + openChannel(channel); + if (!threadParentId) return; + + // The parent may be older than the first page the channel loaded. + if (!channel.messagePaginator.getItem(threadParentId)) { + await channel.messagePaginator.jumpToMessage(threadParentId, { + suppressFocusSignal: true, + }); + } + const message = channel.messagePaginator.getItem(threadParentId); + if (cancelled) return; + if (message) openThread({ channel, message }); + else + console.warn( + 'No message %s in %s to open a thread from.', + threadParentId, + channelId, + ); + } catch (error) { + // URL values go in as arguments, never into the format string. + console.error("Couldn't open %s from the URL", channelId, error); + } + })(); + + return () => { + cancelled = true; + }; + // Runs once: the URL is read on load, and navigation afterwards is the user's. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +}; diff --git a/examples/cookbook/src/base/SidePanel.tsx b/examples/cookbook/src/base/SidePanel.tsx new file mode 100644 index 0000000000..558deaff84 --- /dev/null +++ b/examples/cookbook/src/base/SidePanel.tsx @@ -0,0 +1,222 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; +import type { ComponentType, PropsWithChildren, ReactNode, RefObject } from 'react'; +import { Button, useComponentContextIcons } from 'stream-chat-react'; +import { useChatViewNavigation, useSlotEntity } from 'stream-chat-react/slot-layout'; + +/** The side panel's slot in the channels layout, after the channel and the thread. */ +export const SIDE_PANEL_SLOT = 'sidePanel'; + +/** What a recipe opens in the side panel. */ +export type SidePanelContent = { + /** Names what the panel shows, so a button can tell whether its own content is the open one. */ + id: string; + /** The heading of the panel's title row, and the panel's name for assistive technology. */ + title: string; + /** + * The body renders its own title row and close button, as the SDK's `ChannelDetail` does, so the + * panel leaves its own out and `title` only names it. + */ + ownHeader?: boolean; + /** Everything under the title row. It reads its data itself, so it stays current. */ + body: ReactNode; +}; + +type SidePanelState = { + content?: SidePanelContent; + /** The element that had focus when the panel opened, to give it back when the panel closes. */ + openerRef: RefObject; + /** The panel's `aside` while it's on screen. */ + panelRef: RefObject; + setContent: (content?: SidePanelContent) => void; +}; + +const SidePanelStateContext = createContext({ + openerRef: { current: null }, + panelRef: { current: null }, + setContent: () => undefined, +}); + +/** Holds the content the side panel shows, for the buttons that open it and the panel to share. */ +export const SidePanelProvider = ({ children }: PropsWithChildren) => { + const [content, setContent] = useState(); + const openerRef = useRef(null); + const panelRef = useRef(null); + const value = useMemo(() => ({ content, openerRef, panelRef, setContent }), [content]); + + return ( + + {children} + + ); +}; + +/** The `id` the panel gives a user's profile, so a button can tell whether that profile is open. */ +const userProfileId = (userId: string) => `user-profile:${userId}`; + +/** + * Where focus goes when the panel closes: the element that opened it, or, when that one is gone + * (an item of a menu that has closed, say), the open channel's composer. + */ +const focusTargetAfterClose = (opener: HTMLElement | null) => + opener?.isConnected + ? opener + : document.querySelector( + '.str-chat__channel .str-chat__textarea textarea', + ); + +/** + * Opens and closes the side panel. It shows one thing at a time: opening anything replaces what it + * showed. A user's profile is the SDK's `userProfile` kind, bound in the panel's slot; everything + * else is content held by `SidePanelProvider`. Use it inside `ChatView`. + * + * Opening remembers the element that had focus; closing from inside the panel gives focus back to + * it. + */ +export const useSidePanel = () => { + const { content, openerRef, panelRef, setContent } = useContext(SidePanelStateContext); + const { open, release } = useChatViewNavigation(); + const profile = useSlotEntity({ kind: 'userProfile', slot: SIDE_PANEL_SLOT }); + + // Focus inside the panel means it moved there from the opener, which stays the one to go back to. + // Focus on the page itself, after a click on something that can't take focus, names no opener. + const rememberOpener = useCallback(() => { + const active = document.activeElement; + if (panelRef.current?.contains(active)) return; + openerRef.current = + active instanceof HTMLElement && active !== document.body ? active : null; + }, [openerRef, panelRef]); + + /** Shows `next` in the side panel, in place of whatever it showed. */ + const openSidePanel = useCallback( + (next: SidePanelContent) => { + rememberOpener(); + // Empties the slot of any profile and every layer on it. + release(SIDE_PANEL_SLOT); + setContent(next); + }, + [release, rememberOpener, setContent], + ); + + /** Shows the user's profile in the side panel, in place of whatever it showed. */ + const openUserProfile = useCallback( + (userId: string) => { + rememberOpener(); + setContent(undefined); + open( + { key: userId, kind: 'userProfile', source: { userId } }, + { slot: SIDE_PANEL_SLOT }, + ); + }, + [open, rememberOpener, setContent], + ); + + /** + * Empties the side panel. Focus that was inside it goes back to what opened it. Focus on the page + * body counts as inside too: some browsers leave it there after a click on a button, so a panel + * that closes on its own can move focus to the channel's composer. + */ + const closeSidePanel = useCallback(() => { + const active = document.activeElement; + const hadFocus = + active === document.body || !!(active && panelRef.current?.contains(active)); + release(SIDE_PANEL_SLOT); + setContent(undefined); + if (hadFocus) focusTargetAfterClose(openerRef.current)?.focus(); + openerRef.current = null; + }, [openerRef, panelRef, release, setContent]); + + /** The id of what the panel shows, or `undefined` while it's closed. */ + const openId = content?.id ?? (profile ? userProfileId(profile.userId) : undefined); + + return { closeSidePanel, openId, openSidePanel, openUserProfile }; +}; + +export type SidePanelProps = { + /** Renders a user's profile under the title row. Without it, the panel never shows a profile. */ + UserProfile?: ComponentType<{ userId: string }>; +}; + +/** + * The column at the far right of the channels view, after the channel and the thread, while it has + * something to show: a title row with a close button, then the body. Render it as the view's last + * child. + */ +export const SidePanel = ({ UserProfile }: SidePanelProps) => { + const { content } = useContext(SidePanelStateContext); + const profile = useSlotEntity({ kind: 'userProfile', slot: SIDE_PANEL_SLOT }); + + const shown: SidePanelContent | undefined = + content ?? + (profile && UserProfile + ? { + body: , + id: userProfileId(profile.userId), + title: 'Profile', + } + : undefined); + + if (!shown) return null; + // Keyed by what it shows, so other content starts afresh. + return ; +}; + +/** + * The panel around one piece of content: an `aside` named by the title, the title row with its + * close button (left out when the body brings its own), then the body. It takes focus when it + * opens: the title, or the whole panel when the body has its own title row. + */ +const SidePanelFrame = ({ body, ownHeader, title }: SidePanelContent) => { + const { IconXmark } = useComponentContextIcons(); + const { panelRef } = useContext(SidePanelStateContext); + const { closeSidePanel } = useSidePanel(); + const headingRef = useRef(null); + const titleId = useId(); + + useEffect(() => { + (ownHeader ? panelRef.current : headingRef.current)?.focus(); + }, [ownHeader, panelRef]); + + return ( + + ); +}; diff --git a/examples/cookbook/src/base/Simulate.tsx b/examples/cookbook/src/base/Simulate.tsx new file mode 100644 index 0000000000..34309d3193 --- /dev/null +++ b/examples/cookbook/src/base/Simulate.tsx @@ -0,0 +1,47 @@ +import { useEffect } from 'react'; +import { StreamChat } from 'stream-chat'; +import { apiKey, fetchToken, isDemoChannelId } from './client'; + +/** + * `?simulate=typing&as=&channel=` signs in a second user in the page and keeps them + * typing in the channel, for screenshots of typing indicators. A second client is needed because a + * user never sees their own typing. + */ +export const Simulate = () => { + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const userId = params.get('as'); + const channelId = params.get('channel'); + if (params.get('simulate') !== 'typing' || !userId || !isDemoChannelId(channelId)) + return; + + const other = new StreamChat(apiKey); + let cancelled = false; + let timer: ReturnType | undefined; + + void (async () => { + try { + await other.connectUser({ id: userId }, () => fetchToken(userId)); + if (cancelled) return; + const channel = await other.channel('messaging', channelId).watch(); + if (cancelled) return; + // `keystroke()` sends typing.start and is throttled by the SDK, so repeating it keeps + // the indicator up for as long as the page is open. + await channel.keystroke(); + timer = setInterval(() => void channel.keystroke(), 2000); + } catch (error) { + console.error('Simulated typing failed', error); + } + })(); + + // Cleanup can run while the client is still connecting (StrictMode does that on purpose); the + // `cancelled` checks above stop the rest, and disconnecting here closes whatever was opened. + return () => { + cancelled = true; + if (timer) clearInterval(timer); + void other.disconnectUser(); + }; + }, []); + + return null; +}; diff --git a/examples/cookbook/src/base/base.css b/examples/cookbook/src/base/base.css new file mode 100644 index 0000000000..2c60601edc --- /dev/null +++ b/examples/cookbook/src/base/base.css @@ -0,0 +1,90 @@ +/* + * Layer order, first to last: a CSS reset, the SDK's stylesheet, the base app, then every recipe's + * CSS in `stream-overrides`. A recipe's rules win over both the SDK and the base app without extra + * specificity or !important. This is the setup the docs' Theming page teaches; recipes put all their + * CSS in `stream-overrides` and never add a layer of their own. + */ +@layer modern-normalize, stream, cookbook-base, stream-overrides; + +@import 'modern-normalize/modern-normalize.css' layer(modern-normalize); +@import 'stream-chat-react/dist/css/index.css' layer(stream); +@import 'stream-chat-react/dist/css/channel-detail.css' layer(stream); +@import 'stream-chat-react/dist/css/ai-components.css' layer(stream); + +/* The base app: the Quick start's layout.css, in the 1280×720 frame screenshots are taken in. */ +@layer cookbook-base { + html, + body, + #root { + height: 100%; + margin: 0; + } + + .str-chat { + --str-chat__channel-list-width: 320px; + } + + .cookbook-frame { + height: 100%; + } + + .cookbook-status { + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-body-default); + padding: var(--str-chat__spacing-xl); + } + + /* + * The side panel (SidePanel.tsx) recipes open their content in: a 360px column after the channel + * and the thread, divided from them like the thread is. + */ + .cookbook-side-panel { + display: flex; + flex: 0 0 360px; + flex-direction: column; + min-width: 0; + min-height: 0; + border-inline-start: 1px solid var(--str-chat__border-core-default); + background: var(--str-chat__background-core-elevation-1); + color: var(--str-chat__text-primary); + } + + /* As tall as the channel and thread headers beside it. */ + .cookbook-side-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--str-chat__spacing-sm); + box-sizing: border-box; + height: var(--str-chat__channel-header-height); + padding: var(--str-chat__spacing-md); + border-block-end: 1px solid var(--str-chat__border-core-default); + } + + .cookbook-side-panel__title { + margin: 0; + font: var(--str-chat__font-heading-sm); + } + + /* The panel and its title take focus when it opens, only so reading starts there: no ring. */ + .cookbook-side-panel:focus, + .cookbook-side-panel__title:focus { + outline: none; + } + + /* The body scrolls on its own and sets no padding, so a body can fill it edge to edge. */ + .cookbook-side-panel__body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow-y: auto; + } + + /* The heading of a section inside a panel's body. */ + .cookbook-side-panel__section-title { + margin: 0 0 var(--str-chat__spacing-xs); + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-emphasis); + } +} diff --git a/examples/cookbook/src/base/client.ts b/examples/cookbook/src/base/client.ts new file mode 100644 index 0000000000..a30c0fe5a8 --- /dev/null +++ b/examples/cookbook/src/base/client.ts @@ -0,0 +1,77 @@ +import type { ChannelFilters } from 'stream-chat'; +import demoData from './demo-data.json'; + +/** + * The cookbook runs against Stream's public demo app. Its token endpoint signs a token for any + * user id, so the app needs no secret. Everything in that app is public: keep the content neutral. + */ +export const apiKey = demoData.apiKey; + +export const currentUser = demoData.users.find( + (user) => user.id === demoData.currentUserId, +) as { id: string; name: string }; + +type DemoChannel = { id: string }; +type DemoData = { channels?: DemoChannel[] }; + +const channelIdsOf = (data: DemoData) => + (data.channels ?? []).map((channel) => channel.id); + +// A recipe that needs data of its own keeps it in its folder's demo-data.json, in the same shape. +// Keyed by recipe id (`/`). +const recipeDemoData = Object.fromEntries( + Object.entries( + import.meta.glob('../recipes/*/*/demo-data.json', { + eager: true, + import: 'default', + }), + ).map(([path, data]) => [ + path.replace(/^\.\.\/recipes\/|\/demo-data\.json$/g, ''), + data, + ]), +); + +/** Every channel the cookbook owns: the base ones and every recipe's own. */ +export const demoChannelIds = [demoData, ...Object.values(recipeDemoData)].flatMap( + channelIdsOf, +); + +/** + * The channels the open recipe shows in its lists and search: the base ones plus that recipe's + * own, so one recipe's channels never appear in another recipe's screenshots. + */ +export const visibleChannelIds = [ + ...channelIdsOf(demoData), + ...channelIdsOf( + recipeDemoData[new URLSearchParams(window.location.search).get('recipe') ?? ''] ?? {}, + ), +]; + +/** + * The base app's channel filters: only the open recipe's channels with the user in them, so other + * activity in the shared demo app never shows up. A recipe that registers its own paginators builds + * its filters on these. + */ +export const channelFilters: ChannelFilters = { + id: { $in: visibleChannelIds }, + members: { $in: [currentUser.id] }, + type: 'messaging', +}; + +export const fetchToken = async (userId: string): Promise => { + const url = new URL('https://pronto.getstream.io/api/auth/create-token'); + url.searchParams.set('environment', demoData.tokenEnvironment); + url.searchParams.set('user_id', userId); + const response = await fetch(url); + if (!response.ok) throw new Error(`Token request failed: ${response.status}`); + return ((await response.json()) as { token: string }).token; +}; + +export const tokenProvider = () => fetchToken(currentUser.id); + +/** + * `Channel.watch()` creates a channel that doesn't exist yet. Helpers that take a channel id from the + * URL open only the cookbook's own channels, so a typo never creates one in the shared demo app. + */ +export const isDemoChannelId = (id: string | null): id is string => + !!id && demoChannelIds.includes(id); diff --git a/examples/cookbook/src/base/demo-data.json b/examples/cookbook/src/base/demo-data.json new file mode 100644 index 0000000000..af1a16ebb8 --- /dev/null +++ b/examples/cookbook/src/base/demo-data.json @@ -0,0 +1,173 @@ +{ + "apiKey": "xzwhhgtazy6h", + "tokenEnvironment": "public-shared-chat-redesign", + "currentUserId": "docs-v15-ck-cleo", + "users": [ + { "id": "docs-v15-ck-cleo", "name": "Cleo" }, + { "id": "docs-v15-ck-bob", "name": "Bob" }, + { "id": "docs-v15-ck-cara", "name": "Cara" }, + { "id": "docs-v15-ck-dan", "name": "Dan" } + ], + "channels": [ + { + "id": "docs-v15-cb-design", + "name": "Design review", + "members": [ + "docs-v15-ck-cleo", + "docs-v15-ck-bob", + "docs-v15-ck-cara", + "docs-v15-ck-dan" + ], + "messages": [ + { + "id": "docs-v15-ck-design-1", + "user": "docs-v15-ck-cara", + "text": "I shared the new icon set in the design folder." + }, + { + "id": "docs-v15-ck-design-2", + "user": "docs-v15-ck-bob", + "text": "They look great. Can we use the rounded ones in the sidebar?" + }, + { + "id": "docs-v15-ck-design-3", + "user": "docs-v15-ck-cleo", + "text": "Yes, the rounded set fits better with the avatars." + }, + { + "id": "docs-v15-ck-design-4", + "user": "docs-v15-ck-dan", + "text": "Should the buttons follow the same style?" + }, + { + "id": "docs-v15-ck-design-4-r1", + "user": "docs-v15-ck-cara", + "parent": "docs-v15-ck-design-4", + "text": "I think so, the corners should match." + }, + { + "id": "docs-v15-ck-design-4-r2", + "user": "docs-v15-ck-cleo", + "parent": "docs-v15-ck-design-4", + "text": "Agreed, let us try a 12px radius." + }, + { + "id": "docs-v15-ck-design-4-r3", + "user": "docs-v15-ck-bob", + "parent": "docs-v15-ck-design-4", + "text": "I can update the button component today." + }, + { + "id": "docs-v15-ck-design-5", + "user": "docs-v15-ck-cara", + "text": "Review is on Thursday at 10:00, the agenda is in the doc.", + "pinnedBy": "docs-v15-ck-cara" + }, + { + "id": "docs-v15-ck-design-6", + "user": "docs-v15-ck-cara", + "text": "I will send the updated mockups after lunch." + }, + { + "id": "docs-v15-ck-design-7", + "user": "docs-v15-ck-bob", + "text": "Thanks Cara, looking forward to it!" + } + ] + }, + { + "id": "docs-v15-cb-trip", + "name": "Weekend trip", + "members": ["docs-v15-ck-cleo", "docs-v15-ck-bob", "docs-v15-ck-cara"], + "messages": [ + { + "id": "docs-v15-ck-trip-1", + "user": "docs-v15-ck-bob", + "text": "The cabin is booked for Saturday" + }, + { + "id": "docs-v15-ck-trip-2", + "user": "docs-v15-ck-cara", + "text": "I will bring the board games" + }, + { + "id": "docs-v15-ck-trip-3", + "user": "docs-v15-ck-cleo", + "text": "Perfect, I will bring snacks" + } + ] + }, + { + "id": "docs-v15-cb-dm-bob", + "members": ["docs-v15-ck-cleo", "docs-v15-ck-bob"], + "messages": [ + { + "id": "docs-v15-ck-dm-bob-1", + "user": "docs-v15-ck-cleo", + "text": "Lunch tomorrow?" + }, + { + "id": "docs-v15-ck-dm-bob-2", + "user": "docs-v15-ck-bob", + "text": "Sure, see you at noon 🙂" + } + ] + }, + { + "id": "docs-v15-cb-books", + "name": "Book club", + "members": ["docs-v15-ck-cleo", "docs-v15-ck-cara", "docs-v15-ck-dan"], + "messages": [ + { + "id": "docs-v15-ck-books-1", + "user": "docs-v15-ck-dan", + "text": "Who has finished chapter five?" + }, + { + "id": "docs-v15-ck-books-2", + "user": "docs-v15-ck-cara", + "text": "Great, the meeting is on Tuesday then." + } + ] + }, + { + "id": "docs-v15-cb-garden", + "name": "Garden plot", + "members": ["docs-v15-ck-cleo", "docs-v15-ck-dan"], + "messages": [ + { + "id": "docs-v15-ck-garden-1", + "user": "docs-v15-ck-dan", + "text": "The tomatoes need water this weekend." + }, + { + "id": "docs-v15-ck-garden-2", + "user": "docs-v15-ck-cleo", + "text": "I will water them on Sunday morning." + } + ] + }, + { + "id": "docs-v15-cb-general", + "name": "General", + "members": [ + "docs-v15-ck-cleo", + "docs-v15-ck-bob", + "docs-v15-ck-cara", + "docs-v15-ck-dan" + ], + "messages": [ + { + "id": "docs-v15-ck-general-1", + "user": "docs-v15-ck-dan", + "text": "Welcome to the team chat, everyone!" + }, + { + "id": "docs-v15-ck-general-2", + "user": "docs-v15-ck-bob", + "text": "Thanks Dan, happy to be here." + } + ] + } + ] +} diff --git a/examples/cookbook/src/base/readOnly.ts b/examples/cookbook/src/base/readOnly.ts new file mode 100644 index 0000000000..61cc0b8ab7 --- /dev/null +++ b/examples/cookbook/src/base/readOnly.ts @@ -0,0 +1,37 @@ +import type { StreamChat } from 'stream-chat'; + +/** + * Requests the cookbook may send to the shared demo app: reads, watching, typing events and the + * user's own read state. Everything else (sending, editing, deleting, reacting, flagging, pinning, + * adding members, updating channels) would change data every screenshot depends on. + */ +const ALLOWED_WRITES = [ + /\/chat\/channels$/, // queryChannels + /\/chat\/channels\/read$/, + /\/chat\/channels\/[^/]+\/[^/]+\/(query|event|read|unread|stop-watching)$/, + /\/chat\/channels\/[^/]+\/[^/]+\/bans\/query$/, + /\/chat\/(drafts|reminders|scheduled_messages)\/query$/, + /\/chat\/threads$/, + /\/polls\/query$/, +]; + +/** + * Makes the cookbook read-only against the demo app: a blocked request never settles, so the UI + * keeps its optimistic state (a reaction shows, a sent message stays "sending") and the server + * stays unchanged. `?writes=on` turns it off, for testing against an app of your own. + */ +export const makeReadOnly = (client: StreamChat) => { + if (new URLSearchParams(window.location.search).get('writes') === 'on') + return () => undefined; + + const id = client.axiosInstance.interceptors.request.use((config) => { + const method = (config.method ?? 'get').toLowerCase(); + const path = (config.url ?? '').split('?')[0]; + if (method === 'get' || ALLOWED_WRITES.some((pattern) => pattern.test(path))) + return config; + console.info(`Cookbook is read-only: ${method.toUpperCase()} ${path} was not sent.`); + return new Promise(() => undefined); + }); + + return () => client.axiosInstance.interceptors.request.eject(id); +}; diff --git a/examples/cookbook/src/base/recipe.ts b/examples/cookbook/src/base/recipe.ts new file mode 100644 index 0000000000..b2181abdf6 --- /dev/null +++ b/examples/cookbook/src/base/recipe.ts @@ -0,0 +1,100 @@ +import type { ComponentType, PropsWithChildren, ReactNode } from 'react'; +import type { StreamChat } from 'stream-chat'; +import type { ChatProps } from 'stream-chat-react'; +import type { ChatViewProps, LayoutDescriptor } from 'stream-chat-react/slot-layout'; +import type { SidePanelProps } from './SidePanel'; + +/** + * What one recipe variant changes in the base app. Each `*.recipe.tsx` file under `src/recipes/` + * default-exports one, and imports its own CSS, so only the selected recipe's styles load. + */ +export type RecipeConfig = { + /** Replaces the SDK's `ChannelHeader` in the channel panel. */ + ChannelHeader?: ComponentType; + /** + * Replaces the SDK's `ChannelNavigation` in the base app's channels view. Ignored when + * `views.channels` or `chatViewProps.views` replaces the view. + */ + ChannelNavigation?: ComponentType; + /** + * Extra props for `Chat`, built once per client, for example a `searchController`. The base app + * passes `client` and `theme` itself. + */ + chatProps?: (client: StreamChat) => Partial>; + /** Rendered inside `ChatView`, beside the active view, for code that must outlive view switches. */ + chatViewChildren?: ReactNode; + /** + * Props for `ChatView`: views, layouts, slot rules, navigation overrides, slot renderers. Each + * one replaces the base app's, so `chatViewProps.views` and `chatViewProps.layouts` win over + * `RecipeConfig.views` and `RecipeConfig.layouts`, which add to them. + */ + chatViewProps?: Omit; + /** + * Layouts merged into the base app's by `id`: a new `id` adds a layout, and a base one's `id` + * (`channels`) replaces it. Each view in `RecipeConfig.views` needs its layout here. + */ + layouts?: LayoutDescriptor[]; + /** + * Replaces the SDK's `MessageList` in the base app's channels view: the channel's and the + * thread's alike, so both lists behave the same. Ignored when `views.channels` or + * `chatViewProps.views` replaces the view. + */ + MessageList?: ComponentType; + /** + * `false` turns off the base app's `?channel=` / `?thread=` opener, for a recipe that reads the + * URL itself. + */ + openFromUrl?: boolean; + /** + * Props for the base app's `SidePanel`, for example the `UserProfile` it renders a profile + * with. Ignored when `views.channels` or `chatViewProps.views` replaces the channels view. + */ + sidePanelProps?: SidePanelProps; + /** + * `false` leaves out the base app's channel paginator, for a recipe that registers its own on + * `client.channelManager`, with `channelFilters` from `base/client` to show the same channels. + */ + registerChannelPaginator?: boolean; + /** The `theme` passed to `Chat`, for example `str-chat__theme-dark`. */ + theme?: string; + /** + * Views merged into the base app's by key: a new key adds a view beside the channels view, and + * `channels` replaces it. Each view needs a layout with the same `id` in `RecipeConfig.layouts`, + * and a way to switch to it, for example `ChatView.Selector` in `chatViewChildren`. + */ + views?: ChatViewProps['views']; + /** + * Wraps `ChatView` inside `Chat` and `SidePanelProvider`, for example with `WithComponents`, so + * what it renders can open the side panel. + */ + Wrapper?: ComponentType; +}; + +type RecipeModule = { default: RecipeConfig }; + +// `src/recipes///.recipe.tsx` → id `/`, variant ``. +const recipeModules = import.meta.glob('../recipes/*/*/*.recipe.tsx'); + +const parseRecipePath = (path: string) => { + const match = path.match(/\.\.\/recipes\/([^/]+\/[^/]+)\/([^/]+)\.recipe\.tsx$/); + return match ? { id: match[1], variant: match[2] } : undefined; +}; + +export const listRecipes = () => + Object.keys(recipeModules) + .map(parseRecipePath) + .filter((entry): entry is { id: string; variant: string } => !!entry) + .sort((a, b) => `${a.id}/${a.variant}`.localeCompare(`${b.id}/${b.variant}`)); + +/** Loads `?recipe=/&variant=`; the variant defaults to `default`. */ +export const loadRecipe = async ( + id: string, + variant = 'default', +): Promise => { + // `id` and `variant` come from the URL, so only the glob's own keys are looked up. + const path = `../recipes/${id}/${variant}.recipe.tsx`; + const load = Object.prototype.hasOwnProperty.call(recipeModules, path) + ? recipeModules[path] + : undefined; + return load ? (await load()).default : undefined; +}; diff --git a/examples/cookbook/src/base/searchController.ts b/examples/cookbook/src/base/searchController.ts new file mode 100644 index 0000000000..3274e7bbe2 --- /dev/null +++ b/examples/cookbook/src/base/searchController.ts @@ -0,0 +1,28 @@ +import { + ChannelSearchSource, + MessageSearchSource, + SearchController, + UserSearchSource, +} from 'stream-chat'; +import type { StreamChat } from 'stream-chat'; +import { visibleChannelIds } from './client'; +import demoData from './demo-data.json'; + +/** + * The default sources, kept to the cookbook's own channels and cast, so results from other data + * in the shared demo app never show up in a screenshot. The base app passes it to `Chat`; a recipe's + * `chatProps` can replace it. + */ +export const createDemoSearchController = (client: StreamChat) => + new SearchController({ + client, + sources: [ + new ChannelSearchSource(client, { filters: { id: { $in: visibleChannelIds } } }), + new UserSearchSource(client, { + filters: { id: { $in: demoData.users.map((user) => user.id) } }, + }), + new MessageSearchSource(client, { + messageSearchChannelFilters: { id: { $in: visibleChannelIds } }, + }), + ], + }); diff --git a/examples/cookbook/src/base/useClickInTurn.ts b/examples/cookbook/src/base/useClickInTurn.ts new file mode 100644 index 0000000000..5341735b4b --- /dev/null +++ b/examples/cookbook/src/base/useClickInTurn.ts @@ -0,0 +1,43 @@ +import { useEffect } from 'react'; + +/** + * One click of a screenshot helper: `find` returns the element to click, or nothing while it isn't + * on screen yet. `retry`, when given, runs while `find` finds nothing, to bring the element on + * screen, for example by opening the menu that holds it again. + */ +export type ClickStep = { + find: () => HTMLElement | null | undefined; + retry?: () => void; +}; + +/** The first element matching `selector` whose text includes `text`. */ +export const findByText = (selector: string, text: string) => + [...document.querySelectorAll(selector)].find((element) => + element.textContent?.includes(text), + ); + +/** + * For screenshot helpers that open a recipe's UI from the URL, as a user would: clicks each step's + * element in turn, waiting for each one to appear first. It checks every 200 ms and stops after + * the last click, or when the component unmounts. An empty list clicks nothing. Pass the same + * array on every render, for example one built at module level from the URL, or the clicks start + * over. + */ +export const useClickInTurn = (steps: ClickStep[]) => { + useEffect(() => { + if (!steps.length) return; + let next = 0; + const timer = setInterval(() => { + const step = steps[next]; + const target = step.find(); + if (!target) { + step.retry?.(); + return; + } + target.click(); + next += 1; + if (next === steps.length) clearInterval(timer); + }, 200); + return () => clearInterval(timer); + }, [steps]); +}; diff --git a/examples/cookbook/src/main.tsx b/examples/cookbook/src/main.tsx new file mode 100644 index 0000000000..2e199dfad1 --- /dev/null +++ b/examples/cookbook/src/main.tsx @@ -0,0 +1,55 @@ +import { StrictMode } from 'react'; +import type { ReactNode } from 'react'; +import ReactDOM from 'react-dom/client'; +import { BaseApp } from './base/BaseApp'; +import { listRecipes, loadRecipe } from './base/recipe'; +import './base/base.css'; + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +const params = new URLSearchParams(window.location.search); +const recipeId = params.get('recipe'); + +// Screens outside the app carry `str-chat`, so the SDK's tokens resolve in them too. +const Status = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +const RecipeIndex = () => ( + + + +); + +const start = async () => { + // No `?recipe=` shows the list of recipes; `?recipe=base` is the base app on its own. + if (!recipeId) return root.render(); + + const variant = params.get('variant') || 'default'; + const recipe = recipeId === 'base' ? {} : await loadRecipe(recipeId, variant); + if (!recipe) { + return root.render( + + No recipe “{recipeId}” with a variant “{variant}”. + , + ); + } + + root.render( + + + , + ); +}; + +start().catch((error: unknown) => { + console.error(error); + root.render(The recipe failed to load: {String(error)}); +}); diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/App.tsx b/examples/cookbook/src/recipes/ai/ai-chat-widget/App.tsx new file mode 100644 index 0000000000..2a67bd287e --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/App.tsx @@ -0,0 +1,35 @@ +import { Chat, useCreateChatClient } from 'stream-chat-react'; +import { AssistantWidget } from './AssistantWidget'; +import { isMessageAIGenerated } from './isMessageAIGenerated'; + +const apiKey = 'YOUR_API_KEY'; +const userId = 'YOUR_USER_ID'; +const userToken = 'YOUR_USER_TOKEN'; +// The user's channel with the assistant, which your backend creates. +const assistantChannelId = 'YOUR_ASSISTANT_CHANNEL_ID'; + +/** + * An app that has no chat of its own, only the widget. Load the SDK's stylesheet in a CSS layer, as + * the base app's `chat.css` does, so the widget's CSS wins over it. + */ +export const App = () => { + const client = useCreateChatClient({ + apiKey, + tokenOrProvider: userToken, + userData: { id: userId }, + }); + + return ( + <> +
{/* Your app's own pages. */}
+ {client && ( + + {/* The SDK's tokens are set on `.str-chat`; without ChatView, the widget needs one. */} +
+ +
+
+ )} + + ); +}; diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/AssistantWidget.css b/examples/cookbook/src/recipes/ai/ai-chat-widget/AssistantWidget.css new file mode 100644 index 0000000000..fdc96e8ad4 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/AssistantWidget.css @@ -0,0 +1,64 @@ +@layer stream-overrides { + /* + * Pinned to the bottom right of the page, the window stacked above the launcher. 96px clears a + * composer along the bottom of the page, such as the base app's. + */ + .cookbook-assistant-widget { + position: fixed; + z-index: 10; + inset-block-end: 96px; + inset-inline-end: var(--str-chat__spacing-lg); + display: flex; + flex-direction: column-reverse; + align-items: flex-end; + gap: var(--str-chat__spacing-sm); + } + + .cookbook-assistant-launcher { + box-shadow: var(--str-chat__box-shadow-3); + } + + /* A compact card, never wider or taller than the page leaves room for. */ + .cookbook-assistant-window { + display: flex; + flex-direction: column; + width: min(380px, calc(100vw - 2 * var(--str-chat__spacing-lg))); + height: min(520px, calc(100dvh - 200px)); + overflow: hidden; + border: 1px solid var(--str-chat__border-core-default); + border-radius: var(--str-chat__radius-xl); + background: var(--str-chat__background-core-elevation-1); + box-shadow: var(--str-chat__box-shadow-3); + color: var(--str-chat__text-primary); + } + + .cookbook-assistant-window__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--str-chat__spacing-sm); + padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-sm) + var(--str-chat__spacing-sm) var(--str-chat__spacing-md); + border-block-end: 1px solid var(--str-chat__border-core-default); + } + + .cookbook-assistant-window__title { + margin: 0; + font: var(--str-chat__font-heading-sm); + } + + /* The channel fills the space under the header. */ + .cookbook-assistant-window__body { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + } + + .cookbook-assistant-window__note { + margin: 0; + padding: var(--str-chat__spacing-md); + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-body-default); + } +} diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/AssistantWidget.tsx b/examples/cookbook/src/recipes/ai/ai-chat-widget/AssistantWidget.tsx new file mode 100644 index 0000000000..db545200df --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/AssistantWidget.tsx @@ -0,0 +1,166 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import type { KeyboardEvent } from 'react'; +import type { Channel as StreamChannel } from 'stream-chat'; +import { + AIStateIndicator, + Button, + Channel, + LoadingIndicator, + MessageComposer, + MessageList, + useChatContext, + useComponentContextIcons, +} from 'stream-chat-react'; +import './AssistantWidget.css'; + +/** + * Watches the user's channel with the assistant the first time the window opens, and keeps it + * afterwards, until `channelId` names another channel. A user who never opens the window costs no + * request. + */ +const useAssistantChannel = (channelId: string, isOpen: boolean) => { + const { client } = useChatContext(); + const [channel, setChannel] = useState(); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!isOpen || channel?.id === channelId) return; + let cancelled = false; + setFailed(false); + client + .channel('messaging', channelId) + .watch() + .then((watched) => !cancelled && setChannel(watched)) + .catch(() => !cancelled && setFailed(true)); + return () => { + cancelled = true; + }; + }, [channel, channelId, client, isOpen]); + + return { channel, failed }; +}; + +/** + * A menu, picker or suggestion list inside the window is open, so Escape closes that first. Their + * toggles carry `aria-expanded="true"`, and the composer's textarea carries `aria-controls` while + * its suggestions show. + */ +const hasOpenPopup = (container: HTMLElement | null) => + !!container?.querySelector('[aria-expanded="true"], textarea[aria-controls]'); + +/** + * The Escape was pressed in the window or on the launcher, and nothing inside the window used it, + * as the composer does to leave a command. A modal opened from the window renders in a portal, but + * React still bubbles its keydown here, and that Escape closes only the modal. + */ +const isEscapeForWindow = ( + event: KeyboardEvent, + container: HTMLElement | null, + launcher: HTMLElement | null, +) => { + const target = event.target as Node; + return ( + (!!container?.contains(target) || !!launcher?.contains(target)) && + !event.defaultPrevented && + !hasOpenPopup(container) + ); +}; + +type AssistantWidgetProps = { + /** The id of the user's `messaging` channel with the assistant. */ + channelId: string; + /** The window's heading, which also names it for assistive technology. */ + title?: string; +}; + +/** + * A launcher button at the bottom right of the page that opens a small, non-modal chat window with + * the assistant. Escape or the close button closes it and gives focus back to the launcher. + */ +export const AssistantWidget = ({ + channelId, + title = 'Assistant', +}: AssistantWidgetProps) => { + const { IconMessageBubble, IconXmark } = useComponentContextIcons(); + const [isOpen, setIsOpen] = useState(false); + const { channel, failed } = useAssistantChannel(channelId, isOpen); + const launcherRef = useRef(null); + const windowRef = useRef(null); + const windowId = useId(); + const titleId = useId(); + + const close = () => { + setIsOpen(false); + launcherRef.current?.focus(); + }; + + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || !isOpen) return; + // Escape inside the open widget is the widget's, so handlers on the page don't act on it too, + // such as the channel list's search, which focuses its input on any Escape. + event.stopPropagation(); + if (!isEscapeForWindow(event, windowRef.current, launcherRef.current)) return; + event.preventDefault(); + close(); + }; + + return ( +
+ + {isOpen && ( + + )} +
+ ); +}; diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/ChannelsWithoutAssistant.tsx b/examples/cookbook/src/recipes/ai/ai-chat-widget/ChannelsWithoutAssistant.tsx new file mode 100644 index 0000000000..1125ed27d1 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/ChannelsWithoutAssistant.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; +import { ChannelPaginator } from 'stream-chat'; +import type { SortParamRequest } from 'stream-chat'; +import { useChatContext } from 'stream-chat-react'; +import { channelFilters, visibleChannelIds } from '../../../base/client'; + +const sort: SortParamRequest[] = [{ direction: -1, field: 'last_message_at' }]; + +/** + * Not part of the recipe: the base app's channel list without `channelId`, the assistant's channel, + * which only the widget opens. Registered in place of the base app's paginator. + */ +export const ChannelsWithoutAssistant = ({ channelId }: { channelId: string }) => { + const { client } = useChatContext(); + + useEffect(() => { + const paginator = new ChannelPaginator({ + client, + filters: { + ...channelFilters, + id: { $in: visibleChannelIds.filter((id) => id !== channelId) }, + }, + id: 'cookbook', + requestOptions: { presence: true, state: true }, + sort, + }); + client.channelManager.insertPaginator({ paginator }); + return () => { + client.channelManager.removePaginator(paginator); + }; + }, [channelId, client]); + + return null; +}; diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/OpenWidgetFromUrl.tsx b/examples/cookbook/src/recipes/ai/ai-chat-widget/OpenWidgetFromUrl.tsx new file mode 100644 index 0000000000..45783fbf47 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/OpenWidgetFromUrl.tsx @@ -0,0 +1,18 @@ +import { useClickInTurn } from '../../../base/useClickInTurn'; +import type { ClickStep } from '../../../base/useClickInTurn'; + +// `?widget=open` clicks the launcher, as the user would. +const steps: ClickStep[] = + new URLSearchParams(window.location.search).get('widget') === 'open' + ? [ + { + find: () => document.querySelector('.cookbook-assistant-launcher'), + }, + ] + : []; + +/** For the screenshots only, not part of the recipe: opens the widget from the URL. */ +export const OpenWidgetFromUrl = () => { + useClickInTurn(steps); + return null; +}; diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/default.recipe.tsx b/examples/cookbook/src/recipes/ai/ai-chat-widget/default.recipe.tsx new file mode 100644 index 0000000000..ee2d451b6d --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/default.recipe.tsx @@ -0,0 +1,25 @@ +import type { RecipeConfig } from '../../../base/recipe'; +import { AssistantWidget } from './AssistantWidget'; +import { ChannelsWithoutAssistant } from './ChannelsWithoutAssistant'; +import { isMessageAIGenerated } from './isMessageAIGenerated'; +import { OpenWidgetFromUrl } from './OpenWidgetFromUrl'; + +// The user's channel with the assistant, from this recipe's demo-data.json. +const ASSISTANT_CHANNEL_ID = 'docs-v15-cb-widget-assistant'; + +// A chat bubble at the bottom right of the base app that opens the user's channel with the +// assistant. The channel list leaves that channel out. OpenWidgetFromUrl only serves the +// screenshots. +const recipe: RecipeConfig = { + chatProps: () => ({ isMessageAIGenerated }), + chatViewChildren: ( + <> + + + + + ), + registerChannelPaginator: false, +}; + +export default recipe; diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/demo-data.json b/examples/cookbook/src/recipes/ai/ai-chat-widget/demo-data.json new file mode 100644 index 0000000000..eab2234339 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/demo-data.json @@ -0,0 +1,33 @@ +{ + "users": [{ "id": "docs-v15-ck-assistant", "name": "Assistant" }], + "channels": [ + { + "id": "docs-v15-cb-widget-assistant", + "members": ["docs-v15-ck-cleo", "docs-v15-ck-assistant"], + "messages": [ + { + "id": "docs-v15-ck-widget-assistant-1", + "user": "docs-v15-ck-cleo", + "text": "Can you suggest a few short novels for the book club?" + }, + { + "id": "docs-v15-ck-widget-assistant-2", + "user": "docs-v15-ck-assistant", + "text": "Happy to help. Short novels work well for a monthly club: pick ones under 200 pages, so everyone finishes in time. Would you like a mystery, a classic or something light?", + "custom": { "ai_generated": true } + }, + { + "id": "docs-v15-ck-widget-assistant-3", + "user": "docs-v15-ck-cleo", + "text": "Something light, please." + }, + { + "id": "docs-v15-ck-widget-assistant-4", + "user": "docs-v15-ck-assistant", + "text": "Then look for a warm, funny story with a small cast, such as a road trip or a summer by the sea. They are quick to read and easy to talk about.", + "custom": { "ai_generated": true } + } + ] + } + ] +} diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/isMessageAIGenerated.ts b/examples/cookbook/src/recipes/ai/ai-chat-widget/isMessageAIGenerated.ts new file mode 100644 index 0000000000..848e5f81d1 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/isMessageAIGenerated.ts @@ -0,0 +1,13 @@ +import type { LocalMessage } from 'stream-chat'; + +declare module 'stream-chat' { + // The field the backend sets on the assistant's replies. Optional, because every message in the + // app shares this interface. + interface CustomMessageData { + ai_generated?: boolean; + } +} + +/** The assistant wrote the message: the backend marks its replies with `ai_generated`. */ +export const isMessageAIGenerated = (message: LocalMessage) => + !!message.custom?.ai_generated; diff --git a/examples/cookbook/src/recipes/ai/ai-chat-widget/shots.json b/examples/cookbook/src/recipes/ai/ai-chat-widget/shots.json new file mode 100644 index 0000000000..24845c207b --- /dev/null +++ b/examples/cookbook/src/recipes/ai/ai-chat-widget/shots.json @@ -0,0 +1,16 @@ +[ + { + "file": "ai-chat-widget.png", + "query": { "channel": "docs-v15-cb-design", "widget": "open" }, + "waitFor": ".cookbook-assistant-window [data-message-id=\"docs-v15-ck-widget-assistant-4\"]", + "settleMs": 1500 + }, + { + "file": "ai-chat-widget-launcher.png", + "query": { "channel": "docs-v15-cb-design" }, + "waitFor": ".cookbook-assistant-launcher", + "clip": ".cookbook-assistant-widget", + "padding": 96, + "settleMs": 1500 + } +] diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/AssistantStatus.css b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/AssistantStatus.css new file mode 100644 index 0000000000..20a44ab9c2 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/AssistantStatus.css @@ -0,0 +1,52 @@ +@layer stream-overrides { + /* A quiet line under the last message: three pulsing dots and what the assistant is doing. */ + .cookbook-assistant-status { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xs); + padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-md); + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); + } + + .cookbook-assistant-status[data-ai-state='AI_STATE_ERROR'] { + color: var(--str-chat__accent-error); + } + + .cookbook-assistant-status__dots { + display: flex; + gap: var(--str-chat__spacing-xxs); + } + + .cookbook-assistant-status__dots span { + width: 6px; + height: 6px; + border-radius: var(--str-chat__radius-full); + background: var(--str-chat__accent-primary); + animation: cookbook-assistant-pulse 1.2s ease-in-out infinite; + } + + .cookbook-assistant-status__dots span:nth-child(2) { + animation-delay: 0.2s; + } + + .cookbook-assistant-status__dots span:nth-child(3) { + animation-delay: 0.4s; + } + + @media (prefers-reduced-motion: reduce) { + .cookbook-assistant-status__dots span { + animation: none; + } + } + + @keyframes cookbook-assistant-pulse { + 0%, + 100% { + opacity: 0.3; + } + 50% { + opacity: 1; + } + } +} diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/AssistantStatus.tsx b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/AssistantStatus.tsx new file mode 100644 index 0000000000..48363d2312 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/AssistantStatus.tsx @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { AIStates } from 'stream-chat'; +import type { AIState } from 'stream-chat'; +import { + TypingIndicator, + useAIState, + useChannel, + useThreadContext, +} from 'stream-chat-react'; +import type { TypingIndicatorProps } from 'stream-chat-react'; +import { useAnswerAnnouncements } from './useAnswerAnnouncements'; +import './AssistantStatus.css'; + +/** What the status line says in each state that shows it. */ +const labels: Partial> = { + [AIStates.Error]: 'Couldn’t finish the answer', + [AIStates.ExternalSources]: 'Checking sources…', + [AIStates.Generating]: 'Writing…', + [AIStates.Thinking]: 'Thinking…', +}; + +/** + * The assistant's state at the end of the channel's message list, where the answer appears, and + * the default typing indicator while the assistant is idle. Screen reader announcements come from + * `useAnswerAnnouncements()`, so the line itself isn't a live region. + */ +const ChannelAssistantStatus = (props: TypingIndicatorProps) => { + const { isMessageListScrolledToBottom = true, scrollToBottom } = props; + const channel = useChannel(); + const { aiState } = useAIState(channel); + const label = labels[aiState]; + useAnswerAnnouncements(aiState); + + // Keeps the line in view when it appears, as the typing indicator does. + useEffect(() => { + if (label && isMessageListScrolledToBottom) scrollToBottom?.(); + }, [isMessageListScrolledToBottom, label, scrollToBottom]); + + if (!label) return ; + + return ( +
+ {aiState !== AIStates.Error && ( +
+ ); +}; + +/** + * Fills the `TypingIndicator` slot. The assistant answers in the channel, so a thread keeps the + * default typing indicator. + */ +export const AssistantStatus = (props: TypingIndicatorProps) => { + const thread = useThreadContext(); + return thread ? : ; +}; diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/SimulateAnswer.tsx b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/SimulateAnswer.tsx new file mode 100644 index 0000000000..c8e90ffc68 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/SimulateAnswer.tsx @@ -0,0 +1,130 @@ +import { useEffect } from 'react'; +import { AIStates, msToNs, nsToMs } from 'stream-chat'; +import type { Channel, Event, LocalMessage, MessagePaginatorState } from 'stream-chat'; +import { useChatContext } from 'stream-chat-react'; +import { isDemoChannelId } from '../../../base/client'; + +const params = new URLSearchParams(window.location.search); +const phases = ['thinking', 'streaming', 'done'] as const; +type Phase = (typeof phases)[number]; +const requestedPhase = params.get('phase') as Phase | null; +const runs = params.get('simulate') === 'answer'; +const channelId = params.get('channel'); + +const ASSISTANT_ID = 'docs-v15-ck-assistant'; +const QUESTION_ID = 'docs-v15-ck-garden-assistant-3'; +// Exists only in the page: the demo app never stores it. +const ANSWER_ID = 'docs-v15-ck-garden-assistant-answer'; +const ANSWER = [ + 'Good picks for a first season with six hours of sun:', + '', + '- **Lettuce** grows fast, and you can pick the leaves as you need them.', + '- **Bush beans** need no support and crop for weeks.', + '- **Zucchini** gives plenty from one or two plants.', + '- **Radishes** are ready in about a month.', + '', + 'Water in the morning, and mulch the beds to keep the soil moist.', +].join('\n'); +// The streaming phase stops here, about halfway through the answer. +const STREAMING_STOP = ANSWER.indexOf('- **Zucchini**'); + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** The question the simulation answers is loaded in the channel's message list. */ +const hasQuestion = ({ items }: MessagePaginatorState) => + !!items?.some(({ id }) => id === QUESTION_ID); + +/** + * For the screenshots: `?simulate=answer&channel=&phase=thinking|streaming|done` plays, in + * the page only, the events a backend sends while the assistant answers the channel's last + * question, and stops at the phase: the assistant thinking, the answer half written, or the answer + * finished. Without `phase`, it plays to the end. When it stops, it sets + * `data-simulated-answer` on the root element, for the screenshot to wait for. + */ +export const SimulateAnswer = () => { + const { client } = useChatContext(); + + useEffect(() => { + if (!runs || !isDemoChannelId(channelId)) return; + const channel: Channel = client.channel('messaging', channelId); + const lastPhase = phases.indexOf(requestedPhase ?? 'done'); + let cancelled = false; + + const dispatch = (event: Record) => { + if (cancelled) return; + client.dispatchEvent({ + channel_id: channel.id, + channel_type: channel.type, + cid: channel.cid, + created_at: msToNs(Date.now()), + custom: {}, + ...event, + } as unknown as Event); + }; + + const answerWith = (question: LocalMessage, text: string) => ({ + ...question, + created_at: msToNs(nsToMs(question.created_at) + 60_000), + custom: { ai_generated: true }, + html: '', + id: ANSWER_ID, + text, + updated_at: msToNs(Date.now()), + // The assistant as the channel's earlier answer has it, so the answer shows its avatar. + user: channel.messagePaginator.state + .getLatestValue() + .items?.find(({ user }) => user?.id === ASSISTANT_ID)?.user, + user_id: ASSISTANT_ID, + }); + + const play = async (question: LocalMessage) => { + // The backend posts the empty answer first, then says it's thinking about it. + dispatch({ message: answerWith(question, ''), type: 'message.new' }); + dispatch({ + ai_state: AIStates.Thinking, + message_id: ANSWER_ID, + type: 'ai_indicator.update', + }); + if (lastPhase === 0) return; + + await wait(800); + dispatch({ + ai_state: AIStates.Generating, + message_id: ANSWER_ID, + type: 'ai_indicator.update', + }); + const end = lastPhase === 1 ? STREAMING_STOP : ANSWER.length; + for (let length = 40; length < end; length += 40) { + await wait(250); + const text = ANSWER.slice(0, length); + dispatch({ message: answerWith(question, text), type: 'message.updated' }); + } + await wait(250); + const text = ANSWER.slice(0, end); + dispatch({ message: answerWith(question, text), type: 'message.updated' }); + if (lastPhase === 1) return; + + dispatch({ type: 'ai_indicator.clear' }); + }; + + const finish = () => { + if (!cancelled) document.documentElement.dataset.simulatedAnswer = 'true'; + }; + + // Plays once, as soon as the question is loaded. + let started = false; + const unsubscribe = channel.messagePaginator.state.subscribe((state) => { + if (started || !hasQuestion(state)) return; + started = true; + const question = state.items?.find(({ id }) => id === QUESTION_ID); + if (question) void play(question).then(finish); + }); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, [client]); + + return null; +}; diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/StopButton.css b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/StopButton.css new file mode 100644 index 0000000000..57ab0549d3 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/StopButton.css @@ -0,0 +1,19 @@ +@layer stream-overrides { + /* The size of the send button's icon, so the button keeps its size when it swaps in. */ + .cookbook-stop-button__icon { + display: flex; + align-items: center; + justify-content: center; + width: var(--str-chat__icon-size-md); + height: var(--str-chat__icon-size-md); + } + + /* A filled square, the usual sign for stop, in the button's text color. */ + .cookbook-stop-button__icon::before { + content: ''; + width: 10px; + height: 10px; + border-radius: var(--str-chat__radius-xxs); + background: currentColor; + } +} diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/StopButton.tsx b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/StopButton.tsx new file mode 100644 index 0000000000..47123002ce --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/StopButton.tsx @@ -0,0 +1,33 @@ +import type { ComponentProps, MouseEvent } from 'react'; +import { Button, useMessageComposerContext } from 'stream-chat-react'; +import './StopButton.css'; + +/** + * Fills the composer's `StopAIGenerationButton` slot, which shows in place of the send button + * while the assistant answers. It gets a button's props, and its `onClick` calls + * `Channel.stopAIResponse()`. The button goes away once the answer stops, so the focus moves to + * the text field, where the user writes the next question. + */ +export const StopButton = ({ onClick }: ComponentProps<'button'>) => { + const { textareaRef } = useMessageComposerContext(); + + const stop = (event: MouseEvent) => { + onClick?.(event); + textareaRef.current?.focus(); + }; + + return ( + + ); +}; diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/WithStreamedAnswers.tsx b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/WithStreamedAnswers.tsx new file mode 100644 index 0000000000..00b4e9c112 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/WithStreamedAnswers.tsx @@ -0,0 +1,19 @@ +import type { PropsWithChildren } from 'react'; +import { WithComponents } from 'stream-chat-react'; +import { AssistantStatus } from './AssistantStatus'; +import { StopButton } from './StopButton'; + +// Declared once, so WithComponents gets the same object on every render. +const overrides = { + StopAIGenerationButton: StopButton, + TypingIndicator: AssistantStatus, +}; + +/** + * The assistant's state at the end of the message list, and a Stop button in place of the send + * button while it answers. `StreamedMessageText` needs no override: `MessageUI` renders it for + * every message `Chat`'s `isMessageAIGenerated` returns `true` for. + */ +export const WithStreamedAnswers = ({ children }: PropsWithChildren) => ( + {children} +); diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/default.recipe.tsx b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/default.recipe.tsx new file mode 100644 index 0000000000..2648863990 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/default.recipe.tsx @@ -0,0 +1,22 @@ +import type { PropsWithChildren } from 'react'; +import type { RecipeConfig } from '../../../base/recipe'; +import { isMessageAIGenerated } from './isMessageAIGenerated'; +import { SimulateAnswer } from './SimulateAnswer'; +import { WithStreamedAnswers } from './WithStreamedAnswers'; + +// "Streamed answers with a stop button": the assistant's replies type out as they stream, its state +// shows at the end of the message list, and a Stop button replaces the send button while it +// answers. SimulateAnswer only serves the screenshots. +const Wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + +); + +const recipe: RecipeConfig = { + chatProps: () => ({ isMessageAIGenerated }), + Wrapper, +}; + +export default recipe; diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/demo-data.json b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/demo-data.json new file mode 100644 index 0000000000..f1332cfa3d --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/demo-data.json @@ -0,0 +1,27 @@ +{ + "users": [{ "id": "docs-v15-ck-assistant", "name": "Assistant" }], + "channels": [ + { + "id": "docs-v15-cb-garden-assistant", + "members": ["docs-v15-ck-cleo", "docs-v15-ck-assistant"], + "messages": [ + { + "id": "docs-v15-ck-garden-assistant-1", + "user": "docs-v15-ck-cleo", + "text": "Hi! Can you help me plan my garden plot?" + }, + { + "id": "docs-v15-ck-garden-assistant-2", + "user": "docs-v15-ck-assistant", + "text": "Of course. Tell me what you'd like to grow and how much sun the plot gets.", + "custom": { "ai_generated": true } + }, + { + "id": "docs-v15-ck-garden-assistant-3", + "user": "docs-v15-ck-cleo", + "text": "It gets about six hours of sun. Which vegetables are easy for a first season?" + } + ] + } + ] +} diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/isMessageAIGenerated.ts b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/isMessageAIGenerated.ts new file mode 100644 index 0000000000..848e5f81d1 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/isMessageAIGenerated.ts @@ -0,0 +1,13 @@ +import type { LocalMessage } from 'stream-chat'; + +declare module 'stream-chat' { + // The field the backend sets on the assistant's replies. Optional, because every message in the + // app shares this interface. + interface CustomMessageData { + ai_generated?: boolean; + } +} + +/** The assistant wrote the message: the backend marks its replies with `ai_generated`. */ +export const isMessageAIGenerated = (message: LocalMessage) => + !!message.custom?.ai_generated; diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/shots.json b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/shots.json new file mode 100644 index 0000000000..3aa21ce0eb --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/shots.json @@ -0,0 +1,41 @@ +[ + { + "file": "streamed-answer-thinking.png", + "query": { + "channel": "docs-v15-cb-garden-assistant", + "simulate": "answer", + "phase": "thinking" + }, + "waitFor": ".cookbook-assistant-status", + "clip": ".str-chat__channel", + "padding": 0, + "settleMs": 1500, + "viewport": { "width": 1280, "height": 760 } + }, + { + "file": "streamed-answer-streaming.png", + "query": { + "channel": "docs-v15-cb-garden-assistant", + "simulate": "answer", + "phase": "streaming" + }, + "waitFor": "html[data-simulated-answer] .cookbook-stop-button", + "clip": ".str-chat__channel", + "padding": 0, + "settleMs": 2500, + "viewport": { "width": 1280, "height": 760 } + }, + { + "file": "streamed-answer-done.png", + "query": { + "channel": "docs-v15-cb-garden-assistant", + "simulate": "answer", + "phase": "done" + }, + "waitFor": "html[data-simulated-answer] .str-chat__message-composer", + "clip": ".str-chat__channel", + "padding": 0, + "settleMs": 6000, + "viewport": { "width": 1280, "height": 760 } + } +] diff --git a/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/useAnswerAnnouncements.ts b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/useAnswerAnnouncements.ts new file mode 100644 index 0000000000..cd73c243d8 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/streamed-answers-with-stop-button/useAnswerAnnouncements.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react'; +import { AIStates } from 'stream-chat'; +import type { AIState } from 'stream-chat'; +import { useAriaLiveAnnouncer } from 'stream-chat-react'; + +/** The assistant is working on an answer: thinking, checking sources or writing it. */ +const isAnswering = (aiState: AIState) => + aiState === AIStates.Thinking || + aiState === AIStates.ExternalSources || + aiState === AIStates.Generating; + +/** What to say when an answer ends in each state that isn't answering. */ +const endings: Partial> = { + [AIStates.Error]: 'The assistant couldn’t finish the answer', + [AIStates.Idle]: 'The assistant finished answering', + [AIStates.Stop]: 'Answer stopped', +}; + +/** + * Tells a screen reader how the assistant's answer goes, without reading every streamed word: + * that it started, and that it finished, stopped or failed. The finished answer is then one + * message the user reads like any other. Each change is announced once: a repeated state, such as + * StrictMode running the effect twice, says nothing, and an ending is announced only after an + * answer, so mounting on an earlier error stays quiet. + */ +export const useAnswerAnnouncements = (aiState: AIState) => { + const announce = useAriaLiveAnnouncer(); + // Starts idle, so opening the channel mid-answer announces it too. + const previousState = useRef(AIStates.Idle); + + useEffect(() => { + if (previousState.current === aiState) return; + const wasAnswering = isAnswering(previousState.current); + previousState.current = aiState; + + if (isAnswering(aiState)) { + if (!wasAnswering) announce('The assistant is answering'); + } else if (wasAnswering && endings[aiState]) { + announce(endings[aiState]); + } + }, [aiState, announce]); +}; diff --git a/examples/cookbook/src/recipes/ai/voice-agent-visuals/SimulateVoice.tsx b/examples/cookbook/src/recipes/ai/voice-agent-visuals/SimulateVoice.tsx new file mode 100644 index 0000000000..b8d2134d0d --- /dev/null +++ b/examples/cookbook/src/recipes/ai/voice-agent-visuals/SimulateVoice.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; +import type { PropsWithChildren } from 'react'; +import type { VoiceAgentState } from 'stream-chat-react/ai-components'; +import demoData from './demo-data.json'; +import type { VoiceSession } from './VoiceSession'; +import { VoiceSessionProvider } from './VoiceSession'; + +const params = new URLSearchParams(window.location.search); +const STATES: VoiceAgentState[] = [ + 'idle', + 'connecting', + 'listening', + 'thinking', + 'speaking', +]; + +/** `?state=` names one of the states the visuals take. */ +const isVoiceAgentState = (value: string | null): value is VoiceAgentState => + STATES.includes(value as VoiceAgentState); + +const requestedState = params.get('state'); +const state: VoiceAgentState = isVoiceAgentState(requestedState) + ? requestedState + : 'speaking'; +const freezeAfterFrames = Number(params.get('frames')) || undefined; + +const channel = demoData.channels[0]; +const reply = channel.messages[channel.messages.length - 1]; +// The user has heard the reply's first sentence. +const heardChars = reply.text.indexOf('Drink'); + +// The time the levels are computed from: the fixed clock's while it runs, else the page's own. +let clockMs = 0; +const now = () => (freezeAfterFrames ? clockMs : performance.now()); + +/** A level that rises and falls like syllables, the same at the same time on every run. */ +const speechLevel = () => { + const t = now() / 1000; + return 0.2 + 0.4 * Math.abs(Math.sin(t * 7.3)) * (0.6 + 0.4 * Math.sin(t * 1.9)); +}; + +/** Both visuals have drawn their first frame. */ +const visualsHaveStarted = () => + document.querySelectorAll('.cookbook-voice-panel [data-ready="true"]').length === 2; + +/** + * Replaces `requestAnimationFrame` with a clock that moves exactly 1/60 s a frame. Once the + * visuals have started, it moves `frames` more times and then stops, so the visuals stay on the + * same frame for the screenshot, and sets `data-simulated-voice` on the root element. Callbacks + * keep running at the stopped time, so the rest of the page still renders. + */ +const runFixedClock = (frames: number) => { + let callbacks = new Map(); + let lastId = 0; + let flushes = 0; + let startedAt: number | undefined; + + window.requestAnimationFrame = (callback) => { + callbacks.set(++lastId, callback); + return lastId; + }; + window.cancelAnimationFrame = (id) => { + callbacks.delete(id); + }; + + const flush = () => { + flushes++; + if (startedAt === undefined && visualsHaveStarted()) startedAt = flushes; + if (startedAt === undefined || flushes - startedAt < frames) clockMs += 1000 / 60; + else document.documentElement.dataset.simulatedVoice = 'true'; + const due = callbacks; + callbacks = new Map(); + due.forEach((callback) => callback(clockMs)); + setTimeout(flush, 16); + }; + setTimeout(flush, 16); +}; + +if (freezeAfterFrames) runFixedClock(freezeAfterFrames); + +/** + * Not part of the recipe: stands in for a voice backend, which the demo app doesn't have. It + * provides a voice session in the recipe's channel, in the state `?state=` names (speaking by + * default), with levels that move like speech and the reply's first sentence heard. + * `?frames=` stops the visuals `n` frames after they start, for the screenshots. + */ +export const SimulateVoice = ({ children }: PropsWithChildren) => { + const [ended, setEnded] = useState(false); + + const [session] = useState(() => ({ + agentLevel: speechLevel, + channelId: channel.id, + end: () => setEnded(true), + micLevel: speechLevel, + reply: + state === 'speaking' + ? { + generating: false, + heard: () => ({ chars: heardChars, done: false }), + messageId: reply.id, + } + : undefined, + state, + })); + + return ( + + {children} + + ); +}; diff --git a/examples/cookbook/src/recipes/ai/voice-agent-visuals/SpokenMessageText.tsx b/examples/cookbook/src/recipes/ai/voice-agent-visuals/SpokenMessageText.tsx new file mode 100644 index 0000000000..153e61deb4 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/voice-agent-visuals/SpokenMessageText.tsx @@ -0,0 +1,44 @@ +import type { LocalMessage } from 'stream-chat'; +import { MessageText, StreamedMessageText, useMessageContext } from 'stream-chat-react'; +import type { StreamedMessageTextProps } from 'stream-chat-react'; +import { useHeardText } from 'stream-chat-react/ai-components'; +import { useVoiceSession } from './VoiceSession'; +import type { VoiceSession } from './VoiceSession'; + +type Reply = NonNullable; + +/** The assistant is speaking this message right now. */ +const isSpokenNow = ( + reply: VoiceSession['reply'], + message: LocalMessage, +): reply is Reply => reply?.messageId === message.id; + +/** The reply's text, revealed as far as the user has heard it. */ +const HeardText = ({ + message, + renderText, + reply, +}: StreamedMessageTextProps & { message: LocalMessage; reply: Reply }) => { + const { heardText } = useHeardText({ + generating: reply.generating, + heard: reply.heard, + text: message.text ?? '', + }); + return ( + + ); +}; + +/** + * Fills the `StreamedMessageText` slot, which `MessageUI` renders for the assistant's replies. + * The model writes a reply far ahead of the voice that speaks it, so the reply being spoken shows + * only what the user has heard. Every other reply streams in as usual. + */ +export const SpokenMessageText = (props: StreamedMessageTextProps) => { + const { message: messageFromContext } = useMessageContext(); + const message = props.message ?? messageFromContext; + const reply = useVoiceSession()?.reply; + + if (!isSpokenNow(reply, message)) return ; + return ; +}; diff --git a/examples/cookbook/src/recipes/ai/voice-agent-visuals/VoiceAgentPanel.css b/examples/cookbook/src/recipes/ai/voice-agent-visuals/VoiceAgentPanel.css new file mode 100644 index 0000000000..172fe8a1c2 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/voice-agent-visuals/VoiceAgentPanel.css @@ -0,0 +1,52 @@ +@layer stream-overrides { + /* A strip under the channel header: the glow, the status, the microphone's wave and End. */ + .cookbook-voice-panel { + /* What the glow and the wave sit on, so they pick their light or dark look from it. */ + --str-chat__ai-voice-backdrop: var(--str-chat__background-core-surface-subtle); + + display: flex; + align-items: center; + gap: var(--str-chat__spacing-md); + padding-inline-end: var(--str-chat__spacing-lg); + border-block-end: 1px solid var(--str-chat__border-core-default); + background: var(--str-chat__background-core-surface-subtle); + } + + /* + * Both draw in the middle of their box: the glow's globe about half as wide as the box, the + * wave's string about 84% as wide as its shorter side. + */ + .cookbook-voice-panel__glow, + .cookbook-voice-panel__wave { + flex: none; + width: 128px; + height: 128px; + } + + .cookbook-voice-panel__text { + flex: 1; + min-width: 0; + } + + .cookbook-voice-panel__name { + margin: 0; + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); + } + + .cookbook-voice-panel__status { + margin: 0; + color: var(--str-chat__text-primary); + font: var(--str-chat__font-heading-sm); + } + + /* Read by screen readers only: the panel shows the state on screen. */ + .cookbook-voice-announcement { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } +} diff --git a/examples/cookbook/src/recipes/ai/voice-agent-visuals/VoiceAgentPanel.tsx b/examples/cookbook/src/recipes/ai/voice-agent-visuals/VoiceAgentPanel.tsx new file mode 100644 index 0000000000..2a2973349a --- /dev/null +++ b/examples/cookbook/src/recipes/ai/voice-agent-visuals/VoiceAgentPanel.tsx @@ -0,0 +1,74 @@ +import { useId } from 'react'; +import type { MouseEvent } from 'react'; +import { Button } from 'stream-chat-react'; +import { VoiceGlow, VoiceWave } from 'stream-chat-react/ai-components'; +import type { VoiceAgentState } from 'stream-chat-react/ai-components'; +import type { VoiceSession } from './VoiceSession'; +import './VoiceAgentPanel.css'; + +/** What the status line shows in each state. */ +const statusLabels: Record = { + connecting: 'Connecting…', + idle: 'Ready', + listening: 'Listening', + speaking: 'Speaking', + thinking: 'Thinking…', +}; + +/** The user is talking, so the microphone's level moves the wave. */ +const isUserTalking = (state: VoiceAgentState) => state === 'listening'; + +/** The assistant is talking, so its voice's level moves the glow. */ +const isAgentTalking = (state: VoiceAgentState) => state === 'speaking'; + +/** + * A call-like strip under the channel header while a voice conversation runs: the assistant as a + * glow that moves with its state and voice, a status line, the user's microphone as a wave, and a + * button that ends the conversation. + */ +export const VoiceAgentPanel = ({ session }: { session: VoiceSession }) => { + const { agentLevel, end, micLevel, state } = session; + const nameId = useId(); + + // The panel and its button go away when the conversation ends, so focus moves to the composer. + const handleEnd = (event: MouseEvent) => { + const composer = event.currentTarget + .closest('.str-chat__channel') + ?.querySelector('textarea'); + end(); + composer?.focus(); + }; + + return ( +
+ {/* Both visuals are hidden from screen readers: the status line says the state in words. */} +
+ ); +}; diff --git a/examples/cookbook/src/recipes/ai/voice-agent-visuals/VoiceChannelHeader.tsx b/examples/cookbook/src/recipes/ai/voice-agent-visuals/VoiceChannelHeader.tsx new file mode 100644 index 0000000000..30d9964a83 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/voice-agent-visuals/VoiceChannelHeader.tsx @@ -0,0 +1,46 @@ +import type { Channel } from 'stream-chat'; +import { ChannelHeader, useChannel } from 'stream-chat-react'; +import type { VoiceAgentState } from 'stream-chat-react/ai-components'; +import { VoiceAgentPanel } from './VoiceAgentPanel'; +import { useVoiceSession } from './VoiceSession'; +import type { VoiceSession } from './VoiceSession'; + +/** The voice conversation is written into this channel. */ +const isSessionChannel = ( + session: VoiceSession | undefined, + channel: Channel, +): session is VoiceSession => session?.channelId === channel.id; + +const inConversation = 'Voice chat started. Talk, and the assistant answers out loud.'; + +/** + * What a screen reader announces in each state. Listening, thinking and speaking can be heard, so + * they share one sentence: it's announced once, when the conversation starts, and not on every + * turn. Only the states with no sound get their own words. + */ +const announcements: Record = { + connecting: 'Connecting to the assistant…', + idle: 'Voice chat ready', + listening: inConversation, + speaking: inConversation, + thinking: inConversation, +}; + +/** The SDK's channel header, with the voice panel under it in the conversation's channel. */ +export const VoiceChannelHeader = () => { + const channel = useChannel(); + const session = useVoiceSession(); + const inSession = isSessionChannel(session, channel); + + return ( + <> + + {inSession && } + {/* Mounted, and empty, before the session starts, so its first words are announced. It + follows the session, not the open channel: the voice is heard in every channel. */} +

+ {session ? announcements[session.state] : ''} +

+ + ); +}; diff --git a/examples/cookbook/src/recipes/ai/voice-agent-visuals/VoiceSession.tsx b/examples/cookbook/src/recipes/ai/voice-agent-visuals/VoiceSession.tsx new file mode 100644 index 0000000000..0a887cbb60 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/voice-agent-visuals/VoiceSession.tsx @@ -0,0 +1,29 @@ +import { createContext, useContext } from 'react'; +import type { HeardTextFunction, VoiceAgentState } from 'stream-chat-react/ai-components'; + +/** + * A live voice conversation with the assistant, as the app's voice backend reports it. The + * conversation is also written into a channel: what the user says, and the assistant's replies. + */ +export type VoiceSession = { + /** The channel the conversation is written into. */ + channelId: string; + /** What the assistant is doing: idle, connecting, listening, thinking or speaking. */ + state: VoiceAgentState; + /** The level of the user's microphone, from 0 to 1, for example from `useAudioLevel()`. */ + micLevel: () => number | null; + /** The level of the assistant's voice, from 0 to 1, for example from `useAudioLevel()`. */ + agentLevel: () => number | null; + /** The reply the assistant is speaking: its message, and how much of it the user has heard. */ + reply?: { messageId: string; heard: HeardTextFunction; generating: boolean }; + /** Ends the conversation. */ + end: () => void; +}; + +const VoiceSessionContext = createContext(undefined); + +/** Makes the app's voice session available to the channel header and the message text. */ +export const VoiceSessionProvider = VoiceSessionContext.Provider; + +/** The voice session, or `undefined` while there is none. */ +export const useVoiceSession = () => useContext(VoiceSessionContext); diff --git a/examples/cookbook/src/recipes/ai/voice-agent-visuals/WithVoiceAgent.tsx b/examples/cookbook/src/recipes/ai/voice-agent-visuals/WithVoiceAgent.tsx new file mode 100644 index 0000000000..93249810d0 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/voice-agent-visuals/WithVoiceAgent.tsx @@ -0,0 +1,15 @@ +import type { PropsWithChildren } from 'react'; +import { WithComponents } from 'stream-chat-react'; +import { SpokenMessageText } from './SpokenMessageText'; + +// Declared once, so WithComponents gets the same object on every render. +const overrides = { StreamedMessageText: SpokenMessageText }; + +/** + * The assistant's replies, revealed at the pace of its voice while it speaks them. `MessageUI` + * renders the `StreamedMessageText` slot for every message `Chat`'s `isMessageAIGenerated` + * returns `true` for. + */ +export const WithVoiceAgent = ({ children }: PropsWithChildren) => ( + {children} +); diff --git a/examples/cookbook/src/recipes/ai/voice-agent-visuals/default.recipe.tsx b/examples/cookbook/src/recipes/ai/voice-agent-visuals/default.recipe.tsx new file mode 100644 index 0000000000..caf2f3079a --- /dev/null +++ b/examples/cookbook/src/recipes/ai/voice-agent-visuals/default.recipe.tsx @@ -0,0 +1,23 @@ +import type { PropsWithChildren } from 'react'; +import type { RecipeConfig } from '../../../base/recipe'; +import { isMessageAIGenerated } from './isMessageAIGenerated'; +import { SimulateVoice } from './SimulateVoice'; +import { VoiceChannelHeader } from './VoiceChannelHeader'; +import { WithVoiceAgent } from './WithVoiceAgent'; + +// "Voice agent visuals": a voice conversation with the assistant shows under the channel header as +// a glow for the assistant and a wave for the user's microphone, and the reply being spoken shows +// only what the user has heard. SimulateVoice stands in for the voice backend. +const Wrapper = ({ children }: PropsWithChildren) => ( + + {children} + +); + +const recipe: RecipeConfig = { + ChannelHeader: VoiceChannelHeader, + chatProps: () => ({ isMessageAIGenerated }), + Wrapper, +}; + +export default recipe; diff --git a/examples/cookbook/src/recipes/ai/voice-agent-visuals/demo-data.json b/examples/cookbook/src/recipes/ai/voice-agent-visuals/demo-data.json new file mode 100644 index 0000000000..23dbe81e92 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/voice-agent-visuals/demo-data.json @@ -0,0 +1,33 @@ +{ + "users": [{ "id": "docs-v15-ck-assistant", "name": "Assistant" }], + "channels": [ + { + "id": "docs-v15-cb-voice-assistant", + "members": ["docs-v15-ck-cleo", "docs-v15-ck-assistant"], + "messages": [ + { + "id": "docs-v15-ck-voice-assistant-1", + "user": "docs-v15-ck-cleo", + "text": "What should I pack for a day hike?" + }, + { + "id": "docs-v15-ck-voice-assistant-2", + "user": "docs-v15-ck-assistant", + "text": "Water, a snack, a warm layer and a rain jacket. Comfortable shoes matter most.", + "custom": { "ai_generated": true } + }, + { + "id": "docs-v15-ck-voice-assistant-3", + "user": "docs-v15-ck-cleo", + "text": "How much water is enough?" + }, + { + "id": "docs-v15-ck-voice-assistant-4", + "user": "docs-v15-ck-assistant", + "text": "About half a liter for every hour of walking, and a little more on a hot day. Drink before you feel thirsty, and refill wherever you can.", + "custom": { "ai_generated": true } + } + ] + } + ] +} diff --git a/examples/cookbook/src/recipes/ai/voice-agent-visuals/isMessageAIGenerated.ts b/examples/cookbook/src/recipes/ai/voice-agent-visuals/isMessageAIGenerated.ts new file mode 100644 index 0000000000..848e5f81d1 --- /dev/null +++ b/examples/cookbook/src/recipes/ai/voice-agent-visuals/isMessageAIGenerated.ts @@ -0,0 +1,13 @@ +import type { LocalMessage } from 'stream-chat'; + +declare module 'stream-chat' { + // The field the backend sets on the assistant's replies. Optional, because every message in the + // app shares this interface. + interface CustomMessageData { + ai_generated?: boolean; + } +} + +/** The assistant wrote the message: the backend marks its replies with `ai_generated`. */ +export const isMessageAIGenerated = (message: LocalMessage) => + !!message.custom?.ai_generated; diff --git a/examples/cookbook/src/recipes/ai/voice-agent-visuals/shots.json b/examples/cookbook/src/recipes/ai/voice-agent-visuals/shots.json new file mode 100644 index 0000000000..1a46b974bc --- /dev/null +++ b/examples/cookbook/src/recipes/ai/voice-agent-visuals/shots.json @@ -0,0 +1,38 @@ +[ + { + "file": "voice-agent-speaking.png", + "query": { + "channel": "docs-v15-cb-voice-assistant", + "state": "speaking", + "frames": "90" + }, + "waitFor": "html[data-simulated-voice] [data-message-id=\"docs-v15-ck-voice-assistant-4\"]", + "clip": ".str-chat__channel", + "padding": 0, + "settleMs": 1200 + }, + { + "file": "voice-agent-listening.png", + "query": { + "channel": "docs-v15-cb-voice-assistant", + "state": "listening", + "frames": "90" + }, + "waitFor": "html[data-simulated-voice] .cookbook-voice-panel", + "clip": ".cookbook-voice-panel", + "padding": 0, + "settleMs": 1200 + }, + { + "file": "voice-agent-thinking.png", + "query": { + "channel": "docs-v15-cb-voice-assistant", + "state": "thinking", + "frames": "90" + }, + "waitFor": "html[data-simulated-voice] .cookbook-voice-panel", + "clip": ".cookbook-voice-panel", + "padding": 0, + "settleMs": 1200 + } +] diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/Invitations.css b/examples/cookbook/src/recipes/channel-list/channel-invites/Invitations.css new file mode 100644 index 0000000000..19ec7e5dc3 --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/Invitations.css @@ -0,0 +1,64 @@ +@layer stream-overrides { + /* Sits between the search bar and the channel list, at the height its rows need, and + separated from the channels by a line. */ + .cookbook-invitations { + border-bottom: 1px solid var(--str-chat__border-core-default); + } + + .cookbook-invitations__title { + margin: 0; + padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-md) + var(--str-chat__spacing-xs); + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-emphasis); + } + + .cookbook-invitations__list { + margin: 0; + padding: 0; + list-style: none; + } + + /* The avatar beside the channel's name, who invited the user, and the two buttons. */ + .cookbook-invitation { + display: flex; + align-items: flex-start; + gap: var(--str-chat__spacing-sm); + padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-md) + var(--str-chat__spacing-sm); + } + + .cookbook-invitation__body { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--str-chat__spacing-xxs); + min-width: 0; + } + + .cookbook-invitation__title { + margin: 0; + overflow: hidden; + color: var(--str-chat__text-primary); + font: var(--str-chat__font-body-emphasis); + text-overflow: ellipsis; + white-space: nowrap; + } + + .cookbook-invitation__by { + margin: 0; + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); + } + + .cookbook-invitation__actions { + display: flex; + gap: var(--str-chat__spacing-xs); + margin-top: var(--str-chat__spacing-xs); + } + + .cookbook-invitations__more { + display: flex; + padding: 0 var(--str-chat__spacing-md) var(--str-chat__spacing-xs); + } +} diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/Invitations.tsx b/examples/cookbook/src/recipes/channel-list/channel-invites/Invitations.tsx new file mode 100644 index 0000000000..a3a609b0fb --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/Invitations.tsx @@ -0,0 +1,171 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; +import type { Channel, ChannelPaginator, ChannelPaginatorState } from 'stream-chat'; +import { + Button, + ChannelAvatar, + LoadMoreButton, + useAriaLiveAnnouncer, + useChannelPreviewInfo, + useChatContext, + useStateStore, +} from 'stream-chat-react'; +import './Invitations.css'; + +type Answer = 'accept' | 'decline'; + +const invitesSelector = ({ hasMoreTail, isLoading, items }: ChannelPaginatorState) => ({ + hasMoreTail, + isLoading, + items, +}); + +/** + * The section has nothing to show but more to load: the first page was never loaded, or the user + * answered every loaded invitation while more remain on the server. + */ +const needsPage = (items: Channel[] | undefined, hasMoreTail: boolean) => + items === undefined || (items.length === 0 && hasMoreTail); + +/** + * One invitation: the channel, who invited the user, and Accept and Decline. Each button's name + * includes the channel's name, so a screen reader tells the rows apart. While the request runs, + * both buttons stay focusable but do nothing, and the pressed one reads "Joining…" or + * "Declining…". Once the server answers, the row leaves the section. If the focus is in the row + * then, it moves to the next row's first button, or else the previous row's. + */ +const Invitation = ({ + channel, + paginator, +}: { + channel: Channel; + paginator: ChannelPaginator; +}) => { + const { client } = useChatContext(); + const announce = useAriaLiveAnnouncer(); + const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ + channel, + }); + const [answering, setAnswering] = useState(); + const rowRef = useRef(null); + const inviter = channel.data?.created_by?.name; + + // Runs as the row leaves, while it is still on the page, whatever removed it. + useLayoutEffect(() => { + const row = rowRef.current; + return () => { + if (!row?.contains(document.activeElement)) return; + const neighbour = row.nextElementSibling ?? row.previousElementSibling; + neighbour?.querySelector('button')?.focus(); + }; + }, []); + + const answer = async (choice: Answer) => { + if (answering) return; + setAnswering(choice); + try { + if (choice === 'accept') await channel.acceptInvite(); + else await channel.rejectInvite(); + // An accepted channel joins the channel list through the `member.updated` event that + // follows, which removes the row too if it arrives first. + paginator.removeItem({ item: channel }); + announce( + choice === 'accept' + ? `Joined ${displayTitle}` + : `Declined the invitation to ${displayTitle}`, + ); + } catch (error) { + setAnswering(undefined); + client.notifications.addError({ + message: `Couldn’t answer the invitation to ${displayTitle}`, + origin: { context: { error, panel: 'channel-list' }, emitter: 'Invitation' }, + }); + } + }; + + return ( +
  • +
  • + ); +}; + +/** + * The channels the user is invited to, as a list of invitations under an "Invitations" heading. + * Loads its first page on mount, and each further page when the user clicks "Load more", or when + * the user answered every loaded invitation while more remain. Shows nothing until the first page + * arrives, or when there are no invitations. + */ +export const Invitations = ({ paginator }: { paginator: ChannelPaginator }) => { + const { hasMoreTail, isLoading, items } = useStateStore( + paginator.state, + invitesSelector, + ); + const titleId = useId(); + + useEffect(() => { + if (needsPage(items, hasMoreTail)) void paginator.toTail(); + }, [hasMoreTail, items, paginator]); + + if (!items?.length) return null; + + return ( +
    +

    + Invitations +

    +
      + {items.map((channel) => ( + + ))} +
    + {hasMoreTail && ( +
    + void paginator.toTail()} /> +
    + )} +
    + ); +}; diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/SimulateInvites.tsx b/examples/cookbook/src/recipes/channel-list/channel-invites/SimulateInvites.tsx new file mode 100644 index 0000000000..66430165c7 --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/SimulateInvites.tsx @@ -0,0 +1,27 @@ +import type { ClickStep } from '../../../base/useClickInTurn'; +import { useClickInTurn } from '../../../base/useClickInTurn'; + +const params = new URLSearchParams(window.location.search); +const accepts = params.get('simulate') === 'accept'; +const channelName = params.get('row') ?? ''; + +const steps: ClickStep[] = accepts + ? [ + { + find: () => + document.querySelector( + `[aria-label="Accept invitation to ${CSS.escape(channelName)}"]`, + ), + }, + ] + : []; + +/** + * For the screenshots: `?simulate=accept&row=` presses that invitation's Accept + * button, as a click would. The cookbook is read-only, so the request never settles and the button + * keeps reading "Joining…". + */ +export const SimulateInvites = () => { + useClickInTurn(steps); + return null; +}; diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/WithChannelInvites.tsx b/examples/cookbook/src/recipes/channel-list/channel-invites/WithChannelInvites.tsx new file mode 100644 index 0000000000..1939261663 --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/WithChannelInvites.tsx @@ -0,0 +1,64 @@ +import { createContext, useContext, useMemo } from 'react'; +import type { ComponentType, PropsWithChildren } from 'react'; +import type { + ChannelFilters, + ChannelPaginator, + SearchControllerState, +} from 'stream-chat'; +import { + Search as DefaultSearch, + useChatContext, + useComponentContext, + useStateStore, + WithComponents, +} from 'stream-chat-react'; +import { Invitations } from './Invitations'; +import { useChannelInvites } from './useChannelInvites'; + +const InvitesContext = createContext<{ + invites?: ChannelPaginator; + Search: ComponentType; +}>({ Search: DefaultSearch }); + +const searchSelector = ({ isActive }: SearchControllerState) => ({ isActive }); + +/** + * The app's search, then the invitations while no search is open. `ChannelNavigation` renders its + * `Search` slot right above the channel list, so the invitations sit there too. + */ +const SearchWithInvitations = () => { + const { invites, Search } = useContext(InvitesContext); + const { searchController } = useChatContext(); + const { isActive } = useStateStore(searchController.state, searchSelector); + + return ( + <> + + {!isActive && invites && } + + ); +}; + +// Declared once, so WithComponents gets the same object on every render. +const overrides = { Search: SearchWithInvitations }; + +/** + * Shows the channels the user is invited to above the channel list, and keeps them out of the + * list until the user accepts. `filters` scope both to the user's channels; pass a stable object. + * Keeps the app's own `Search` override, if it has one. + */ +export const WithChannelInvites = ({ + children, + filters, +}: PropsWithChildren<{ filters: ChannelFilters }>) => { + const invites = useChannelInvites(filters); + // Read outside this component's own WithComponents, where `Search` is still the app's. + const { Search = DefaultSearch } = useComponentContext(); + const value = useMemo(() => ({ invites, Search }), [invites, Search]); + + return ( + + {children} + + ); +}; diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/default.recipe.tsx b/examples/cookbook/src/recipes/channel-list/channel-invites/default.recipe.tsx new file mode 100644 index 0000000000..7aed9aca3b --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/default.recipe.tsx @@ -0,0 +1,22 @@ +import type { PropsWithChildren } from 'react'; +import { channelFilters } from '../../../base/client'; +import type { RecipeConfig } from '../../../base/recipe'; +import { SimulateInvites } from './SimulateInvites'; +import { WithChannelInvites } from './WithChannelInvites'; + +// "Channel invites": an Invitations section above the channel list, whose channel list holds only +// the channels the user joined. The recipe registers that list's paginator itself, scoped to the +// cookbook's channels. SimulateInvites only serves the screenshots. +const Wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + +); + +const recipe: RecipeConfig = { + registerChannelPaginator: false, + Wrapper, +}; + +export default recipe; diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/demo-data.json b/examples/cookbook/src/recipes/channel-list/channel-invites/demo-data.json new file mode 100644 index 0000000000..bec1489f1b --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/demo-data.json @@ -0,0 +1,42 @@ +{ + "channels": [ + { + "id": "docs-v15-cb-bird-walk", + "name": "Bird walk", + "createdBy": "docs-v15-ck-bob", + "members": ["docs-v15-ck-bob", "docs-v15-ck-dan"], + "invites": ["docs-v15-ck-cleo"], + "messages": [ + { + "id": "docs-v15-ck-bird-walk-1", + "user": "docs-v15-ck-bob", + "text": "We start at the lake at eight on Sunday. Bring binoculars if you have them." + }, + { + "id": "docs-v15-ck-bird-walk-2", + "user": "docs-v15-ck-dan", + "text": "I saw two herons there last week." + } + ] + }, + { + "id": "docs-v15-cb-chess", + "name": "Chess club", + "createdBy": "docs-v15-ck-cara", + "members": ["docs-v15-ck-cara", "docs-v15-ck-bob"], + "invites": ["docs-v15-ck-cleo"], + "messages": [ + { + "id": "docs-v15-ck-chess-1", + "user": "docs-v15-ck-cara", + "text": "Games every Thursday evening at the library, all levels welcome." + }, + { + "id": "docs-v15-ck-chess-2", + "user": "docs-v15-ck-bob", + "text": "I'll bring the spare boards." + } + ] + } + ] +} diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/shots.json b/examples/cookbook/src/recipes/channel-list/channel-invites/shots.json new file mode 100644 index 0000000000..c61e972d2f --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/shots.json @@ -0,0 +1,24 @@ +[ + { + "file": "channel-list-invitations.png", + "query": { + "channel": "docs-v15-cb-design" + }, + "waitFor": ".cookbook-invitation + .cookbook-invitation", + "clip": ".str-chat__channel-list", + "padding": 0, + "settleMs": 1500 + }, + { + "file": "channel-list-invitation-joining.png", + "query": { + "channel": "docs-v15-cb-design", + "simulate": "accept", + "row": "Bird walk" + }, + "waitFor": ".cookbook-invitation [aria-busy='true']", + "clip": ".cookbook-invitations", + "padding": 0, + "settleMs": 1000 + } +] diff --git a/examples/cookbook/src/recipes/channel-list/channel-invites/useChannelInvites.ts b/examples/cookbook/src/recipes/channel-list/channel-invites/useChannelInvites.ts new file mode 100644 index 0000000000..5a6a2b5876 --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/channel-invites/useChannelInvites.ts @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react'; +import { ChannelPaginator } from 'stream-chat'; +import type { + Channel, + ChannelFilters, + ChannelMemberResponse, + ChannelPaginatorOptions, + SortParamRequest, +} from 'stream-chat'; +import { useChatContext } from 'stream-chat-react'; + +/** The user joined the channel: they weren't invited, or they accepted the invite. */ +const hasJoined = (channel: Channel) => { + const { invite_accepted_at, invited } = channel.state.membership; + return !invited || !!invite_accepted_at; +}; + +/** The member answered their invite, on this device or another. */ +const hasAnswered = ({ invite_accepted_at, invite_rejected_at }: ChannelMemberResponse) => + !!(invite_accepted_at || invite_rejected_at); + +/** + * A channel paginator that checks the `joined` filter on the channel's membership, as the server + * does, when an event asks whether a channel belongs in the list. + */ +class JoinedChannelPaginator extends ChannelPaginator { + constructor(options: ChannelPaginatorOptions) { + super(options); + // First in the list: the last default resolver takes every field it reaches. + this.setFilterResolvers([ + { matchesField: (field) => field === 'joined', resolve: hasJoined }, + ...this._filterFieldToDataResolvers, + ]); + } +} + +// The channel with the latest message first. +const byLastMessage: SortParamRequest[] = [{ direction: -1, field: 'last_message_at' }]; +// The newest channel first. +const byCreatedAt: SortParamRequest[] = [{ direction: -1, field: 'created_at' }]; + +/** + * Registers the channel list's paginator on `client.channelManager`, limited to channels the user + * joined, and returns a second paginator with the channels the user is invited to. `filters` + * scope both to the user's channels; pass a stable object. Returns `undefined` until both exist. + */ +export const useChannelInvites = (filters: ChannelFilters) => { + const { client } = useChatContext(); + const [invites, setInvites] = useState(); + + useEffect(() => { + const channels = new JoinedChannelPaginator({ + client, + filters: { ...filters, joined: true }, + id: 'channels', + requestOptions: { presence: true, state: true }, + sort: byLastMessage, + }); + const invites = new ChannelPaginator({ + client, + filters: { ...filters, invite: 'pending' }, + id: 'invites', + paginatorOptions: { pageSize: 5 }, + sort: byCreatedAt, + }); + client.channelManager.insertPaginator({ paginator: channels }); + + // A paginator runs one query at a time, so an invite that arrives during a load is noted, and + // loaded once that load ends. The reload keeps the rows on screen until its page arrives. + let reloadWhenIdle = false; + const reload = () => { + if (invites.isLoading) { + reloadWhenIdle = true; + return; + } + reloadWhenIdle = false; + void invites.toTail({ keepPreviousItems: true, reset: 'yes' }); + }; + const offLoadEnd = invites.state.subscribeWithSelector( + ({ isLoading }) => ({ isLoading }), + ({ isLoading }) => { + if (!isLoading && reloadWhenIdle) reload(); + }, + ); + + // `ChannelNavigation` renders a channel list for every paginator on the manager, so the + // invites stay off it and follow the events themselves. The invites query watches its + // channels, so a watched channel's answer arrives as `member.updated`, not as a + // notification. A new invite arrives as `notification.invited`. + const { unsubscribe } = client.on((event) => { + if (event.type === 'notification.invited') reload(); + if ( + event.type === 'member.updated' && + event.member.user_id === client.userId && + hasAnswered(event.member) + ) + invites.removeItem({ id: event.cid }); + }); + setInvites(invites); + + return () => { + unsubscribe(); + offLoadEnd(); + client.channelManager.removePaginator(channels); + channels.dispose(); + invites.dispose(); + setInvites(undefined); + }; + }, [client, filters]); + + return invites; +}; diff --git a/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListLoadError.tsx b/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListLoadError.tsx new file mode 100644 index 0000000000..49e620e77b --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListLoadError.tsx @@ -0,0 +1,57 @@ +import { useEffect } from 'react'; +import { + Button, + IconExclamationCircleFill, + IconRetry, + useAriaLiveAnnouncer, +} from 'stream-chat-react'; +import './ChannelListState.css'; + +const title = 'Couldn’t load your conversations'; + +/** + * Shown in place of the channel list when its first page failed to load. `onRetry` queries the first + * page again. The view stays mounted while the retry runs, so the Retry button keeps focus: it turns + * busy, and reads Retry again if the retry fails. + */ +export const ChannelListLoadError = ({ + isRetrying, + onRetry, +}: { + isRetrying: boolean; + onRetry: () => void; +}) => { + const announce = useAriaLiveAnnouncer(); + + // Announced when the error first shows and again whenever a retry fails. Polite, so it follows + // the SDK's own error notification, which is announced assertively at the same moment. The dedupe + // window swallows the second run of the effect in development. + useEffect(() => { + if (isRetrying) return; + announce(title, { dedupeMs: 1000, priority: 'polite' }); + }, [announce, isRetrying]); + + return ( +
    +
    + +
    +

    {title}

    +

    + Something went wrong. Try again. +

    + {/* aria-disabled, not disabled: a disabled button would lose focus. */} + +
    + ); +}; diff --git a/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListSkeleton.css b/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListSkeleton.css new file mode 100644 index 0000000000..38fbea5d8c --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListSkeleton.css @@ -0,0 +1,79 @@ +@layer stream-overrides { + .cookbook-channel-list-skeleton { + display: flex; + flex-direction: column; + } + + /* The same size and spacing as a channel list row, so nothing moves when the rows arrive. */ + .cookbook-channel-list-skeleton__row { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-md); + padding: calc(var(--str-chat__spacing-sm) + var(--str-chat__spacing-xxs)); + border-bottom: 1px solid var(--str-chat__border-core-subtle); + } + + .cookbook-channel-list-skeleton__avatar { + flex-shrink: 0; + width: var(--str-chat__size-48); + height: var(--str-chat__size-48); + border-radius: var(--str-chat__radius-full); + } + + .cookbook-channel-list-skeleton__lines { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--str-chat__spacing-xs); + min-width: 0; + } + + .cookbook-channel-list-skeleton__first-line { + display: flex; + justify-content: space-between; + gap: var(--str-chat__spacing-md); + } + + .cookbook-channel-list-skeleton__bar { + height: 14px; + border-radius: var(--str-chat__radius-full); + } + + .cookbook-channel-list-skeleton__bar--time { + flex-shrink: 0; + width: var(--str-chat__size-48); + } + + /* The skeleton tokens are a shimmer laid over a surface, so the shapes get a surface below it that + stands out from the list. One shimmer moves across every shape. */ + .cookbook-channel-list-skeleton__avatar, + .cookbook-channel-list-skeleton__bar { + background-color: var(--str-chat__background-core-surface-default); + background-image: linear-gradient( + 90deg, + var(--str-chat__skeleton-loading-base) 0%, + var(--str-chat__skeleton-loading-highlight) 50%, + var(--str-chat__skeleton-loading-base) 100% + ); + background-repeat: no-repeat; + background-size: 200% 100%; + animation: cookbook-channel-list-shimmer 1.4s linear infinite; + } + + @media (prefers-reduced-motion: reduce) { + .cookbook-channel-list-skeleton__avatar, + .cookbook-channel-list-skeleton__bar { + animation: none; + } + } + + @keyframes cookbook-channel-list-shimmer { + from { + background-position: 200% 0; + } + + to { + background-position: -200% 0; + } + } +} diff --git a/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListSkeleton.tsx b/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListSkeleton.tsx new file mode 100644 index 0000000000..ad6ac2500b --- /dev/null +++ b/examples/cookbook/src/recipes/channel-list/empty-and-loading-states/ChannelListSkeleton.tsx @@ -0,0 +1,47 @@ +import { useEffect } from 'react'; +import { useAriaLiveAnnouncer } from 'stream-chat-react'; +import './ChannelListSkeleton.css'; + +// Different widths per row, so the placeholder reads as a list of conversations. +const rows = [ + { preview: '88%', title: '52%' }, + { preview: '70%', title: '38%' }, + { preview: '80%', title: '60%' }, + { preview: '64%', title: '44%' }, + { preview: '76%', title: '34%' }, +]; + +/** + * Placeholder rows shaped like the channel list's rows: an avatar, the title and the time on the + * first line, the last message on the second. Screen readers skip the shapes and hear one message + * through the SDK's announcer instead. + */ +export const ChannelListSkeleton = () => { + const announce = useAriaLiveAnnouncer(); + + // Delayed, so a first page that arrives quickly is not announced at all; unmounting cancels it. + useEffect(() => announce('Loading conversations…', { delayMs: 300 }), [announce]); + + return ( +