Render command invocations from parsed content

This commit is contained in:
Coracle-Bot 2026-09-09 01:09:24 +00:00 committed by hodlbod
parent 7eb1c8c141
commit 4992b05ef1
4 changed files with 56 additions and 21 deletions

View file

@ -78,17 +78,46 @@ export const getCommandsForTarget = (target: CommandScopeTarget) =>
.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)),
// A space's definitions are the same for everything rendered in it, so one store per url is
// shared rather than every message deriving its own. Subscribing is also what pulls them, so
// reading a space loads its definitions whether or not anything is being composed.
const commandsByUrl = new Map<string, Readable<CommandReader[]>>()
const deriveCommandsForUrl = (url: string): Readable<CommandReader[]> => {
const cached = commandsByUrl.get(url)
if (cached) {
return cached
}
const store = fromApp($app => {
const plugin = $app.use(Commands)
if (url) {
plugin.ensureLoaded(url)
}
return plugin.forUrl(url).$
})
commandsByUrl.set(url, store)
return store
}
// The definitions a piece of content is read against: the ones its space publishes that also
// scope to it. Content outside a space has no url, and so no commands.
export const deriveValidCommands = (target: CommandScopeTarget): Readable<CommandReader[]> =>
derived(deriveCommandsForUrl(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.
// than one here is what tells a composer to disambiguate. Takes an invocation from either
// side: the one a composer is typing, or the one a message was parsed into.
export const getCommandsForInvocation = (
available: CommandReader[],
invocation: CommandInvocation,
invocation: Pick<CommandInvocation, "command" | "pubkey">,
) =>
available.filter(
command =>

View file

@ -5,7 +5,7 @@
import {slide} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import {deriveCommandsForTarget, describeCommandDraft} from "@app/commands"
import {deriveValidCommands, describeCommandDraft} from "@app/commands"
type Props = {
target: CommandScopeTarget
@ -15,7 +15,7 @@
const {target, content, insert}: Props = $props()
const available = deriveCommandsForTarget(target)
const available = deriveValidCommands(target)
const draft = $derived(describeCommandDraft($available, content))

View file

@ -9,6 +9,7 @@
isEmoji,
isTopic,
isCode,
isCommand,
isCashu,
isInvoice,
isLink,
@ -39,7 +40,7 @@
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 {deriveValidCommands, getCommandsForInvocation} from "@app/commands"
import {userSettingsValues} from "@app/settings"
type Props = {
@ -63,18 +64,16 @@
}: Props = $props()
// 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({
// definitions the space it was written in publishes. Anything else — including a command
// nobody here answers to — falls through and renders as the text it is.
const available = deriveValidCommands({
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 fullContent = $derived(parse(event))
const expand = () => {
showEntire = true
@ -175,11 +174,15 @@
<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)}
{#if isCommand(parsed)}
{@const command = getCommandsForInvocation($available, parsed.value)[0]}
{#if command}
<ContentCommand {command} value={parsed.value} />
{:else}
{@html renderAsHtml(parsed)}
{/if}
{:else if isNewline(parsed) && !isBlock(i - 1)}
<ContentNewline value={parsed.value} />
{:else if isTopic(parsed)}
<ContentTopic value={parsed.value} />

View file

@ -1,13 +1,16 @@
<script lang="ts">
import type {ParsedCommandValue} from "@welshman/content"
import type {CommandReader} from "@welshman/domain"
import ProfileName from "@app/components/ProfileName.svelte"
type Props = {
command: CommandReader
value: ParsedCommandValue
}
const {command}: Props = $props()
const {command, value}: Props = $props()
</script>
<span class="tiptap-object tip tip-top" data-tip={command.description()}>
/{command.command()}
/{command.command()}{#if value.pubkey}@<ProfileName pubkey={value.pubkey} />{/if}
</span>