Draw what the bot actually sent

Buttons and menus under the message, in the rows they were laid out in. A
link button is drawn as a link, because that is what it is - it opens a
page and tells nobody it was pressed - and everything else reports back to
whichever bot posted it.

Typing a slash offers the channel's commands, in the same place and with
the same keys as the name picker: two lists that behaved differently would
be two things to learn. What is typed after the name fills the command's
arguments in the order it declares them, and a command still missing a
required one says which rather than failing at the service.

Nothing is echoed when a button is pressed. Whatever the bot does about it
arrives the way anything else does, and guessing would mean showing an
answer that never came.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-05 18:38:52 -04:00
co-authored by Claude Opus 5
parent e6a9f485b2
commit e3dcebe28b
6 changed files with 352 additions and 2 deletions
+1 -1
Submodule nobilis updated: 9577b117c5...af11ded923
+71
View File
@@ -766,6 +766,77 @@
border-radius: var(--corner-radius-small);
}
/*
* The controls a service put on a message.
*
* Rows, because the layout is information: a bot that put five buttons on one
* row and one below meant the last to stand apart. Sized down from a real
* button - these sit inside a message, and a row of full-size buttons would
* outweigh what was said.
*/
.component-rows {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
margin-top: var(--spacing-xs);
}
.component-row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
align-items: center;
}
.component-button {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
max-width: 220px;
padding: 3px var(--spacing-s);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius-small);
background: var(--surface-container-highest);
color: var(--surface-text);
font-size: var(--font-size-small);
text-decoration: none;
cursor: pointer;
}
.component-button:hover:not(:disabled) {
border-color: var(--primary);
}
.component-button:disabled {
opacity: 0.5;
cursor: default;
}
/* Discord's own four meanings, in this theme's colours rather than theirs. */
.component-button.primary {
border-color: var(--primary);
color: var(--primary);
}
.component-button.success {
border-color: var(--success, var(--primary));
}
.component-button.danger {
border-color: var(--error);
color: var(--error);
}
.component-select {
max-width: 260px;
padding: 3px var(--spacing-s);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius-small);
background: var(--surface-container-highest);
color: var(--surface-text);
font-size: var(--font-size-small);
}
/*
* The filter menu under a search box.
*
+135
View File
@@ -69,6 +69,16 @@ function caretToEnd(el: HTMLElement): void {
* encryption indicator. A fixed bar rather than part of the scrolling message
* area, so arriving messages can never visually intrude under it.
*/
/** One slash command a channel offers, as the daemon describes it. */
interface DiscordCommand {
id: string
name: string
description?: string
application?: string
/** Discord's own description of the command, handed back to run it. */
command: { options?: { name: string; type?: number; required?: boolean }[] }
}
export function Composer(): JSX.Element | null {
const store = useStore()
const buffer = useActiveBuffer()
@@ -110,6 +120,15 @@ export function Composer(): JSX.Element | null {
const cycle = useRef<{ last: Completion; attempt: number } | null>(null)
/** The "@..." being typed, and which suggestion is selected. */
const [mention, setMention] = useState<{ query: string; index: number } | null>(null)
/**
* The slash commands this channel offers, while one is being typed.
*
* Asked of the service rather than assembled here: these belong to whatever
* bots a server has installed, they differ per channel, and there is no
* list a client could hold. Only Discord has them.
*/
const [commands, setCommands] = useState<DiscordCommand[]>([])
const [commandIndex, setCommandIndex] = useState(0)
/** Discord's mentionable roles here; empty everywhere else. */
const [roles, setRoles] = useState<{ id: string; name: string; colour?: string }[]>([])
const emojiButtonRef = useRef<HTMLButtonElement>(null)
@@ -273,6 +292,75 @@ export function Composer(): JSX.Element | null {
})
}
// What has been typed as a command, if anything: a slash at the very start
// and the word after it. Not mid-message - "and/or" is not a command.
const typedCommand = service === 'discord' && /^\/\S*$/.test(text) ? text.slice(1) : null
useEffect(() => {
if (typedCommand === null) {
setCommands([])
return
}
const timer = setTimeout(() => {
void window.moho
.rpc<{ commands: DiscordCommand[] }>('listDiscordCommands', {
bufferId: buffer!.id,
query: typedCommand
})
.then((answer) => {
setCommands(answer.commands || [])
setCommandIndex(0)
})
.catch(() => {
// A channel with no bots answers nothing useful, and a list that
// could not be read is a list that stays closed rather than a
// complaint about typing a slash.
setCommands([])
})
}, 200)
return () => clearTimeout(timer)
}, [typedCommand, buffer?.id])
/**
* Runs the chosen command, with whatever was typed after it as its
* arguments, in the order the command declares them.
*
* Positional rather than named: Discord's own client draws a form built
* from the command's own description of itself, which is a great deal of
* interface for something people type. What somebody types after the name
* is what they mean by the first argument.
*/
const runCommand = (command: DiscordCommand): void => {
const words = text.trim().split(/\s+/).slice(1)
const declared = (command.command?.options ?? []).filter((o) => (o.type ?? 3) <= 10)
const options = declared
.map((option, i) => ({ option, word: words[i] }))
.filter((pair) => pair.word !== undefined)
.map(({ option, word }) => ({
type: option.type ?? 3,
name: option.name,
// Numbers and booleans are sent as themselves; everything else, a
// service resolves for itself from what was written.
value:
option.type === 4 || option.type === 10
? Number(word)
: option.type === 5
? word.toLowerCase() === 'true'
: word
}))
const missing = declared.find((o, i) => o.required && words[i] === undefined)
if (missing) {
store.toast('info', `/${command.name} needs ${missing.name}`)
return
}
setText('')
if (inputRef.current) inputRef.current.textContent = ''
setCommands([])
void window.moho
.rpc('runDiscordCommand', { bufferId: buffer!.id, command: command.command, options })
.catch((e: Error) => store.toast('error', e.message))
}
/** Reads the "@..." at the caret, so the list follows what is being typed. */
const noteMention = (): void => {
const selection = window.getSelection()
@@ -373,6 +461,35 @@ export function Composer(): JSX.Element | null {
put it, and the only place it can go without covering what is being
typed. Members first, then roles, then the service's whole-room
words, because that is the order of how often each is meant. */}
{/* The commands a channel offers, in the same place and with the same
keys as the name picker above the box - two lists that behave
differently would be two things to learn. */}
{commands.length > 0 && (
<div className="mention-picker" role="listbox" aria-label="Run a command">
{commands.map((c, i) => (
<button
key={c.id}
type="button"
role="option"
aria-selected={i === commandIndex}
className={classes('mention-option', i === commandIndex && 'active')}
onMouseDown={(e) => {
e.preventDefault()
runCommand(c)
}}
onMouseEnter={() => setCommandIndex(i)}
>
<span className="mention-glyph">
<Icon name="terminal" size={16} />
</span>
<span className="ellipsis">/{c.name}</span>
{c.description && <span className="small muted ellipsis mention-detail">{c.description}</span>}
{c.application && <span className="small muted">{c.application}</span>}
</button>
))}
</div>
)}
{mention && suggestions.length > 0 && (
<div className="mention-picker" role="listbox" aria-label="Tag somebody">
{suggestions.map((target, i) => (
@@ -490,6 +607,24 @@ export function Composer(): JSX.Element | null {
// While the mention list is up it owns the keys somebody is
// already using to drive it - a list you steer with the arrows
// and take with Enter is one nobody has to be taught.
if (commands.length > 0) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
const step = e.key === 'ArrowDown' ? 1 : commands.length - 1
setCommandIndex((commandIndex + step) % commands.length)
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
runCommand(commands[commandIndex])
return
}
if (e.key === 'Escape') {
e.preventDefault()
setCommands([])
return
}
}
if (mention && suggestions.length > 0) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
+97 -1
View File
@@ -8,7 +8,7 @@ import { RichText } from '../lib/richtext'
import { useChat, usePref, useStore } from '../state/hooks'
import { useSniffedTypes, sniffUrl } from '../lib/sniff'
import type { ChatMessage } from '../state/store'
import type { MessageBadge, MessageReader } from '../../../shared/wire'
import type { MessageBadge, MessageComponent, MessageReader } from '../../../shared/wire'
import {
embedColor,
extractCodeBlocks,
@@ -659,6 +659,18 @@ export function MessageRow({
</div>
)}
{/* What the message is for, on a great many bot messages: the
prose is a caption and the button is the thing. Above the
reactions because it belongs to the message rather than being a
response to it. */}
{(message.components?.length ?? 0) > 0 && (
<MessageControls
components={message.components!}
bufferId={bufferId}
messageId={message.id}
/>
)}
{(message.reactions?.length ?? 0) > 0 && (
<div className="reaction-row">
{message.reactions!.map((r) => (
@@ -825,3 +837,87 @@ function moderationEntries(
}
return out
}
/**
* The buttons and menus a service put on a message.
*
* Drawn in the rows they were laid out in, because that is information: a bot
* that put five buttons on one row and one underneath meant the last one to
* stand apart, and flattening them into a line loses which is which.
*
* A link button is a link and nothing else - it opens a page and tells nobody
* it was pressed - so it is drawn as one rather than sent back to the service.
*/
function MessageControls({
components,
bufferId,
messageId
}: {
components: MessageComponent[]
bufferId: string
messageId: string
}): JSX.Element {
const store = useStore()
const rows = new Map<number, MessageComponent[]>()
for (const c of components) {
const row = c.row ?? 0
rows.set(row, [...(rows.get(row) ?? []), c])
}
return (
<div className="component-rows">
{[...rows.entries()]
.sort((a, b) => a[0] - b[0])
.map(([row, controls]) => (
<div key={row} className="component-row">
{controls.map((c, i) =>
c.kind === 'select' ? (
<select
key={c.customId ?? i}
className="component-select"
defaultValue=""
disabled={c.disabled}
onChange={(e) => {
if (!c.customId || !e.target.value) return
store.useComponent(bufferId, messageId, c.customId, true, [e.target.value])
}}
>
<option value="" disabled>
{c.placeholder || 'Choose…'}
</option>
{(c.options ?? []).map((o) => (
<option key={o.value} value={o.value} title={o.description}>
{o.label}
</option>
))}
</select>
) : c.style === 'link' && c.url ? (
<a
key={c.url}
className="component-button link"
href={c.url}
target="_blank"
rel="noreferrer"
>
{c.emoji && <ReactionEmoji emoji={c.emoji} />}
<span className="ellipsis">{c.label || c.url}</span>
<Icon name="open_in_new" size={13} />
</a>
) : (
<button
key={c.customId ?? i}
type="button"
className={classes('component-button', c.style)}
disabled={c.disabled || !c.customId}
onClick={() => c.customId && store.useComponent(bufferId, messageId, c.customId, false, [])}
>
{c.emoji && <ReactionEmoji emoji={c.emoji} />}
<span className="ellipsis">{c.label || 'Press'}</span>
</button>
)
)}
</div>
))}
</div>
)
}
+22
View File
@@ -1563,6 +1563,19 @@ export class ChatStore {
.catch((e: Error) => this.toast('error', e.message))
}
/**
* Presses a button on a message, or answers one of its menus.
*
* Nothing is echoed here. Whatever the bot does about it arrives the way
* anything else does - a message, or an edit to the one that was pressed -
* and guessing at it would mean showing an answer that never came.
*/
useComponent(bufferId: string, messageId: string, customId: string, select: boolean, values: string[]): void {
void window.moho
.rpc('useDiscordComponent', { bufferId, messageId, customId, select, values })
.catch((e: Error) => this.toast('error', e.message))
}
/** Follows a Kick channel, or stops. The header reads the answer back. */
setFollowing(bufferId: string, follow: boolean): void {
void window.moho
@@ -1579,6 +1592,15 @@ export class ChatStore {
if (!data.isOwn || !this.reconcileOwnEcho(data)) this.appendMessage(data.bufferId, data)
break
// Buttons and menus, which arrive a moment after the message they are
// on: the row has to exist before they can be written onto it.
case 'messageComponents':
this.mapMessage(data.bufferId as string, data.messageId as string, (m) => ({
...m,
components: data.components as ChatMessage['components']
}))
break
case 'messageUpdated':
// `edited` comes from the event rather than being hardcoded true: a
// backend-internal body rewrite (Sneedchat swapping in a Tor-fetched
+26
View File
@@ -305,6 +305,24 @@ export interface Reaction {
animated?: boolean
}
/** One control on a message: a button, or a menu to pick from. */
export interface MessageComponent {
kind: 'button' | 'select'
/** What the service calls it, sent back when it is used. Links have none. */
customId?: string
label?: string
/** primary | secondary | success | danger | link */
style?: string
/** Where a link button goes, instead of telling anybody it was pressed. */
url?: string
disabled?: boolean
emoji?: string
options?: { value: string; label: string; description?: string }[]
placeholder?: string
/** Which row it was laid out on. */
row?: number
}
export interface Embed {
title?: string
description?: string
@@ -364,6 +382,14 @@ export interface Message {
* the plain-text fallback and is what search reads.
*/
html?: string
/**
* Buttons and menus under the message, where the service has them.
*
* Discord calls these components, and on a great many servers they are the
* message: a bot posts a line of prose and the thing it is for is the
* button below it.
*/
components?: MessageComponent[]
/** Matrix only - the sender's full MXID, for targeting moderation actions. */
senderId?: string
/**