A window for the form a bot asks for

Drawn from what the bot sent rather than from a template: its labels,
its placeholders, which boxes are one line and which are several, and
which may be left empty. A client that decided any of that itself would
be filling in somebody else's form.

Refused sends keep what was typed on screen, because retyping a form is
the worst way to learn that a field was too long.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-06 23:23:34 -04:00
co-authored by Claude Opus 5
parent 99e48588ac
commit 6ea68c3d42
5 changed files with 224 additions and 1 deletions
+1 -1
Submodule nobilis updated: f027f5388e...2aee7a23bf
+4
View File
@@ -22,6 +22,7 @@ import { Toasts } from './components/Toasts'
import { IncomingCallPanel } from './components/IncomingCallPanel'
import { CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage'
import { StreamStage } from './components/StreamStage'
import { DiscordModal } from './components/DiscordModal'
import { FileDrop } from './components/FileDrop'
import { TransferPanel } from './components/TransferPanel'
import { Icon, IconButton } from './components/Icon'
@@ -219,6 +220,9 @@ export default function App(): JSX.Element {
than by the daemon - the media is the window's. */}
<IncomingMatrixCall />
<ScreenPicker />
{/* A form a Discord bot asked for. Over everything, because it is the
answer to something just pressed and it expires. */}
<DiscordModal />
{/* Where a call goes when you walk away from it: a corner of its own,
the way a phone keeps the picture in the corner. In the conversation
it belongs to it sits above the log instead - see MessageList - so
+30
View File
@@ -3545,6 +3545,36 @@ img.smilie {
object-fit: contain;
}
/* A form a bot asked for: a title, its boxes, and a send. Sized by what it
holds rather than fixed, since a form is one field as often as five. */
.discord-modal {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
width: min(520px, 92vw);
max-height: 80vh;
overflow-y: auto;
margin: auto;
padding: var(--spacing-m);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius);
background: var(--surface-container-high);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
}
.discord-modal-head {
display: flex;
align-items: center;
gap: var(--spacing-s);
font-weight: 500;
}
.discord-modal-field {
display: flex;
flex-direction: column;
gap: var(--spacing-xxs);
}
/*
* A sentence somebody writes before something happens: a knock's reason, a
* report's. The text is the point rather than a note attached to a button, so
@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from './Icon'
import { useChat, useStore } from '../state/hooks'
/**
* A form a bot asked for.
*
* Discord's third answer to a slash command, after a message and a row of
* buttons: a modal, which is a handful of text boxes and a send. Until this
* existed those commands appeared to do nothing at all - the form arrived,
* was dropped, and the person was left looking at a channel where they had
* pressed something and nothing had happened.
*
* Drawn from what the bot sent rather than from a template: the labels, the
* placeholders, which boxes are one line and which are several, and which may
* be left empty are all its own, and a client that decided any of them for
* itself would be filling in a form on somebody else's behalf.
*/
export function DiscordModal(): JSX.Element | null {
const store = useStore()
const modal = useChat((s) => s.discordModal)
const [values, setValues] = useState<Record<string, string>>({})
const [sending, setSending] = useState(false)
const first = useRef<HTMLTextAreaElement | HTMLInputElement>(null)
// A new form starts empty, except where the bot filled something in.
useEffect(() => {
if (!modal) return
const start: Record<string, string> = {}
for (const field of modal.fields) start[field.customId] = field.value ?? ''
setValues(start)
setSending(false)
first.current?.focus()
}, [modal])
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') store.closeDiscordModal()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [store])
if (!modal) return null
// What the bot said it needs. Checked here so the button says so before it
// is pressed, rather than the send coming back refused.
const missing = modal.fields.filter(
(field) => field.required !== false && !(values[field.customId] ?? '').trim()
)
const tooLong = modal.fields.filter(
(field) => field.maxLength && (values[field.customId] ?? '').length > field.maxLength
)
const ready = missing.length === 0 && tooLong.length === 0
const send = (): void => {
if (!ready || sending) return
setSending(true)
void store.submitDiscordModal(values)
}
return createPortal(
<div className="lightbox-backdrop" onClick={() => store.closeDiscordModal()}>
<div className="discord-modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="discord-modal-head">
<Icon name="edit_note" size={18} />
<span>{modal.title}</span>
</div>
{modal.fields.map((field, i) => {
const value = values[field.customId] ?? ''
const set = (next: string): void =>
setValues((all) => ({ ...all, [field.customId]: next }))
return (
<label key={field.customId} className="discord-modal-field">
<span className="small muted">
{field.label || field.customId}
{field.required === false && ' · optional'}
{field.maxLength ? ` · ${value.length}/${field.maxLength}` : ''}
</span>
{field.long ? (
<textarea
ref={i === 0 ? (first as React.RefObject<HTMLTextAreaElement>) : undefined}
className="reason-prompt-box"
rows={4}
value={value}
placeholder={field.placeholder}
onChange={(e) => set(e.target.value)}
/>
) : (
<input
ref={i === 0 ? (first as React.RefObject<HTMLInputElement>) : undefined}
className="text-field"
value={value}
placeholder={field.placeholder}
onChange={(e) => set(e.target.value)}
onKeyDown={(e) => {
// One line means Enter finishes it, which is what a
// single box in a form is for.
if (e.key === 'Enter') send()
}}
/>
)}
</label>
)
})}
<div className="reason-prompt-actions">
<button type="button" className="button" onClick={() => store.closeDiscordModal()}>
Cancel
</button>
<button type="button" className="button" disabled={!ready || sending} onClick={send}>
{sending ? 'Sending…' : 'Send'}
</button>
</div>
</div>
</div>,
document.body
)
}
+68
View File
@@ -259,6 +259,29 @@ export interface KickStream {
playbackUrl?: string | null
}
/** One box on a form a Discord bot sent. */
export interface DiscordModalField {
customId: string
label: string
/** Several lines rather than one - Discord's "paragraph" style. */
long: boolean
placeholder?: string
value?: string
required?: boolean
minLength?: number | null
maxLength?: number | null
}
export interface DiscordModal {
accountId: string
bufferId: string
id?: string
customId: string
applicationId?: string
title: string
fields: DiscordModalField[]
}
export type ActivePanel = '' | 'accounts' | 'settings' | 'join' | 'downloads'
export interface ChatState {
@@ -444,6 +467,13 @@ export interface ChatState {
watching: { bufferId: string; accountId: string; title: string } | null
/** The stream put away without being stopped. */
watchMinimized: boolean
/**
* A form a Discord bot asked for, while it is being filled in.
*
* One at a time: a modal is the answer to something just pressed, and a
* second arriving while one is open is a bot talking over itself.
*/
discordModal: DiscordModal | null
/** The screens and windows on offer, while somebody is choosing one. */
screenSources: { id: string; name: string; thumbnail: string }[] | null
activeCall: {
@@ -543,6 +573,7 @@ const INITIAL: ChatState = {
watching: null,
watchMinimized: false,
screenSources: null,
discordModal: null,
activeCall: null,
matrixIgnored: {},
matrixInvites: {},
@@ -1832,6 +1863,37 @@ export class ChatStore {
return group?.sfu ? group.sfu.arriving() : []
}
closeDiscordModal(): void {
this.set({ discordModal: null })
}
/**
* Sends a filled-in form back to the bot that asked for it.
*
* Closed on success rather than on sending: a refusal is worth showing
* with what was typed still on screen, since retyping a form somebody has
* just filled in is the worst way to learn a field was too long.
*/
async submitDiscordModal(values: Record<string, string>): Promise<void> {
const modal = this.state.discordModal
if (!modal) return
try {
await window.moho.rpc('submitDiscordModal', {
bufferId: modal.bufferId,
customId: modal.customId,
applicationId: modal.applicationId,
id: modal.id,
values: modal.fields.map((field) => ({
customId: field.customId,
value: values[field.customId] ?? ''
}))
})
this.set({ discordModal: null })
} catch (e) {
this.toast('error', (e as Error).message)
}
}
hangUpMatrixCall(): void {
// Whichever kind is up: leaving a room's call withdraws the membership
// that says this end is in it, as well as hanging up on everybody.
@@ -2161,6 +2223,12 @@ export class ChatStore {
// stopped - so a list replaces, and a single nick is merged in.
// Somebody joined or left a room's call. Both the offer of a call to
// join and, while in one, the signal to meet whoever just arrived.
// A bot answering a slash command with a form to fill in.
case 'discordModal': {
this.set({ discordModal: data as unknown as DiscordModal })
break
}
// Somebody's media key, for a call whose frames the server cannot read.
case 'matrixCallKey': {
void this.matrixCalls.takeKey(data.userId, data.deviceId, data.key, data.index ?? 0)