Add slash commands

This commit is contained in:
Jon Staab 2026-09-07 09:48:36 -07:00
parent 801568d590
commit 17467808ca
9 changed files with 440 additions and 13 deletions

150
src/app/commands.ts Normal file
View file

@ -0,0 +1,150 @@
import {derived} from "svelte/store"
import type {Readable} from "svelte/store"
import {
Address,
COMMAND,
bindCommandArgs,
getActiveCommandArgIndex,
parseCommandInvocation,
} from "@welshman/util"
import type {
CommandArg,
CommandArgBinding,
CommandInvocation,
CommandScopeTarget,
} from "@welshman/util"
import {Command} from "@welshman/domain"
import type {CommandReader} from "@welshman/domain"
import {Domain, Network, RelayScopedDerivedPlugin, createSearch, projectFrom} from "@welshman/app"
import type {IApp, Projection} from "@welshman/app"
import {fromApp, usePlugin} from "@app/core"
// NIP-CD definitions count where they're published, since a space curates its own command
// namespace by controlling who may write to it. `|` never appears in a url, and keeps these
// distinct from the `'`-separated room keys.
const makeCommandKey = (url: string, address: string) => `${url}|${address}`
const splitCommandKey = (key: string): [string, string] => {
const i = key.indexOf("|")
return [key.slice(0, i), key.slice(i + 1)]
}
export class Commands extends RelayScopedDerivedPlugin<CommandReader> {
// Definitions are replaceable, so one pull per space keeps the repository current. The set
// lives on the plugin rather than the module so logging in, which swaps the app and its
// repository, starts over.
private pulled = new Set<string>()
constructor(app: IApp) {
super(app, {
filters: [{kinds: [COMMAND]}],
eventToItem: app.use(Domain).reader(Command),
getKey: (command, url) => makeCommandKey(url, command.address()),
})
}
fetch(key: string) {
const [url, address] = splitCommandKey(key)
const {pubkey, identifier} = Address.from(address)
return this.app
.use(Network)
.load({relays: [url], filters: [{kinds: [COMMAND], authors: [pubkey], "#d": [identifier]}]})
}
ensureLoaded = (url: string) => {
if (!this.pulled.has(url)) {
this.pulled.add(url)
this.app.use(Network).load({relays: [url], filters: [{kinds: [COMMAND]}]})
}
}
forUrl = (url: string): Projection<CommandReader[]> =>
projectFrom(this.index, byKey =>
Array.from(byKey.entries())
.filter(([key]) => key.startsWith(`${url}|`))
.map(([, command]) => command),
)
}
export const commands = usePlugin(Commands)
// The definitions an executor is listening for, given the event that would be written.
export const getCommandsForTarget = (target: CommandScopeTarget) =>
commands
.get()
.forUrl(target.url ?? "")
.get()
.filter(command => command.matches(target))
export const deriveCommandsForTarget = (target: CommandScopeTarget): Readable<CommandReader[]> =>
derived(
fromApp($app => $app.use(Commands).forUrl(target.url ?? "").$),
$commands => $commands.filter(command => command.matches(target)),
)
// Without a qualifier an invocation targets every definition whose trigger matches, so more
// than one here is what tells a composer to disambiguate.
export const getCommandsForInvocation = (
available: CommandReader[],
invocation: CommandInvocation,
) =>
available.filter(
command =>
command.command() === invocation.command &&
(!invocation.pubkey || command.author() === invocation.pubkey),
)
export const createCommandSearch = (target: CommandScopeTarget) =>
createSearch(
getCommandsForTarget(target).map(command => ({
address: command.address(),
command: command.command() ?? "",
title: command.title() ?? "",
description: command.description() ?? "",
})),
{
getValue: option => option.address,
fuseOptions: {keys: ["command", "title"], threshold: 0.3},
},
)
export const getCommandByAddress = (url: string, address: string) =>
commands.get().get(makeCommandKey(url, address))
// An invocation in progress, as the composer understands it. `matches` holds every definition
// the text currently targets, so an ambiguous trigger is visible rather than guessed at, and
// `command` is the one whose arguments shape the input.
export type CommandDraft = {
invocation: CommandInvocation
command: CommandReader
matches: CommandReader[]
args: CommandArg[]
// One entry per argument the text supplies, in order, so an argument already typed can be
// shown as bound or as wrong while the rest are still being entered
bindings: CommandArgBinding[]
activeIndex: number
}
export const describeCommandDraft = (
available: CommandReader[],
content: string,
): CommandDraft | undefined => {
const invocation = parseCommandInvocation(content)
const matches = invocation ? getCommandsForInvocation(available, invocation) : []
const command = matches[0]
if (invocation && command) {
const args = command.args()
return {
invocation,
command,
matches,
args,
bindings: bindCommandArgs(args, invocation.rest),
activeIndex: getActiveCommandArgIndex(args, invocation.rest),
}
}
}

