diff --git a/nobilis b/nobilis index 9577b11..af11ded 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 9577b117c509ee451b59d768f9a2d1d927fa95c2 +Subproject commit af11ded9236ce9923e4b72eefb072eaa0a132088 diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index b362d27..0707182 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -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. * diff --git a/src/renderer/src/components/Composer.tsx b/src/renderer/src/components/Composer.tsx index 162454c..f76bdbe 100644 --- a/src/renderer/src/components/Composer.tsx +++ b/src/renderer/src/components/Composer.tsx @@ -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([]) + 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(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 && ( +
+ {commands.map((c, i) => ( + + ))} +
+ )} + {mention && suggestions.length > 0 && (
{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() diff --git a/src/renderer/src/components/MessageRow.tsx b/src/renderer/src/components/MessageRow.tsx index 711d51f..e218bc6 100644 --- a/src/renderer/src/components/MessageRow.tsx +++ b/src/renderer/src/components/MessageRow.tsx @@ -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({
)} + {/* 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 && ( + + )} + {(message.reactions?.length ?? 0) > 0 && (
{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() + for (const c of components) { + const row = c.row ?? 0 + rows.set(row, [...(rows.get(row) ?? []), c]) + } + + return ( +
+ {[...rows.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([row, controls]) => ( +
+ {controls.map((c, i) => + c.kind === 'select' ? ( + + ) : c.style === 'link' && c.url ? ( + + {c.emoji && } + {c.label || c.url} + + + ) : ( + + ) + )} +
+ ))} +
+ ) +} diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 876cb5f..fc9aad4 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -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 diff --git a/src/shared/wire.ts b/src/shared/wire.ts index a95ab9e..5102d30 100644 --- a/src/shared/wire.ts +++ b/src/shared/wire.ts @@ -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 /**