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 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} />
|
||||
|
|
|
|||
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">
|
||||
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
|
||||
|
|
|
|||
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 {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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue