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:
+1
-1
Submodule nobilis updated: f027f5388e...2aee7a23bf
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user