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:
+1
-1
Submodule nobilis updated: 9577b117c5...af11ded923
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user