diff --git a/src/app/commands.ts b/src/app/commands.ts new file mode 100644 index 00000000..79634404 --- /dev/null +++ b/src/app/commands.ts @@ -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 { + // 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() + + 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 => + 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 => + 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), + } + } +} diff --git a/src/app/components/CommandArgBar.svelte b/src/app/components/CommandArgBar.svelte new file mode 100644 index 00000000..ad3cf373 --- /dev/null +++ b/src/app/components/CommandArgBar.svelte @@ -0,0 +1,86 @@ + + +{#if draft} +
+
+ + /{draft.command.command()} + {#each draft.args as arg, i (arg.name)} + + {displayArg(arg)} + + {/each} + + {#if invalid} + That doesn't look like a {invalid.type} + {:else} + {activeArg?.label || draft.command.description()} + {/if} +
+ {#if ambiguous} +
+ Several bots answer this — pick one: + {#each draft.matches as match (match.address())} + + {/each} +
+ {:else if choices.length > 0} +
+ {#each choices as choice (choice)} + + {/each} +
+ {/if} +
+{/if} diff --git a/src/app/components/Content.svelte b/src/app/components/Content.svelte index 1b471ab7..afe95c6b 100644 --- a/src/app/components/Content.svelte +++ b/src/app/components/Content.svelte @@ -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 @@
+ {#if draft} + + {/if} {#each shortContent as parsed, i (i)} {#if isNewline(parsed) && !isBlock(i - 1)} diff --git a/src/app/components/ContentCommand.svelte b/src/app/components/ContentCommand.svelte new file mode 100644 index 00000000..6b43c0ac --- /dev/null +++ b/src/app/components/ContentCommand.svelte @@ -0,0 +1,13 @@ + + + + /{command.command()} + diff --git a/src/app/components/RoomCompose.svelte b/src/app/components/RoomCompose.svelte index 6dae07a5..f9a44ec8 100644 --- a/src/app/components/RoomCompose.svelte +++ b/src/app/components/RoomCompose.svelte @@ -1,6 +1,7 @@ +