/** * folk-inbox-client — Collaborative email client. * * Shows mailbox list, thread inbox, thread detail with comments, * and approval workflow interface. */ class FolkInboxClient extends HTMLElement { private shadow: ShadowRoot; private space = "demo"; private view: "mailboxes" | "threads" | "thread" | "approvals" = "mailboxes"; private mailboxes: any[] = []; private threads: any[] = []; private currentMailbox: any = null; private currentThread: any = null; private approvals: any[] = []; private filter: "all" | "open" | "snoozed" | "closed" = "all"; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.loadMailboxes(); } private async loadMailboxes() { try { const base = window.location.pathname.replace(/\/$/, ""); const resp = await fetch(`${base}/api/mailboxes`); if (resp.ok) { const data = await resp.json(); this.mailboxes = data.mailboxes || []; } } catch { /* ignore */ } this.render(); } private async loadThreads(slug: string) { try { const base = window.location.pathname.replace(/\/$/, ""); const status = this.filter === "all" ? "" : `?status=${this.filter}`; const resp = await fetch(`${base}/api/mailboxes/${slug}/threads${status}`); if (resp.ok) { const data = await resp.json(); this.threads = data.threads || []; } } catch { /* ignore */ } this.render(); } private async loadThread(id: string) { try { const base = window.location.pathname.replace(/\/$/, ""); const resp = await fetch(`${base}/api/threads/${id}`); if (resp.ok) { this.currentThread = await resp.json(); } } catch { /* ignore */ } this.render(); } private async loadApprovals() { try { const base = window.location.pathname.replace(/\/$/, ""); const q = this.currentMailbox ? `?mailbox=${this.currentMailbox.slug}` : ""; const resp = await fetch(`${base}/api/approvals${q}`); if (resp.ok) { const data = await resp.json(); this.approvals = data.approvals || []; } } catch { /* ignore */ } this.render(); } private timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 60) return `${mins}m ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; } private render() { this.shadow.innerHTML = `
${this.renderNav()} ${this.renderView()}
`; this.bindEvents(); } private renderNav(): string { const items = [ { id: "mailboxes", label: "Mailboxes" }, { id: "approvals", label: "Approvals" }, ]; if (this.currentMailbox) { items.unshift({ id: "threads", label: this.currentMailbox.name }); } return ` `; } private renderView(): string { switch (this.view) { case "mailboxes": return this.renderMailboxes(); case "threads": return this.renderThreads(); case "thread": return this.renderThreadDetail(); case "approvals": return this.renderApprovals(); default: return ""; } } private renderMailboxes(): string { if (this.mailboxes.length === 0) { return `

📨

No mailboxes yet

Create a shared mailbox to get started

`; } return this.mailboxes.map((m) => `
${m.name}
${m.email}
${m.description || "Shared mailbox"}
`).join(""); } private renderThreads(): string { const filters = ["all", "open", "snoozed", "closed"]; return `
${filters.map((f) => ``).join("")}
${this.threads.length === 0 ? `
No threads
` : this.threads.map((t) => `
${t.is_read ? "" : ``} ${t.from_name || t.from_address || "Unknown"} ${t.subject} ${t.comment_count > 0 ? `(${t.comment_count})` : ""} ${t.is_starred ? `` : ""} ${t.status} ${this.timeAgo(t.received_at)}
`).join("")}
`; } private renderThreadDetail(): string { if (!this.currentThread) return `
Loading...
`; const t = this.currentThread; const comments = t.comments || []; return `
${t.subject}
From: ${t.from_name || ""} <${t.from_address || "unknown"}> · ${this.timeAgo(t.received_at)} · ${t.status}
${t.body_text || t.body_html || "(no content)"}
Comments (${comments.length})
${comments.map((cm: any) => `
${cm.username || "Anonymous"} ${this.timeAgo(cm.created_at)}
${cm.body}
`).join("")} ${comments.length === 0 ? `
No comments yet
` : ""}
`; } private renderApprovals(): string { if (this.approvals.length === 0) { return `

No pending approvals

`; } return this.approvals.map((a) => `
${a.subject}
${a.status}
${a.signature_count || 0} / ${a.required_signatures} signatures · ${this.timeAgo(a.created_at)}
${a.status === "PENDING" ? `
` : ""}
`).join(""); } private bindEvents() { // Navigation this.shadow.querySelectorAll("[data-nav]").forEach((btn) => { btn.addEventListener("click", () => { const nav = (btn as HTMLElement).dataset.nav as any; if (nav === "approvals") { this.view = "approvals"; this.loadApprovals(); } else if (nav === "mailboxes") { this.view = "mailboxes"; this.currentMailbox = null; this.render(); } else if (nav === "threads") { this.view = "threads"; this.render(); } }); }); // Back const backBtn = this.shadow.querySelector("[data-action='back']"); if (backBtn) { backBtn.addEventListener("click", () => { if (this.view === "thread") { this.view = "threads"; this.render(); } else if (this.view === "threads" || this.view === "approvals") { this.view = "mailboxes"; this.currentMailbox = null; this.render(); } }); } // Mailbox click this.shadow.querySelectorAll("[data-mailbox]").forEach((card) => { card.addEventListener("click", () => { const slug = (card as HTMLElement).dataset.mailbox!; this.currentMailbox = this.mailboxes.find((m) => m.slug === slug); this.view = "threads"; this.loadThreads(slug); }); }); // Thread click this.shadow.querySelectorAll("[data-thread]").forEach((row) => { row.addEventListener("click", () => { const id = (row as HTMLElement).dataset.thread!; this.view = "thread"; this.loadThread(id); }); }); // Filter this.shadow.querySelectorAll("[data-filter]").forEach((btn) => { btn.addEventListener("click", () => { this.filter = (btn as HTMLElement).dataset.filter as any; if (this.currentMailbox) this.loadThreads(this.currentMailbox.slug); }); }); // Approval actions this.shadow.querySelectorAll("[data-approve]").forEach((btn) => { btn.addEventListener("click", async () => { const id = (btn as HTMLElement).dataset.approve!; const base = window.location.pathname.replace(/\/$/, ""); await fetch(`${base}/api/approvals/${id}/sign`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vote: "APPROVE" }), }); this.loadApprovals(); }); }); this.shadow.querySelectorAll("[data-reject]").forEach((btn) => { btn.addEventListener("click", async () => { const id = (btn as HTMLElement).dataset.reject!; const base = window.location.pathname.replace(/\/$/, ""); await fetch(`${base}/api/approvals/${id}/sign`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vote: "REJECT" }), }); this.loadApprovals(); }); }); } } customElements.define("folk-inbox-client", FolkInboxClient);