From 8e7008e712c026bbf193e314ad84d7f824748954 Mon Sep 17 00:00:00 2001 From: woksin Date: Mon, 28 Sep 2026 18:20:14 +0200 Subject: [PATCH 1/7] Add host-owned topic actions to chat lists Expose topicActions on the standalone list and sidebar without changing action-free markup or message actions. Reuse the existing hover/focus action styling to keep packed CSS within its budget. Closes #206. --- Documentation/Chat/index.md | 6 +- Documentation/Chat/toc.yml | 2 + Documentation/Chat/topic-actions.md | 41 ++++++ Documentation/Chat/topics-and-naming.md | 12 ++ Source/Chat/ChatSidebar.stories.tsx | 3 +- Source/Chat/ChatSidebar.tsx | 6 + Source/Chat/ChatTopicAction.ts | 30 ++++ Source/Chat/ChatTopicList.stories.tsx | 21 +++ Source/Chat/ChatTopicList.tsx | 133 +++++++++++------- .../when_topic_actions_are_offered.ts | 42 ++++++ .../when_no_topic_actions_are_offered.ts | 25 ++++ .../when_topic_actions_are_offered.ts | 63 +++++++++ Source/Chat/index.ts | 1 + Storybook/scripts/storybook-inventory.json | 1 + 14 files changed, 334 insertions(+), 52 deletions(-) create mode 100644 Documentation/Chat/topic-actions.md create mode 100644 Source/Chat/ChatTopicAction.ts create mode 100644 Source/Chat/for_ChatSidebar/when_topic_actions_are_offered.ts create mode 100644 Source/Chat/for_ChatTopicList/when_no_topic_actions_are_offered.ts create mode 100644 Source/Chat/for_ChatTopicList/when_topic_actions_are_offered.ts diff --git a/Documentation/Chat/index.md b/Documentation/Chat/index.md index 91e1d614..a52c3898 100644 --- a/Documentation/Chat/index.md +++ b/Documentation/Chat/index.md @@ -20,7 +20,7 @@ Everything about _data_ stays with your application: - `@`-mentions from a list you hold or a provider callback you resolve, rendered distinctly in message bodies - Emoji picker in the composer, with a quick row of recently used emoji - Host-supplied render callbacks for avatars and display names — messages carry only the author id -- Extensible per-message actions shown on hover or keyboard focus +- Extensible per-message and per-topic actions shown on hover or keyboard focus - Every color from the `--cratis-*` token seam, so the chat follows your theme ## Components @@ -97,7 +97,8 @@ export const Workspace = () => { | `onRequestTopicName` / `isTopicUnnamed` | callbacks | — | The host-side naming contract. | | `selectedTopicId` / `onTopicSelected` | `ChatIdentifier \| null`, callback | Internal | Owns or observes the open topic. | | `authorOf`, `renderAvatar`, `renderAuthorName`, `buildAvatarUrl` | callbacks | — | Author resolution and rendering. Without `authorOf`, the id is shown as the name. | -| `actions`, `quickReply` | `ChatMessageAction[]`, `boolean` | —, `true` | See [Message actions](./message-actions.md). | +| `actions`, `quickReply` | `ChatMessageAction[]`, `boolean` | —, `true` | Message actions and quick reply; see [Message actions](./message-actions.md). | +| `topicActions` | `ChatTopicAction[]` | — | Actions beside available topics; see [Topic actions](./topic-actions.md). | | `mentionCandidates` / `resolveMentionCandidates` | array or callback | — | See [Mentions and emoji](./mentions-and-emoji.md). Omit both to turn mentions off. | | `typingAuthors` | `ChatTypingAuthor[]` | `[]` | Who the conversation is waiting on. | | `autoFocus` | `boolean` | `false` | Focuses the composer when a conversation mounts. | @@ -218,4 +219,5 @@ Import `@cratis/components/styles` (or, with per-area stylesheets, `@cratis/comp - [Topics and naming](./topics-and-naming.md) — the topic lifecycle and the host-side auto-naming contract - [Mentions and emoji](./mentions-and-emoji.md) — candidate providers, how mentions travel and render - [Message actions](./message-actions.md) — offering your own actions on messages +- [Topic actions](./topic-actions.md) — offering your own actions on topics - [Observable queries](./observable-queries.md) — the optional Arc-aware wrapper diff --git a/Documentation/Chat/toc.yml b/Documentation/Chat/toc.yml index 1950d7f5..d75d6d53 100644 --- a/Documentation/Chat/toc.yml +++ b/Documentation/Chat/toc.yml @@ -6,5 +6,7 @@ href: mentions-and-emoji.md - name: Message actions href: message-actions.md +- name: Topic actions + href: topic-actions.md - name: Observable queries href: observable-queries.md diff --git a/Documentation/Chat/topic-actions.md b/Documentation/Chat/topic-actions.md new file mode 100644 index 00000000..c64ee66b --- /dev/null +++ b/Documentation/Chat/topic-actions.md @@ -0,0 +1,41 @@ +--- +title: Topic actions +description: Offer host-owned actions next to each topic without opening its conversation. +--- + +Pass `topicActions` to `ChatSidebar` or the standalone `ChatTopicList` when a person needs to rename, archive, or otherwise act on a topic without opening it. Unlike the sidebar's `actions` prop, which applies to **messages**, `topicActions` applies to topics only. + +## Descriptor + +| Field | Type | Required | Behavior | +| --- | --- | --- | --- | +| `id` | `string` | Yes | Unique rendering key among the actions. | +| `label` | `string` | Yes | Tooltip and accessible name (combined with the topic's name). Shown as button text if no icon is supplied. | +| `icon` | `string \| ReactNode` | No | An icon-font CSS class name or a ready element. | +| `isAvailable` | `(topic: TTopic) => boolean` | No | Return `false` to omit the action for a topic; omitted means available on every topic. | +| `onInvoke` | `(topic: TTopic) => void` | Yes | Receives the full host topic when the control is activated. | + +The buttons sit beside, not inside, the button that opens the conversation. They appear on hover or keyboard focus. Tab reaches each available control, and activating one does not open the topic. + +## Offer an action + +This excerpt assumes `sidebarProps` supplies `open`, `onClose`, `topics`, `messages`, and `onSendMessage` as in [basic usage](./index.md#basic-usage). The handlers belong to your application. + +```tsx +import { ChatSidebar } from '@cratis/components/Chat'; + + Boolean(topic.name?.trim()), + onInvoke: topic => renameTopic(topic.id), + }, + ]} +/> +``` + +The same `topicActions` prop works on `ChatTopicList`. Omit it to leave the existing topic markup and opening behavior unchanged. diff --git a/Documentation/Chat/topics-and-naming.md b/Documentation/Chat/topics-and-naming.md index 1207003b..07909a10 100644 --- a/Documentation/Chat/topics-and-naming.md +++ b/Documentation/Chat/topics-and-naming.md @@ -22,6 +22,18 @@ interface ChatTopic { The list orders topics by `lastActivity` (falling back to `started`) itself — hand it topics in any order. +## ChatTopicList props + +| Prop | Type | Default | Behavior | +| --- | --- | --- | --- | +| `topics`, `onOpen` | `TTopic[]`, `(topic: TTopic) => void` | Required | Topics and the callback for opening one. | +| `onStart` | `() => void` | — | Shows the new-topic button when supplied. | +| `topicActions` | `ChatTopicAction[]` | — | Host actions beside topics where they are available. See [Topic actions](./topic-actions.md). | +| `status` | `ChatStatus` | `Ready` | List query display state. | +| `authorOf`, `renderAvatar`, `buildAvatarUrl` | callbacks | — | Resolve and render the topic starter. | +| `isTopicUnnamed` | `(topic: TTopic) => boolean` | Blank name | Decides when to show the pending placeholder. | +| `labels`, `className` | `ChatTopicListLabels`, `string` | — | Label overrides and root class name. | + ## Starting a topic `onStartTopic` raises the intent; creating the topic is the application's business. Answer with the new topic's id — directly or through a promise — and the sidebar opens it, ready for the first message: diff --git a/Source/Chat/ChatSidebar.stories.tsx b/Source/Chat/ChatSidebar.stories.tsx index b62de131..38110b8b 100644 --- a/Source/Chat/ChatSidebar.stories.tsx +++ b/Source/Chat/ChatSidebar.stories.tsx @@ -4,7 +4,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { fn } from 'storybook/test'; import { useRef, useState } from 'react'; -import { FaCopy } from 'react-icons/fa6'; +import { FaCopy, FaPen } from 'react-icons/fa6'; import { ChatAuthorKind } from './Kit/ChatAuthorKind'; import type { ChatAuthor } from './ChatAuthor'; import type { ChatIdentifier } from './ChatIdentifier'; @@ -72,6 +72,7 @@ export const Playground: Story = { onStartTopic: fn(), onRequestTopicName: fn(), onTopicSelected: fn(), + topicActions: [{ id: 'rename', label: 'Rename', icon: , onInvoke: fn() }], authorOf, mentionCandidates, }, diff --git a/Source/Chat/ChatSidebar.tsx b/Source/Chat/ChatSidebar.tsx index 96b9727a..3f3ae177 100644 --- a/Source/Chat/ChatSidebar.tsx +++ b/Source/Chat/ChatSidebar.tsx @@ -11,6 +11,7 @@ import { sameChatIdentifier, type ChatIdentifier } from './ChatIdentifier'; import type { ChatMention } from './ChatMention'; import type { ChatMessage } from './ChatMessage'; import type { ChatTopic } from './ChatTopic'; +import type { ChatTopicAction } from './ChatTopicAction'; import type { ChatStatus } from './ChatStatus'; import type { ChatTopicListLabels } from './ChatTopicList'; import { ChatTopicList } from './ChatTopicList'; @@ -107,6 +108,9 @@ export interface ChatSidebarProps< */ messages: TMessage[]; + /** The host's actions to offer on each topic where they are available. The inherited `actions` prop applies only to messages. */ + topicActions?: ChatTopicAction[]; + /** Topic-list query display state. Defaults to ready when omitted. */ topicsStatus?: ChatStatus; @@ -225,6 +229,7 @@ export const ChatSidebar = < onClose, topics, messages, + topicActions, topicsStatus, messagesStatus, selectedTopicId, @@ -399,6 +404,7 @@ export const ChatSidebar = < {openTopicId === undefined ? ( topics={topics} + topicActions={topicActions} status={topicsStatus} onOpen={(topic) => select(topic.id, topic)} onStart={ diff --git a/Source/Chat/ChatTopicAction.ts b/Source/Chat/ChatTopicAction.ts new file mode 100644 index 00000000..799df2a7 --- /dev/null +++ b/Source/Chat/ChatTopicAction.ts @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { ReactNode } from 'react'; +import type { ChatTopic } from './ChatTopic'; + +/** An action a host offers on topics. The library ships no topic actions of its own. + * @typeParam TTopic The host's topic type, so {@link onInvoke} receives the full topic back. + */ +export interface ChatTopicAction { + /** Identifies the action among its siblings — used as the rendering key. */ + id: string; + + /** The label, used in the tooltip and accessible name alongside the topic's name. */ + label: string; + + /** An icon element or a CSS class name for the host's icon font. Omit to show the label. */ + icon?: string | ReactNode; + + /** Decides which topics the action is offered on. Omit to offer it on all topics. + * @param topic The topic the action would apply to. + * @returns True when the action should be offered. + */ + isAvailable?: (topic: TTopic) => boolean; + + /** Invoked when the action is picked on a topic. + * @param topic The topic it was picked on. + */ + onInvoke: (topic: TTopic) => void; +} diff --git a/Source/Chat/ChatTopicList.stories.tsx b/Source/Chat/ChatTopicList.stories.tsx index d9e42edf..4671799a 100644 --- a/Source/Chat/ChatTopicList.stories.tsx +++ b/Source/Chat/ChatTopicList.stories.tsx @@ -3,6 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { fn } from 'storybook/test'; +import { FaPen, FaBoxArchive } from 'react-icons/fa6'; import { ChatAuthorKind } from './Kit/ChatAuthorKind'; import type { ChatAuthor } from './ChatAuthor'; import type { ChatIdentifier } from './ChatIdentifier'; @@ -74,6 +75,26 @@ export const Playground: Story = { ), }; +/** Hover a topic, or Tab from its open button, to see the host's available actions. */ +export const WithActions: Story = { + args: { + topics: [ + { id: 'topic-1', name: 'Example topic' }, + { id: 'topic-2', name: 'Pinned topic', metadata: { pinned: true } }, + ], + onOpen: fn(), + topicActions: [ + { id: 'rename', label: 'Rename', icon: , onInvoke: fn() }, + { id: 'archive', label: 'Archive', icon: , isAvailable: (topic) => topic.metadata?.pinned !== true, onInvoke: fn() }, + ], + }, + render: (args) => ( +
+ +
+ ), +}; + /** Nothing to pick from yet — only the empty message and the way to start the first topic. */ export const Empty: Story = { args: { diff --git a/Source/Chat/ChatTopicList.tsx b/Source/Chat/ChatTopicList.tsx index 3bfb24bf..733e222b 100644 --- a/Source/Chat/ChatTopicList.tsx +++ b/Source/Chat/ChatTopicList.tsx @@ -7,6 +7,7 @@ import { ChatAuthorKind } from './Kit/ChatAuthorKind'; import type { ChatAuthor } from './ChatAuthor'; import { chatIdentifierString, type ChatIdentifier } from './ChatIdentifier'; import type { ChatTopic } from './ChatTopic'; +import type { ChatTopicAction } from './ChatTopicAction'; import { ChatStatus } from './ChatStatus'; import { isTopicUnnamed as defaultIsTopicUnnamed } from './isTopicUnnamed'; import { relativeTimestamp, type RelativeTimestampLabels } from './relativeTimestamp'; @@ -112,6 +113,9 @@ export interface ChatTopicListProps { /** Overrides for every label rendered. Unset fields fall back to literal English defaults. */ labels?: ChatTopicListLabels; + /** The host's actions to offer on each topic where they are available. */ + topicActions?: ChatTopicAction[]; + /** Additional class name for the list's root element. */ className?: string; } @@ -132,6 +136,7 @@ export const ChatTopicList = ({ isTopicUnnamed, buildAvatarUrl, labels, + topicActions, className, }: ChatTopicListProps) => { const unnamed = isTopicUnnamed ?? defaultIsTopicUnnamed; @@ -176,59 +181,89 @@ export const ChatTopicList = ({ : authorFor(topic.startedBy); const isPending = unnamed(topic); const activity = topic.lastActivity ?? topic.started; + const topicName = isPending ? (labels?.unnamedTopic ?? 'New topic') : topic.name; + const availableActions = (topicActions ?? []).filter( + (action) => action.isAvailable?.(topic) ?? true, + ); - return ( -
  • - + + {activity !== undefined && ( + + {relativeTimestamp( + activity, + new Date(), + labels?.timestamps, + )} + + )} + + ); + + // Reuse the message action overlay so keyboard focus reveals controls without adding CSS. + return ( +
  • 0 ? 'cratis-chat-message cratis-chat-topics__row' : undefined} + style={availableActions.length > 0 ? { position: 'relative' } : undefined} + > + {topicButton} + {availableActions.length > 0 && ( +
    + {availableActions.map((action) => ( +