View file

@ -0,0 +1,86 @@
<script lang="ts">
import cx from "classnames"
import {Pubkey} from "@welshman/util"
import type {CommandArg, CommandScopeTarget} from "@welshman/util"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import {deriveCommandsForTarget, describeCommandDraft} from "@app/commands"
type Props = {
target: CommandScopeTarget
content: string
insert: (token: string) => void
}
const {target, content, insert}: Props = $props()
const available = deriveCommandsForTarget(target)
const draft = $derived(describeCommandDraft($available, content))
// Without a qualifier an invocation reaches every executor whose trigger matches, so offer
// the choice rather than picking one silently.
const ambiguous = $derived((draft?.matches.length ?? 0) > 1 && !draft?.invocation.pubkey)
const activeArg = $derived(draft?.args[draft.activeIndex])
const choices = $derived.by(() => {
if (activeArg?.type === "enum") return activeArg.choices
if (activeArg?.type === "bool") return ["true", "false"]
return []
})
// An argument the user has moved past that didn't parse. The one being typed is legitimately
// incomplete, so flagging it would just blink red on every keystroke.
const invalid = $derived(
draft?.bindings.find((binding, i) => i < draft.activeIndex && !binding.value)?.arg,
)
const displayArg = (arg: CommandArg) => (arg.required ? `<${arg.name}>` : `[${arg.name}]`)
const qualify = (pubkey: string) => insert(`@${new Pubkey(pubkey).toNpub()}`)
</script>
{#if draft}
<div class="flex flex-col gap-1 px-1 pb-1 text-sm">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span class="font-mono">
<span class="text-primary">/{draft.command.command()}</span>
{#each draft.args as arg, i (arg.name)}
<span
class={cx("ml-1", {
"text-error": arg === invalid,
"text-primary font-bold": arg !== invalid && i === draft.activeIndex,
"opacity-50": arg !== invalid && i !== draft.activeIndex,
})}>
{displayArg(arg)}
</span>
{/each}
</span>
{#if invalid}
<span class="text-error">That doesn't look like a {invalid.type}</span>
{:else}
<span class="opacity-75">{activeArg?.label || draft.command.description()}</span>
{/if}
</div>
{#if ambiguous}
<div class="flex flex-wrap items-center gap-2">
<span class="opacity-75">Several bots answer this — pick one:</span>
{#each draft.matches as match (match.address())}
<Button class="button button-sm button-neutral" onclick={() => qualify(match.author())}>
@<ProfileName pubkey={match.author()} url={target.url} />
</Button>
{/each}
</div>
{:else if choices.length > 0}
<div class="flex flex-wrap items-center gap-2">
{#each choices as choice (choice)}
<Button class="button button-sm button-neutral" onclick={() => insert(choice)}>
{choice}
</Button>
{/each}
</div>
{/if}
</div>
{/if}

View file

@ -35,9 +35,11 @@
import ContentNewline from "@app/components/ContentNewline.svelte"
import ContentQuote from "@app/components/ContentQuote.svelte"
import ContentTopic from "@app/components/ContentTopic.svelte"
import ContentCommand from "@app/components/ContentCommand.svelte"
import ContentMention from "@app/components/ContentMention.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {makeRoomPath} from "@app/routes"
import {deriveCommandsForTarget, describeCommandDraft} from "@app/commands"
import {userSettingsValues} from "@app/settings"
type Props = {
@ -60,7 +62,19 @@
url,
}: Props = $props()
const fullContent = $derived(parse(event))
// An invocation is plain text and carries no tags, so it's only recognizable against the
// definitions loaded for this space. Anything else — including a command nobody here
// publishes — falls through and renders as the text it is.
const available = deriveCommandsForTarget({
url,
kind: event.kind,
pubkey: event.pubkey,
tags: event.tags,
})
const draft = $derived(describeCommandDraft($available, event.content))
const fullContent = $derived(parse(draft ? {...event, content: draft.invocation.rest} : event))
const expand = () => {
showEntire = true
@ -161,6 +175,9 @@
<div
class="overflow-hidden text-ellipsis wrap-break-word"
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
{#if draft}
<ContentCommand command={draft.command} />
{/if}
{#each shortContent as parsed, i (i)}
{#if isNewline(parsed) && !isBlock(i - 1)}
<ContentNewline value={parsed.value} />

View file

@ -0,0 +1,13 @@
<script lang="ts">
import type {CommandReader} from "@welshman/domain"
type Props = {
command: CommandReader
}
const {command}: Props = $props()
</script>
<span class="tiptap-object tip tip-top" data-tip={command.description()}>
/{command.command()}
</span>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import {writable} from "svelte/store"
import type {Maybe} from "@welshman/lib"
import {MESSAGE} from "@welshman/util"
import type {EventContent} from "@welshman/util"
import {escapeHtml, isMobile, preventDefault} from "@lib/html"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
@ -13,8 +14,10 @@
import Spinner from "@lib/components/Spinner.svelte"
import ComposeMenu from "@app/components/ComposeMenu.svelte"
import DictationButton from "@app/components/DictationButton.svelte"
import CommandArgBar from "@app/components/CommandArgBar.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {app} from "@app/core"
import {DraftKey, type Draft} from "@app/drafts"
import type {Share} from "@app/share"
import {onDestroy, onMount} from "svelte"
@ -37,6 +40,15 @@
const uploading = writable(false)
const text = writable("")
const commandTarget = {
url,
kind: MESSAGE,
pubkey: $app.user?.pubkey,
tags: h ? [["h", h]] : [],
}
export const focus = () => editor.then(ed => ed.chain().focus().run())
export const canEnterEditPrevious = () =>
@ -56,10 +68,21 @@
// Tiptap parses a string handed to insertContent as html, so an angle bracket in the
// transcript would eat the rest of the sentence.
const insertTranscript = async (text: string) => {
const insertTranscript = async (transcript: string) => {
const ed = await editor
ed.chain().focus().insertContent(escapeHtml(text)).run()
ed.chain().focus().insertContent(escapeHtml(transcript)).run()
}
// Argument tokens are whitespace-delimited, so separate one from whatever precedes it.
const insertCommandToken = async (token: string) => {
const ed = await editor
const separator = /\s$/.test($text) ? "" : " "
ed.chain()
.focus()
.insertContent(escapeHtml(separator + token))
.run()
}
const showPopover = () => tippy?.show()
@ -96,8 +119,10 @@
content,
empty,
submit,
text,
uploading,
onChange,
commandTarget,
aggressive: true,
})
@ -126,6 +151,7 @@
})
</script>
<CommandArgBar target={commandTarget} content={$text} insert={insertCommandToken} />
<form class="relative flex gap-2 py-2" onsubmit={preventDefault(submit)}>
<div class="join">
<Button

View file

@ -0,0 +1,19 @@
import type {NodeViewRendererProps} from "@tiptap/core"
export const CommandNodeView = ({node}: NodeViewRendererProps) => {
const dom = document.createElement("span")
const command = typeof node.attrs.command === "string" ? node.attrs.command : ""
dom.classList.add("tiptap-object")
dom.textContent = `/${command}`
return {
dom,
selectNode() {
dom.classList.add("tiptap-active")
},
deselectNode() {
dom.classList.remove("tiptap-active")
},
}
}

View file

@ -0,0 +1,31 @@
<script lang="ts">
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import {getCommandByAddress} from "@app/commands"
type Props = {
value: string
url: string
}
const {value, url}: Props = $props()
const command = getCommandByAddress(url, value)
</script>
<div class="flex max-w-full gap-3">
<div class="py-1">
<ProfileCircle pubkey={command?.author() ?? ""} {url} />
</div>
<div class="flex min-w-0 flex-col">
<div class="flex items-center gap-2">
<span class="text-bold">/{command?.command()}</span>
<span class="overflow-hidden text-ellipsis text-sm opacity-75">
@<ProfileName pubkey={command?.author() ?? ""} {url} />
</span>
</div>
<div class="overflow-hidden text-ellipsis text-sm opacity-75">
{command?.description()}
</div>
</div>
</div>

View file

@ -7,14 +7,25 @@ import {createSearch, splitRoomKey} from "@welshman/app"
import type {Room} from "@welshman/app"
import type {FileAttributes} from "@welshman/editor"
import {
CommandExtension,
CommandSuggestion,
Editor,
MentionSuggestion,
TippySuggestion,
WelshmanExtension,
editorProps,
} from "@welshman/editor"
import type {CommandScopeTarget} from "@welshman/util"
import {escapeHtml} from "@lib/html"
import {profiles, relayLists, relayMemberLists, rooms} from "@app/core"
import {
commands,
createCommandSearch,
getCommandByAddress,
getCommandsForTarget,
} from "@app/commands"
import {CommandNodeView} from "@app/editor/CommandNodeView"
import CommandSuggestionItem from "@app/editor/CommandSuggestion.svelte"
import {MentionNodeView} from "@app/editor/MentionNodeView"
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension"
@ -37,8 +48,10 @@ export const makeEditor = async ({
placeholder = "",
url,
submit,
text,
uploading,
wordCount,
commandTarget,
}: {
encryptFiles?: boolean
aggressive?: boolean
@ -49,8 +62,11 @@ export const makeEditor = async ({
placeholder?: string
url?: string
submit: () => void
text?: Writable<string>
uploading?: Writable<boolean>
wordCount?: Writable<number>
// When given, `/` at the start of the composer suggests commands scoped to this target
commandTarget?: CommandScopeTarget
}) => {
const searchProfiles = derived(
[profiles.get().profileSearch, throttled(800, relayMemberLists.get().forUrl(url ?? ""))],
@ -91,12 +107,38 @@ export const makeEditor = async ({
},
)
if (commandTarget?.url) {
commands.get().ensureLoaded(commandTarget.url)
}
const commandSearch = derived(throttled(800, commands.get().index.$), () =>
commandTarget ? createCommandSearch(commandTarget) : undefined,
)
// The spec prefers a bare trigger, so qualify with the executor's pubkey only where the
// trigger would otherwise reach more than one of them.
const getCommandAttributes = (address: string) => {
if (commandTarget) {
const command = getCommandByAddress(commandTarget.url ?? "", address)
if (command) {
const trigger = command.command() ?? ""
const matches = getCommandsForTarget(commandTarget).filter(
option => option.command() === trigger,
)
return {command: trigger, pubkey: matches.length > 1 ? command.author() : undefined}
}
}
}
const ed = new Editor({
content: typeof content === "string" ? escapeHtml(content) : content,
editorProps,
element: document.createElement("div"),
extensions: [
RoomReferenceExtension,
CommandExtension.extend({addNodeView: () => CommandNodeView}),
WelshmanExtension.configure({
submit,
extensions: {
@ -185,6 +227,23 @@ export const makeEditor = async ({
return target
},
}),
...(commandTarget
? [
CommandSuggestion({
editor: (this as any).editor,
search: (term: string) => get(commandSearch)?.searchValues(term) ?? [],
updateSignal: commandSearch,
getAttributes: getCommandAttributes,
createSuggestion: (value: string) => {
const target = document.createElement("div")
mount(CommandSuggestionItem, {target, props: {value, url: url ?? ""}})
return target
},
}),
]
: []),
]
},
},
@ -194,6 +253,7 @@ export const makeEditor = async ({
NativeClipboardPasteExtension,
],
onUpdate({editor}) {
text?.set(editor.getText({blockSeparator: "\n"}))
wordCount?.set(editor.storage.wordCount.words)
charCount?.set(editor.storage.wordCount.chars)
empty?.set(isEmpty(editor))
@ -206,6 +266,7 @@ export const makeEditor = async ({
// clear their draft when this reads true, so it has to be set before they render: keep every
// await in this function inside a callback, below the constructor.
empty?.set(isEmpty(ed))
text?.set(ed.getText({blockSeparator: "\n"}))
return ed
}

View file

@ -3,7 +3,7 @@ import type {Unsubscriber} from "svelte/store"
import {deleteDB} from "idb"
import {SecureStorage} from "@aparajita/capacitor-secure-storage"
import {Preferences} from "@capacitor/preferences"
import {noop, on, throttle, batch, call, makeQueue} from "@welshman/lib"
import {WEEK, ago, noop, now, on, throttle, batch, call, makeQueue} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {
ALERT_ANDROID,
@ -13,6 +13,7 @@ import {
ALERT_WEB,
APP_DATA,
BLOSSOM_SERVERS,
COMMAND,
FOLLOWS,
MESSAGING_RELAYS,
MUTES,
@ -138,7 +139,7 @@ const idleWrite = <T>(f: (xs: T[]) => void): ((xs: T[]) => void) => {
const kinds = {
meta: [PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, MESSAGING_RELAYS, APP_DATA, ROOMS],
alert: [ALERT_STATUS, ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID],
space: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS, RELAY_JOIN, RELAY_LEAVE],
space: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS, RELAY_JOIN, RELAY_LEAVE, COMMAND],
room: [
ROOM_META,
ROOM_DELETE,
@ -169,7 +170,13 @@ const shouldPersistEvent = (event: TrustedEvent, pubkey: string) =>
isRelayScoped(event) ||
isConversation(event)
type EventItem = {id: string; event: TrustedEvent; relays: string[]}
type EventItem = {id: string; event: TrustedEvent; relays: string[]; cachedAt?: number}
// A command definition is only advisory, and a space's set drifts as bots come and go, so one
// we haven't seen republished in a week is dropped rather than offered as still available.
// Everything else is kept until it's superseded or deleted.
const isExpired = (item: EventItem) =>
item.event.kind === COMMAND && (item.cachedAt ?? now()) < ago(1, WEEK)
type PlaintextItem = {key: string; value: string}
@ -182,6 +189,7 @@ class Storage {
ready: Promise<void>
private db: IDB
private cachedAt = new Map<string, number>()
private unsubscribers: Unsubscriber[] = []
private timeouts: ReturnType<typeof setTimeout>[] = []
private stopped = false
@ -258,9 +266,11 @@ class Storage {
if (
item.event &&
shouldPersistEvent(item.event, this.pubkey) &&
(!isRelayScoped(item.event) || item.relays.length > 0)
(!isRelayScoped(item.event) || item.relays.length > 0) &&
!isExpired(item)
) {
item.event[verifiedSymbol] = true
this.cachedAt.set(item.id, item.cachedAt ?? now())
items.push(item)
} else {
stale.push(item.id)
@ -312,18 +322,27 @@ class Storage {
}
if (add.length > 0) {
const cachedAt = now()
// The ingest policy tracks an event before publishing it, so by the time the
// repository reports it, its provenance is already in the tracker
await table.bulkPut(
add.map(event => ({
id: event.id,
event,
relays: Array.from(this.app.tracker.getRelays(event.id)),
})),
add.map(event => {
this.cachedAt.set(event.id, cachedAt)
return {
id: event.id,
event,
relays: Array.from(this.app.tracker.getRelays(event.id)),
cachedAt,
}
}),
)
}
if (remove.size > 0) {
remove.forEach(id => this.cachedAt.delete(id))
await table.bulkDelete(remove)
}
}),
@ -343,7 +362,12 @@ class Storage {
// syncEvents persists its provenance along with the event itself. This pass only
// records relay changes for events we already have.
if (event && shouldPersistEvent(event, this.pubkey)) {
items.push({id, event, relays: Array.from(this.app.tracker.getRelays(id))})
items.push({
id,
event,
relays: Array.from(this.app.tracker.getRelays(id)),
cachedAt: this.cachedAt.get(id) ?? now(),
})
}
}