feat(ui,api,telegram): shared conversation panel for obsidian and /admin/panel, bind materializes a window, inbox survives link previews
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
import ArchiveIcon from "@lucide/svelte/icons/archive";
|
||||
import ArrowRightIcon from "@lucide/svelte/icons/arrow-right";
|
||||
import BookCheckIcon from "@lucide/svelte/icons/book-check";
|
||||
import BrainIcon from "@lucide/svelte/icons/brain";
|
||||
import CircleXIcon from "@lucide/svelte/icons/circle-x";
|
||||
import CopyIcon from "@lucide/svelte/icons/copy";
|
||||
import EyeOffIcon from "@lucide/svelte/icons/eye-off";
|
||||
import FileTextIcon from "@lucide/svelte/icons/file-text";
|
||||
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
|
||||
import GitMergeIcon from "@lucide/svelte/icons/git-merge";
|
||||
import PencilIcon from "@lucide/svelte/icons/pencil";
|
||||
import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw";
|
||||
import SendIcon from "@lucide/svelte/icons/send";
|
||||
import type { Component } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { ConversationInfo } from "$lib/api/types";
|
||||
import type { PanelHost } from "./host";
|
||||
|
||||
export interface MenuAction {
|
||||
// Present: rendered as a checkbox item with this state.
|
||||
checked?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
// A separator sits above this action.
|
||||
gap?: boolean;
|
||||
icon?: Component<{ class?: string }>;
|
||||
label: string;
|
||||
run: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ActionScope {
|
||||
client: ApiClient;
|
||||
// The conversation is the one on screen, so "Open" is pointless.
|
||||
current: boolean;
|
||||
host: PanelHost;
|
||||
onBranch: () => void;
|
||||
onChanged: () => void;
|
||||
onOpen: (id: string) => void;
|
||||
onRename: () => void;
|
||||
}
|
||||
|
||||
const TELEGRAM = "telegram";
|
||||
const MARKDOWN = "markdown";
|
||||
|
||||
function describe(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function attempt(
|
||||
action: () => Promise<unknown>,
|
||||
done: string | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
await action();
|
||||
if (done) {
|
||||
toast.success(done);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(describe(error));
|
||||
}
|
||||
}
|
||||
|
||||
function copyId(id: string): void {
|
||||
navigator.clipboard
|
||||
.writeText(id)
|
||||
.then(() => toast.success("Id copied"))
|
||||
.catch(() => toast.error("Clipboard is not available"));
|
||||
}
|
||||
|
||||
// The one list of things you can do to a conversation, in the order the
|
||||
// header menu and the row's context menu both show them.
|
||||
export function conversationActions(
|
||||
info: ConversationInfo,
|
||||
scope: ActionScope
|
||||
): MenuAction[] {
|
||||
const { client, host } = scope;
|
||||
const open = info.status === "open";
|
||||
const telegram = info.bindings.find((b) => b.frontend === TELEGRAM);
|
||||
const note = info.bindings.find((b) => b.frontend === MARKDOWN && b.visible);
|
||||
const remembers = info.flags.memory !== false;
|
||||
const changed = () => scope.onChanged();
|
||||
const actions: MenuAction[] = [];
|
||||
|
||||
if (!scope.current) {
|
||||
actions.push({
|
||||
icon: ArrowRightIcon,
|
||||
label: "Open",
|
||||
run: () => scope.onOpen(info.id),
|
||||
});
|
||||
}
|
||||
if (note && host.openNote) {
|
||||
actions.push({
|
||||
icon: FileTextIcon,
|
||||
label: "Open note",
|
||||
run: () => host.openNote?.(note.external_id),
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
disabled: !open,
|
||||
gap: actions.length > 0,
|
||||
icon: GitBranchIcon,
|
||||
label: "Branch…",
|
||||
run: scope.onBranch,
|
||||
});
|
||||
actions.push({
|
||||
checked: remembers,
|
||||
gap: true,
|
||||
icon: BrainIcon,
|
||||
label: "Remember on close",
|
||||
run: () =>
|
||||
attempt(
|
||||
async () => {
|
||||
await client.setFlags(info.id, { memory: !remembers });
|
||||
changed();
|
||||
},
|
||||
remembers
|
||||
? "Memory off: closing only merges"
|
||||
: "Memory on: closing writes a digest"
|
||||
),
|
||||
});
|
||||
if (info.kind === "branch") {
|
||||
if (telegram?.visible) {
|
||||
actions.push({
|
||||
icon: EyeOffIcon,
|
||||
label: "Hide from Telegram",
|
||||
run: () =>
|
||||
attempt(async () => {
|
||||
await client.bind(info.id, TELEGRAM, telegram.external_id, false);
|
||||
changed();
|
||||
}, "Hidden from Telegram"),
|
||||
});
|
||||
} else {
|
||||
actions.push({
|
||||
icon: SendIcon,
|
||||
label: "Return to Telegram",
|
||||
run: () =>
|
||||
attempt(async () => {
|
||||
await client.bind(
|
||||
info.id,
|
||||
TELEGRAM,
|
||||
telegram?.external_id ?? null,
|
||||
true
|
||||
);
|
||||
changed();
|
||||
}, "Back in Telegram"),
|
||||
});
|
||||
}
|
||||
}
|
||||
actions.push({
|
||||
gap: true,
|
||||
icon: PencilIcon,
|
||||
label: "Rename…",
|
||||
run: scope.onRename,
|
||||
});
|
||||
actions.push({
|
||||
icon: CopyIcon,
|
||||
label: "Copy id",
|
||||
run: () => copyId(info.id),
|
||||
});
|
||||
if (info.parent && open) {
|
||||
actions.push({
|
||||
gap: true,
|
||||
icon: GitMergeIcon,
|
||||
label: "Merge into parent",
|
||||
run: () =>
|
||||
attempt(async () => {
|
||||
await client.merge(info.id);
|
||||
changed();
|
||||
}, "Merged into the parent"),
|
||||
});
|
||||
}
|
||||
if (info.kind === "deep" && open) {
|
||||
actions.push({
|
||||
gap: !(info.parent && open),
|
||||
icon: BookCheckIcon,
|
||||
label: "Close with digest",
|
||||
run: () =>
|
||||
attempt(async () => {
|
||||
const result = await client.close(info.id);
|
||||
changed();
|
||||
if (result.error) {
|
||||
toast.warning(`Digest: ${result.error}`);
|
||||
} else if (result.digest) {
|
||||
toast.message(`Digest: ${result.digest}`);
|
||||
}
|
||||
}, "Chat closed"),
|
||||
});
|
||||
}
|
||||
if (open) {
|
||||
actions.push({
|
||||
gap: !(info.parent || info.kind === "deep"),
|
||||
icon: CircleXIcon,
|
||||
label: "Close",
|
||||
run: () =>
|
||||
attempt(async () => {
|
||||
await client.update(info.id, { status: "closed" });
|
||||
changed();
|
||||
}, "Closed"),
|
||||
});
|
||||
actions.push({
|
||||
danger: true,
|
||||
icon: ArchiveIcon,
|
||||
label: "Archive",
|
||||
run: () =>
|
||||
attempt(async () => {
|
||||
await client.update(info.id, { status: "archived" });
|
||||
changed();
|
||||
}, "Archived"),
|
||||
});
|
||||
} else {
|
||||
actions.push({
|
||||
gap: true,
|
||||
icon: RotateCcwIcon,
|
||||
label: "Reopen",
|
||||
run: () =>
|
||||
attempt(async () => {
|
||||
await client.update(info.id, { status: "open" });
|
||||
changed();
|
||||
}, "Reopened"),
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
Reference in New Issue
Block a user