Add slash commands
This commit is contained in:
parent
801568d590
commit
17467808ca
9 changed files with 440 additions and 13 deletions
150
src/app/commands.ts
Normal file
150
src/app/commands.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/app/components/CommandArgBar.svelte
Normal file
86
src/app/components/CommandArgBar.svelte
Normal 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}
|
||||||
|
|
@ -35,9 +35,11 @@
|
||||||
import ContentNewline from "@app/components/ContentNewline.svelte"
|
import ContentNewline from "@app/components/ContentNewline.svelte"
|
||||||
import ContentQuote from "@app/components/ContentQuote.svelte"
|
import ContentQuote from "@app/components/ContentQuote.svelte"
|
||||||
import ContentTopic from "@app/components/ContentTopic.svelte"
|
import ContentTopic from "@app/components/ContentTopic.svelte"
|
||||||
|
import ContentCommand from "@app/components/ContentCommand.svelte"
|
||||||
import ContentMention from "@app/components/ContentMention.svelte"
|
import ContentMention from "@app/components/ContentMention.svelte"
|
||||||
import RoomName from "@app/components/RoomName.svelte"
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
import {makeRoomPath} from "@app/routes"
|
import {makeRoomPath} from "@app/routes"
|
||||||
|
import {deriveCommandsForTarget, describeCommandDraft} from "@app/commands"
|
||||||
import {userSettingsValues} from "@app/settings"
|
import {userSettingsValues} from "@app/settings"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -60,7 +62,19 @@
|
||||||
url,
|
url,
|
||||||
}: Props = $props()
|
}: 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 = () => {
|
const expand = () => {
|
||||||
showEntire = true
|
showEntire = true
|
||||||
|
|
@ -161,6 +175,9 @@
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden text-ellipsis wrap-break-word"
|
class="overflow-hidden text-ellipsis wrap-break-word"
|
||||||
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
|
style={expandBlock ? "mask-image: linear-gradient(0deg, transparent 0px, black 100px)" : ""}>
|
||||||
|
{#if draft}
|
||||||
|
<ContentCommand command={draft.command} />
|
||||||
|
{/if}
|
||||||
{#each shortContent as parsed, i (i)}
|
{#each shortContent as parsed, i (i)}
|
||||||
{#if isNewline(parsed) && !isBlock(i - 1)}
|
{#if isNewline(parsed) && !isBlock(i - 1)}
|
||||||
<ContentNewline value={parsed.value} />
|
<ContentNewline value={parsed.value} />
|
||||||
|
|
|
||||||
13
src/app/components/ContentCommand.svelte
Normal file
13
src/app/components/ContentCommand.svelte
Normal 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>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
|
import {MESSAGE} from "@welshman/util"
|
||||||
import type {EventContent} from "@welshman/util"
|
import type {EventContent} from "@welshman/util"
|
||||||
import {escapeHtml, isMobile, preventDefault} from "@lib/html"
|
import {escapeHtml, isMobile, preventDefault} from "@lib/html"
|
||||||
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||||
|
|
@ -13,8 +14,10 @@
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import ComposeMenu from "@app/components/ComposeMenu.svelte"
|
import ComposeMenu from "@app/components/ComposeMenu.svelte"
|
||||||
import DictationButton from "@app/components/DictationButton.svelte"
|
import DictationButton from "@app/components/DictationButton.svelte"
|
||||||
|
import CommandArgBar from "@app/components/CommandArgBar.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
|
import {app} from "@app/core"
|
||||||
import {DraftKey, type Draft} from "@app/drafts"
|
import {DraftKey, type Draft} from "@app/drafts"
|
||||||
import type {Share} from "@app/share"
|
import type {Share} from "@app/share"
|
||||||
import {onDestroy, onMount} from "svelte"
|
import {onDestroy, onMount} from "svelte"
|
||||||
|
|
@ -37,6 +40,15 @@
|
||||||
|
|
||||||
const uploading = writable(false)
|
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 focus = () => editor.then(ed => ed.chain().focus().run())
|
||||||
|
|
||||||
export const canEnterEditPrevious = () =>
|
export const canEnterEditPrevious = () =>
|
||||||
|
|
@ -56,10 +68,21 @@
|
||||||
|
|
||||||
// Tiptap parses a string handed to insertContent as html, so an angle bracket in the
|
// Tiptap parses a string handed to insertContent as html, so an angle bracket in the
|
||||||
// transcript would eat the rest of the sentence.
|
// transcript would eat the rest of the sentence.
|
||||||
const insertTranscript = async (text: string) => {
|
const insertTranscript = async (transcript: string) => {
|
||||||
const ed = await editor
|
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()
|
const showPopover = () => tippy?.show()
|
||||||
|
|
@ -96,8 +119,10 @@
|
||||||
content,
|
content,
|
||||||
empty,
|
empty,
|
||||||
submit,
|
submit,
|
||||||
|
text,
|
||||||
uploading,
|
uploading,
|
||||||
onChange,
|
onChange,
|
||||||
|
commandTarget,
|
||||||
aggressive: true,
|
aggressive: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -126,6 +151,7 @@
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<CommandArgBar target={commandTarget} content={$text} insert={insertCommandToken} />
|
||||||
<form class="relative flex gap-2 py-2" onsubmit={preventDefault(submit)}>
|
<form class="relative flex gap-2 py-2" onsubmit={preventDefault(submit)}>
|
||||||
<div class="join">
|
<div class="join">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
19
src/app/editor/CommandNodeView.ts
Normal file
19
src/app/editor/CommandNodeView.ts
Normal 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")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/editor/CommandSuggestion.svelte
Normal file
31
src/app/editor/CommandSuggestion.svelte
Normal 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>
|
||||||
|
|
@ -7,14 +7,25 @@ import {createSearch, splitRoomKey} from "@welshman/app"
|
||||||
import type {Room} from "@welshman/app"
|
import type {Room} from "@welshman/app"
|
||||||
import type {FileAttributes} from "@welshman/editor"
|
import type {FileAttributes} from "@welshman/editor"
|
||||||
import {
|
import {
|
||||||
|
CommandExtension,
|
||||||
|
CommandSuggestion,
|
||||||
Editor,
|
Editor,
|
||||||
MentionSuggestion,
|
MentionSuggestion,
|
||||||
TippySuggestion,
|
TippySuggestion,
|
||||||
WelshmanExtension,
|
WelshmanExtension,
|
||||||
editorProps,
|
editorProps,
|
||||||
} from "@welshman/editor"
|
} from "@welshman/editor"
|
||||||
|
import type {CommandScopeTarget} from "@welshman/util"
|
||||||
import {escapeHtml} from "@lib/html"
|
import {escapeHtml} from "@lib/html"
|
||||||
import {profiles, relayLists, relayMemberLists, rooms} from "@app/core"
|
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 {MentionNodeView} from "@app/editor/MentionNodeView"
|
||||||
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
|
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
|
||||||
import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension"
|
import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension"
|
||||||
|
|
@ -37,8 +48,10 @@ export const makeEditor = async ({
|
||||||
placeholder = "",
|
placeholder = "",
|
||||||
url,
|
url,
|
||||||
submit,
|
submit,
|
||||||
|
text,
|
||||||
uploading,
|
uploading,
|
||||||
wordCount,
|
wordCount,
|
||||||
|
commandTarget,
|
||||||
}: {
|
}: {
|
||||||
encryptFiles?: boolean
|
encryptFiles?: boolean
|
||||||
aggressive?: boolean
|
aggressive?: boolean
|
||||||
|
|
@ -49,8 +62,11 @@ export const makeEditor = async ({
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
url?: string
|
url?: string
|
||||||
submit: () => void
|
submit: () => void
|
||||||
|
text?: Writable<string>
|
||||||
uploading?: Writable<boolean>
|
uploading?: Writable<boolean>
|
||||||
wordCount?: Writable<number>
|
wordCount?: Writable<number>
|
||||||
|
// When given, `/` at the start of the composer suggests commands scoped to this target
|
||||||
|
commandTarget?: CommandScopeTarget
|
||||||
}) => {
|
}) => {
|
||||||
const searchProfiles = derived(
|
const searchProfiles = derived(
|
||||||
[profiles.get().profileSearch, throttled(800, relayMemberLists.get().forUrl(url ?? ""))],
|
[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({
|
const ed = new Editor({
|
||||||
content: typeof content === "string" ? escapeHtml(content) : content,
|
content: typeof content === "string" ? escapeHtml(content) : content,
|
||||||
editorProps,
|
editorProps,
|
||||||
element: document.createElement("div"),
|
element: document.createElement("div"),
|
||||||
extensions: [
|
extensions: [
|
||||||
RoomReferenceExtension,
|
RoomReferenceExtension,
|
||||||
|
CommandExtension.extend({addNodeView: () => CommandNodeView}),
|
||||||
WelshmanExtension.configure({
|
WelshmanExtension.configure({
|
||||||
submit,
|
submit,
|
||||||
extensions: {
|
extensions: {
|
||||||
|
|
@ -185,6 +227,23 @@ export const makeEditor = async ({
|
||||||
return target
|
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,
|
NativeClipboardPasteExtension,
|
||||||
],
|
],
|
||||||
onUpdate({editor}) {
|
onUpdate({editor}) {
|
||||||
|
text?.set(editor.getText({blockSeparator: "\n"}))
|
||||||
wordCount?.set(editor.storage.wordCount.words)
|
wordCount?.set(editor.storage.wordCount.words)
|
||||||
charCount?.set(editor.storage.wordCount.chars)
|
charCount?.set(editor.storage.wordCount.chars)
|
||||||
empty?.set(isEmpty(editor))
|
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
|
// 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.
|
// await in this function inside a callback, below the constructor.
|
||||||
empty?.set(isEmpty(ed))
|
empty?.set(isEmpty(ed))
|
||||||
|
text?.set(ed.getText({blockSeparator: "\n"}))
|
||||||
|
|
||||||
return ed
|
return ed
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type {Unsubscriber} from "svelte/store"
|
||||||
import {deleteDB} from "idb"
|
import {deleteDB} from "idb"
|
||||||
import {SecureStorage} from "@aparajita/capacitor-secure-storage"
|
import {SecureStorage} from "@aparajita/capacitor-secure-storage"
|
||||||
import {Preferences} from "@capacitor/preferences"
|
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 type {Maybe} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
ALERT_ANDROID,
|
ALERT_ANDROID,
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
ALERT_WEB,
|
ALERT_WEB,
|
||||||
APP_DATA,
|
APP_DATA,
|
||||||
BLOSSOM_SERVERS,
|
BLOSSOM_SERVERS,
|
||||||
|
COMMAND,
|
||||||
FOLLOWS,
|
FOLLOWS,
|
||||||
MESSAGING_RELAYS,
|
MESSAGING_RELAYS,
|
||||||
MUTES,
|
MUTES,
|
||||||
|
|
@ -138,7 +139,7 @@ const idleWrite = <T>(f: (xs: T[]) => void): ((xs: T[]) => void) => {
|
||||||
const kinds = {
|
const kinds = {
|
||||||
meta: [PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, MESSAGING_RELAYS, APP_DATA, ROOMS],
|
meta: [PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, MESSAGING_RELAYS, APP_DATA, ROOMS],
|
||||||
alert: [ALERT_STATUS, ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID],
|
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: [
|
||||||
ROOM_META,
|
ROOM_META,
|
||||||
ROOM_DELETE,
|
ROOM_DELETE,
|
||||||
|
|
@ -169,7 +170,13 @@ const shouldPersistEvent = (event: TrustedEvent, pubkey: string) =>
|
||||||
isRelayScoped(event) ||
|
isRelayScoped(event) ||
|
||||||
isConversation(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}
|
type PlaintextItem = {key: string; value: string}
|
||||||
|
|
||||||
|
|
@ -182,6 +189,7 @@ class Storage {
|
||||||
ready: Promise<void>
|
ready: Promise<void>
|
||||||
|
|
||||||
private db: IDB
|
private db: IDB
|
||||||
|
private cachedAt = new Map<string, number>()
|
||||||
private unsubscribers: Unsubscriber[] = []
|
private unsubscribers: Unsubscriber[] = []
|
||||||
private timeouts: ReturnType<typeof setTimeout>[] = []
|
private timeouts: ReturnType<typeof setTimeout>[] = []
|
||||||
private stopped = false
|
private stopped = false
|
||||||
|
|
@ -258,9 +266,11 @@ class Storage {
|
||||||
if (
|
if (
|
||||||
item.event &&
|
item.event &&
|
||||||
shouldPersistEvent(item.event, this.pubkey) &&
|
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
|
item.event[verifiedSymbol] = true
|
||||||
|
this.cachedAt.set(item.id, item.cachedAt ?? now())
|
||||||
items.push(item)
|
items.push(item)
|
||||||
} else {
|
} else {
|
||||||
stale.push(item.id)
|
stale.push(item.id)
|
||||||
|
|
@ -312,18 +322,27 @@ class Storage {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (add.length > 0) {
|
if (add.length > 0) {
|
||||||
|
const cachedAt = now()
|
||||||
|
|
||||||
// The ingest policy tracks an event before publishing it, so by the time the
|
// The ingest policy tracks an event before publishing it, so by the time the
|
||||||
// repository reports it, its provenance is already in the tracker
|
// repository reports it, its provenance is already in the tracker
|
||||||
await table.bulkPut(
|
await table.bulkPut(
|
||||||
add.map(event => ({
|
add.map(event => {
|
||||||
|
this.cachedAt.set(event.id, cachedAt)
|
||||||
|
|
||||||
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
event,
|
event,
|
||||||
relays: Array.from(this.app.tracker.getRelays(event.id)),
|
relays: Array.from(this.app.tracker.getRelays(event.id)),
|
||||||
})),
|
cachedAt,
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remove.size > 0) {
|
if (remove.size > 0) {
|
||||||
|
remove.forEach(id => this.cachedAt.delete(id))
|
||||||
|
|
||||||
await table.bulkDelete(remove)
|
await table.bulkDelete(remove)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
@ -343,7 +362,12 @@ class Storage {
|
||||||
// syncEvents persists its provenance along with the event itself. This pass only
|
// syncEvents persists its provenance along with the event itself. This pass only
|
||||||
// records relay changes for events we already have.
|
// records relay changes for events we already have.
|
||||||
if (event && shouldPersistEvent(event, this.pubkey)) {
|
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(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue