import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; const styles = css` :host { background: #0f172a; color: #e2e8f0; border-radius: 8px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); min-width: 360px; min-height: 320px; overflow: hidden; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #6366f1; color: white; font-size: 12px; font-weight: 600; cursor: move; border-radius: 8px 8px 0 0; } .header-title { display: flex; align-items: center; gap: 6px; } .status-badge { padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 700; text-transform: uppercase; } .status-draft { background: #334155; color: #94a3b8; } .status-pending { background: #fbbf24; color: #1e293b; } .status-approved { background: #22c55e; color: white; } .status-sent { background: #22c55e; color: white; } .status-rejected { background: #ef4444; color: white; } .body { padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; height: calc(100% - 36px); overflow-y: auto; } .field { display: flex; gap: 6px; font-size: 12px; align-items: baseline; } .field-label { color: #64748b; font-weight: 600; min-width: 50px; flex-shrink: 0; } .field-value { color: #cbd5e1; word-break: break-all; } .field-input { flex: 1; background: rgba(0,0,0,0.3); border: 1px solid #334155; border-radius: 4px; padding: 3px 6px; color: #e2e8f0; font-size: 12px; font-family: inherit; outline: none; } .field-input:focus { border-color: #6366f1; } .field-textarea { flex: 1; background: rgba(0,0,0,0.3); border: 1px solid #334155; border-radius: 4px; padding: 4px 6px; color: #e2e8f0; font-size: 12px; font-family: inherit; outline: none; resize: vertical; min-height: 48px; max-height: 120px; } .field-textarea:focus { border-color: #6366f1; } .divider { border: none; border-top: 1px solid #1e293b; margin: 4px 0; } .body-preview { font-size: 11px; color: #94a3b8; line-height: 1.4; max-height: 60px; overflow: hidden; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; border-left: 3px solid #334155; } /* Progress */ .progress-section { margin-top: auto; } .progress-bar-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .progress-bar { flex: 1; height: 6px; background: #1e293b; border-radius: 3px; overflow: hidden; } .progress-fill { height: 100%; border-radius: 3px; transition: width 0.3s; } .progress-fill.pending { background: linear-gradient(90deg, #fbbf24, #f59e0b); } .progress-fill.approved, .progress-fill.sent { background: linear-gradient(90deg, #22c55e, #16a34a); } .progress-text { font-size: 10px; color: #64748b; flex-shrink: 0; } .signer-row { display: flex; align-items: center; gap: 6px; font-size: 11px; padding: 2px 0; } .signer-dot { width: 14px; height: 14px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white; flex-shrink: 0; } .signer-dot.signed { background: #22c55e; } .signer-dot.waiting { background: #334155; } .signer-name { flex: 1; } .signer-name.signed { color: #e2e8f0; } .signer-name.waiting { color: #64748b; } .signer-status { font-size: 10px; font-weight: 600; } .signer-status.signed { color: #4ade80; } .signer-status.waiting { color: #64748b; } /* Actions */ .actions { display: flex; gap: 6px; margin-top: 6px; padding-top: 6px; border-top: 1px solid #1e293b; } .btn { padding: 4px 12px; border-radius: 4px; border: none; cursor: pointer; font-size: 11px; font-weight: 600; transition: opacity 0.15s; } .btn:hover { opacity: 0.85; } .btn-primary { background: #6366f1; color: white; } .btn-success { background: #22c55e; color: white; } .btn-danger { background: #ef4444; color: white; } .btn-secondary { background: #334155; color: #94a3b8; } `; export class FolkMultisigEmail extends FolkShape { static tagName = "folk-multisig-email"; // Shape data mailboxSlug = ""; toAddresses: string[] = []; ccAddresses: string[] = []; subject = ""; bodyText = ""; bodyHtml = ""; replyToThreadId: string | null = null; replyType: 'new' | 'reply' | 'forward' = 'new'; approvalId: string | null = null; status: 'draft' | 'pending' | 'approved' | 'sent' | 'rejected' = 'draft'; requiredSignatures = 2; signatures: Array<{ id: string; signerId: string; vote: string; signedAt: string }> = []; private _pollInterval: ReturnType | null = null; static get observedAttributes() { return [...super.observedAttributes, "mailbox-slug", "subject", "status", "approval-id"]; } attributeChangedCallback(name: string, oldValue: string, newValue: string) { super.attributeChangedCallback(name, oldValue, newValue); switch (name) { case "mailbox-slug": this.mailboxSlug = newValue || ""; break; case "subject": this.subject = newValue || ""; break; case "status": this.status = (newValue || "draft") as any; break; case "approval-id": this.approvalId = newValue || null; break; } } connectedCallback() { super.connectedCallback(); this.shadowRoot!.adoptedStyleSheets = [...this.shadowRoot!.adoptedStyleSheets, styles]; this.renderContent(); if (this.status === 'pending' && this.approvalId) { this.startPolling(); } } disconnectedCallback() { super.disconnectedCallback(); this.stopPolling(); } private getApiBase(): string { const space = (this as any).spaceSlug || (window as any).__communitySync?.communitySlug || "demo"; return `/${space}/rinbox`; } private getAuthHeaders(): Record { const headers: Record = { "Content-Type": "application/json" }; const token = localStorage.getItem("encryptid-token"); if (token) headers["Authorization"] = `Bearer ${token}`; return headers; } private startPolling() { this.stopPolling(); this._pollInterval = setInterval(() => this.pollApproval(), 5000); } private stopPolling() { if (this._pollInterval) { clearInterval(this._pollInterval); this._pollInterval = null; } } private async pollApproval() { if (!this.approvalId) return; try { const resp = await fetch(`${this.getApiBase()}/api/approvals/${this.approvalId}`, { headers: this.getAuthHeaders(), }); if (!resp.ok) return; const data = await resp.json(); const newStatus = data.status?.toLowerCase(); if (newStatus && newStatus !== this.status) { this.status = newStatus; this.signatures = data.signatures || []; this.renderContent(); this.dispatchEvent(new CustomEvent("content-change", { bubbles: true })); if (newStatus !== 'pending') this.stopPolling(); } else { // Update signature count even if status didn't change if (data.signatures) { this.signatures = data.signatures; this.renderContent(); } } } catch { /* ignore polling errors */ } } private renderContent() { const sr = this.shadowRoot; if (!sr) return; const isDraft = this.status === 'draft'; const sigCount = this.signatures.filter(s => s.vote === 'APPROVE').length; const pct = this.requiredSignatures > 0 ? Math.min(100, Math.round((sigCount / this.requiredSignatures) * 100)) : 0; let contentEl = sr.querySelector(".msig-content") as HTMLDivElement; if (!contentEl) { contentEl = document.createElement("div"); contentEl.className = "msig-content"; contentEl.style.cssText = "display:flex;flex-direction:column;height:100%;"; sr.appendChild(contentEl); } if (isDraft) { contentEl.innerHTML = html`
✉ Multi-Sig Email
Draft
From: ${this.mailboxSlug || "team"}@rspace.online
To:
Subject:

`; } else { const snippet = this.bodyText.length > 120 ? this.bodyText.slice(0, 120) + '...' : this.bodyText; contentEl.innerHTML = html`
✉ Multi-Sig Email
${this.status}
From: ${this.mailboxSlug || "team"}@rspace.online
To: ${this.toAddresses.join(', ') || '(none)'}
Subject: ${this.subject || '(no subject)'}

${this.escapeHtml(snippet)}
${sigCount}/${this.requiredSignatures} signatures
${this.signatures.map(s => html`
${s.vote === 'APPROVE' ? '✓' : ''}
${s.signerId?.slice(0, 12) || 'Unknown'}... ${s.vote === 'APPROVE' ? 'Signed' : 'Awaiting'}
`).join('')}
${this.status === 'pending' ? html`
` : ''} ${this.status === 'draft' ? html`
` : ''}
`; } this.bindShapeEvents(contentEl); } private escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>'); } private escapeAttr(s: string): string { return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } private bindShapeEvents(root: HTMLElement) { // Draft field changes root.querySelectorAll("[data-field]").forEach(el => { el.addEventListener("input", () => { const field = (el as HTMLElement).dataset.field; const value = (el as HTMLInputElement | HTMLTextAreaElement).value; switch (field) { case 'to': this.toAddresses = value.split(',').map(s => s.trim()).filter(Boolean); break; case 'subject': this.subject = value; break; case 'body': this.bodyText = value; break; } this.dispatchEvent(new CustomEvent("content-change", { bubbles: true })); }); }); // Submit root.querySelector("[data-action='submit']")?.addEventListener("click", async () => { if (!this.subject || this.toAddresses.length === 0) { alert("Please fill To and Subject fields."); return; } try { const resp = await fetch(`${this.getApiBase()}/api/approvals`, { method: "POST", headers: this.getAuthHeaders(), body: JSON.stringify({ mailbox_slug: this.mailboxSlug || undefined, subject: this.subject, body_text: this.bodyText, to_addresses: this.toAddresses, cc_addresses: this.ccAddresses, reply_type: this.replyType, thread_id: this.replyToThreadId, }), }); if (resp.ok) { const data = await resp.json(); this.approvalId = data.id; this.status = 'pending'; this.requiredSignatures = data.required_signatures || 2; this.renderContent(); this.dispatchEvent(new CustomEvent("content-change", { bubbles: true })); this.startPolling(); } } catch (e) { console.error("[MultiSigEmail] Submit error:", e); } }); // Approve root.querySelector("[data-action='approve']")?.addEventListener("click", async () => { if (!this.approvalId) return; try { const resp = await fetch(`${this.getApiBase()}/api/approvals/${this.approvalId}/sign`, { method: "POST", headers: this.getAuthHeaders(), body: JSON.stringify({ vote: "APPROVE" }), }); if (resp.ok) { const data = await resp.json(); if (data.status) { this.status = data.status.toLowerCase(); if (this.status !== 'pending') this.stopPolling(); } this.renderContent(); this.dispatchEvent(new CustomEvent("content-change", { bubbles: true })); } } catch (e) { console.error("[MultiSigEmail] Approve error:", e); } }); // Reject root.querySelector("[data-action='reject']")?.addEventListener("click", async () => { if (!this.approvalId) return; try { const resp = await fetch(`${this.getApiBase()}/api/approvals/${this.approvalId}/sign`, { method: "POST", headers: this.getAuthHeaders(), body: JSON.stringify({ vote: "REJECT" }), }); if (resp.ok) { const data = await resp.json(); if (data.status) this.status = data.status.toLowerCase(); this.stopPolling(); this.renderContent(); this.dispatchEvent(new CustomEvent("content-change", { bubbles: true })); } } catch (e) { console.error("[MultiSigEmail] Reject error:", e); } }); } } customElements.define(FolkMultisigEmail.tagName, FolkMultisigEmail);