Repository navigation
Expand file tree
/
Copy pathdelete.ts
More file actions
151 lines (145 loc) · 12.9 KB
/
Copy pathdelete.ts
File metadata and controls
151 lines (145 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
// Deleting a chat, a channel, or the account (the Captain, 10-06: "delete = delete. It really really really means delete.").
// The server does the deleting (proto src/deletion.js); this module asks, calls it, and tells the app what's gone.
// Chat: the ⋯ menu on a task row (side panel, phone drawer), the task header, the phone's About sheet → ask() → POST
// /res/<ch>/sessions/<id>/delete.
// Channel: the ⋯ menu on the channel header (desktop) or the phone's About sheet → ask() with the channel's name typed →
// POST /channels/<ch>/delete {confirm}. General has no Delete.
// Account: Settings → General → Delete account → ask() with "delete my account" typed → POST /account/delete {confirm} → signed
// out (the server clears the cookie) → the 1Helm home page.
// The server checks the typed confirmation too; this UI is never the only guard.
import { ask, tell } from "./dialog";
export type DelDeps = {
api: (path: string, body?: any) => Promise<any>;
note: (title: string, sub: string) => void;
/** a chat by channel slug + session id (server sessions only), for its title */
chat: (slug: string, sid: string) => { title: string; jobs: number } | null;
/** a channel's display name */
channelName: (slug: string) => string | null;
chatGone: (slug: string, sid: string) => void;
channelGone: (slug: string) => void;
closeSheet?: () => void;
/** Stop (stop.ts): a working chat's ⋯ menu offers "Stop" first */
working?: (slug: string, sid: string) => boolean;
stop?: (slug: string, sid: string) => void;
rename?: (slug: string, sid: string, opener?: HTMLElement) => void;
};
let D: DelDeps | null = null;
type ChatDeletion = { slug: string; sid: string; title: string; phase: "confirming" | "deleting" | "failed" };
const chatDeletions = new Map<string, ChatDeletion>();
const chatKey = (slug: string, sid: any) => JSON.stringify([slug, String(sid)]);
const deleting = (slug: string, sid: any) => chatDeletions.get(chatKey(slug, sid))?.phase === "deleting";
const SPINNER = '<i class="delspin" aria-hidden="true"></i>';
export const CHAT_TITLE = "Delete this chat?";
export const CHAT_BODY = "It's permanently deleted, including its messages, files made in it, and what it added to memory. This can't be undone.";
export const ACCOUNT_WORDS = "delete my account";
const esc = (s: any = "") => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]!));
const svg = (d: string, s = 16) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${d}</svg>`;
export const MORE = svg('<circle cx="5" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="19" cy="12" r="1.3"/>');
export const TRASH = svg('<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>', 15);
const STOPSQ = `<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2.5" fill="currentColor"/></svg>`;
// ---- markup the app drops in ----
/** The ⋯ button on a task row (inside its <a>; clicks never open the task). */
export const rowMore = (slug: string, sid: any, title: string) => `<span class="rmore${deleting(slug, sid) ? " delbusy" : ""}" role="button" tabindex="${deleting(slug, sid) ? -1 : 0}" data-rowmenu="${esc(slug)}|${esc(sid)}" aria-disabled="${deleting(slug, sid)}" aria-haspopup="menu" aria-label="${deleting(slug, sid) ? "Deleting" : "More for"} ${esc(title)}" title="${deleting(slug, sid) ? "Deleting…" : "More"}">${deleting(slug, sid) ? SPINNER : MORE}</span>`;
/** Delete in the task header (desktop). */
export const headDelete = (slug: string, sid: any) => `<button class="hdel${deleting(slug, sid) ? " delbusy" : ""}" data-delchat="${esc(slug)}|${esc(sid)}"${deleting(slug, sid) ? " disabled aria-busy=\"true\"" : ""} title="${deleting(slug, sid) ? "Deleting…" : "Delete this chat"}" aria-label="${deleting(slug, sid) ? "Deleting…" : "Delete this chat"}">${deleting(slug, sid) ? SPINNER : TRASH}</button>`;
/** The ⋯ on the channel header (desktop side panel). General has none. */
export const channelMore = (slug: string, name: string) => slug === "main" ? "" : `<button class="chmore" data-chmenu="${esc(slug)}" aria-haspopup="menu" aria-label="More for ${esc(name)}" title="Channel options">${MORE}</button>`;
/** Rows for the phone's About sheet: delete the open chat, delete the channel. */
const sheetChatRow = (slug: string, sid: any) => `<button class="srow delrow" data-delchat="${esc(slug)}|${esc(sid)}"${deleting(slug, sid) ? " disabled aria-busy=\"true\"" : ""}>${deleting(slug, sid) ? SPINNER : TRASH}<span>${deleting(slug, sid) ? "Deleting…" : "Delete this chat"}</span></button>`;
export const sheetRows = (slug: string, sid: any | null) => (sid != null ? sheetChatRow(slug, sid) : "")
+ (slug === "main" ? "" : `<button class="srow delrow" data-delch="${esc(slug)}">${TRASH}<span>Delete channel</span></button>`);
// ---- the three deletions ----
function deletionFeedback() {
for (const el of document.querySelectorAll<HTMLElement>("[data-rowmenu],[data-delchat]")) {
const [slug, sid] = (el.dataset.rowmenu || el.dataset.delchat || "").split("|");
if (el.dataset.rowmenu) el.outerHTML = rowMore(slug, sid, D?.chat(slug, sid)?.title || "chat");
else if (el.classList.contains("hdel")) el.outerHTML = headDelete(slug, sid);
else if (el.classList.contains("delrow")) el.outerHTML = sheetChatRow(slug, sid);
}
const visible = [...chatDeletions.values()].filter(x => x.phase !== "confirming");
let tray = document.getElementById("chat-deletion-status");
if (!visible.length) { tray?.remove(); return; }
if (!tray) { tray = document.createElement("div"); tray.id = "chat-deletion-status"; tray.setAttribute("aria-live", "polite"); document.body.appendChild(tray); }
tray.innerHTML = visible.map((x, i) => `<div class="delstatus${x.phase === "failed" ? " failed" : ""}" role="${x.phase === "failed" ? "alert" : "status"}">${x.phase === "deleting" ? SPINNER : TRASH}<span><b>${x.phase === "deleting" ? "Deleting…" : "Couldn't confirm deletion"}</b><small>${esc(x.title)}</small>${x.phase === "failed" ? '<small>Check your connection, then try again.</small>' : ""}</span>${x.phase === "failed" ? `<button data-delretry="${i}">Retry</button><button data-deldismiss="${i}" aria-label="Dismiss deletion error">×</button>` : ""}</div>`).join("");
tray.onclick = e => { const target = e.target as HTMLElement, retry = target.closest<HTMLElement>("[data-delretry]"), dismiss = target.closest<HTMLElement>("[data-deldismiss]");
const item = visible[Number(retry?.dataset.delretry ?? dismiss?.dataset.deldismiss)]; if (!item) return;
if (retry) void deleteChat(item.slug, item.sid); else if (dismiss) { chatDeletions.delete(chatKey(item.slug, item.sid)); deletionFeedback(); }
};
}
export async function deleteChat(slug: string, sid: string) {
if (!D) return; const c = D.chat(slug, sid); if (!c) return;
const key = chatKey(slug, sid), previous = chatDeletions.get(key);
if (previous && previous.phase !== "failed") return;
chatDeletions.set(key, { slug, sid, title: c.title, phase: "confirming" });
const ok = await ask({ title: CHAT_TITLE, body: CHAT_BODY + (c.jobs ? ` Its scheduled ${c.jobs === 1 ? "job is" : `jobs (${c.jobs}) are`} deleted too.` : ""), confirm: "Delete", danger: true });
if (!ok) { if (previous) chatDeletions.set(key, previous); else chatDeletions.delete(key); deletionFeedback(); return; }
chatDeletions.set(key, { slug, sid, title: c.title, phase: "deleting" });
D.closeSheet?.();
deletionFeedback();
try {
const r = await D.api(`/res/${slug}/sessions/${sid}/delete`, {});
if (r?.error) throw new Error(r.error);
chatDeletions.delete(key); deletionFeedback();
D.chatGone(slug, sid);
D.note("Chat deleted", r.kept?.length ? `Kept ${r.kept.length === 1 ? `“${r.kept[0]}”` : `${r.kept.length} files`}: also used in another chat.` : c.title);
} catch { chatDeletions.set(key, { slug, sid, title: c.title, phase: "failed" }); deletionFeedback(); }
}
export async function deleteChannel(slug: string) {
if (!D || slug === "main") return; const name = D.channelName(slug); if (!name) return;
const ok = await ask({ title: `Delete ${name}?`, body: "The channel and everything in it is permanently deleted: its chats, files, Notebook, scheduled jobs, its computer, and what it remembers. This can't be undone.",
type: { match: name, label: `Type “${name}” to confirm` }, confirm: "Delete channel", danger: true });
if (!ok) return;
D.closeSheet?.();
try {
const r = await D.api(`/channels/${slug}/delete`, { confirm: name });
if (r?.error) throw new Error(r.error);
D.channelGone(slug); D.note("Channel deleted", `${name} and everything in it are gone.`);
} catch { await tell({ title: "Couldn't finish deleting the channel", body: "It's already hidden, and deleting keeps going on its own in the next few minutes." }); }
}
export async function deleteAccount() {
if (!D) return;
const ok = await ask({ title: "Delete your account?", body: "Everything is permanently deleted: every channel and chat, your files, Notebook and memory, contacts, saved logins and keys, connected apps and texting. Your plan is canceled now. This can't be undone.",
type: { match: ACCOUNT_WORDS, label: `Type “${ACCOUNT_WORDS}” to confirm` }, confirm: "Delete account", danger: true });
if (!ok) return;
document.body.classList.add("deleting-acct");
let r: any = null; try { r = await D.api("/account/delete", { confirm: ACCOUNT_WORDS }); } catch {}
if (r?.ok) { location.replace(r.redirect || "/"); return; }
document.body.classList.remove("deleting-acct");
await tell({ title: "Deleting didn't finish", body: "You're signed out, and deleting keeps going on its own in the next few minutes." });
location.replace("/");
}
// ---- a tiny menu (row ⋯ / channel ⋯) ----
function closeMenu() { document.getElementById("delmenu")?.remove(); }
function openMenu(at: HTMLElement, items: [string, string, () => void][]) {
closeMenu();
const m = document.createElement("div"); m.id = "delmenu"; m.className = "delmenu"; m.setAttribute("role", "menu");
m.innerHTML = items.map(([label, icon], i) => `<button type="button" role="menuitem" class="delmi${label.startsWith("Delete") ? " bad" : ""}" data-mi="${i}">${icon}<span>${esc(label)}</span></button>`).join("");
document.body.appendChild(m);
const r = at.getBoundingClientRect(), w = m.offsetWidth, h = m.offsetHeight;
m.style.left = `${Math.max(8, Math.min(innerWidth - w - 8, r.right - w))}px`;
m.style.top = `${r.bottom + 6 + h > innerHeight - 8 ? Math.max(8, r.top - h - 6) : r.bottom + 6}px`;
m.addEventListener("click", (e) => { e.stopPropagation(); const b = (e.target as HTMLElement).closest<HTMLElement>("[data-mi]"); if (!b) return; closeMenu(); items[+b.dataset.mi!][2](); });
m.querySelector<HTMLElement>("button")?.focus({ preventScroll: true });
}
export function delInit(deps: DelDeps) {
D = deps;
const act = (t: HTMLElement, e: Event) => {
const rm = t.closest<HTMLElement>("[data-rowmenu]");
if (rm) { e.preventDefault(); e.stopImmediatePropagation(); const [slug, sid] = rm.dataset.rowmenu!.split("|"); if (deleting(slug, sid)) return true; openMenu(rm, [...(D?.working?.(slug, sid) && D?.stop ? [["Stop", STOPSQ, () => D!.stop!(slug, sid)] as [string, string, () => void]] : []), ...(D?.rename ? [["Rename task", svg('<path d="m16 3 5 5L8 21H3v-5Z"/>'), () => D!.rename!(slug, sid, rm)] as [string, string, () => void]] : []), ["Delete chat", TRASH, () => void deleteChat(slug, sid)]]); return true; }
const cm = t.closest<HTMLElement>("[data-chmenu]");
if (cm) { e.preventDefault(); e.stopImmediatePropagation(); const slug = cm.dataset.chmenu!; openMenu(cm, [["Delete channel", TRASH, () => void deleteChannel(slug)]]); return true; }
const dc = t.closest<HTMLElement>("[data-delchat]");
if (dc) { e.preventDefault(); e.stopImmediatePropagation(); const [slug, sid] = dc.dataset.delchat!.split("|"); void deleteChat(slug, sid); return true; }
const dh = t.closest<HTMLElement>("[data-delch]");
if (dh) { e.preventDefault(); e.stopImmediatePropagation(); void deleteChannel(dh.dataset.delch!); return true; }
if (t.closest("[data-delacct]")) { e.preventDefault(); e.stopImmediatePropagation(); void deleteAccount(); return true; }
return false;
};
window.addEventListener("click", (e) => { const t = e.target as HTMLElement; if (!t.closest?.("#delmenu")) closeMenu(); act(t, e); }, true);
window.addEventListener("keydown", (e) => {
if (e.key === "Escape" && document.getElementById("delmenu")) { e.preventDefault(); e.stopImmediatePropagation(); closeMenu(); return; }
if (e.key !== "Enter" && e.key !== " ") return; const t = e.target as HTMLElement;
if (t.closest?.("[data-rowmenu]")) act(t, e);
}, true);
window.addEventListener("resize", closeMenu); window.addEventListener("hashchange", closeMenu);
}