/**
* 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 `
${this.view !== "mailboxes" ? `` : ""}
${items.map((i) => ``).join("")}
`;
}
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.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.body_text || t.body_html || "(no content)"}
`;
}
private renderApprovals(): string {
if (this.approvals.length === 0) {
return ``;
}
return this.approvals.map((a) => `
${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);