/** * — Comment button with dropdown panel. * * Shows a chat-bubble icon in the header bar. Badge displays the count * of unresolved comment pins on the current canvas. Clicking toggles a * dropdown panel showing all comment threads, sorted by most recent * message. Includes a "New Comment" button to enter pin-placement mode. * * Data source: `window.__communitySync?.doc?.commentPins` * Listens for `comment-pins-changed` on `window` (re-dispatched by canvas). * Polls every 5s as fallback (sync may appear after component mounts). */ const POLL_INTERVAL = 5_000; interface CommentMessage { text: string; createdBy: string; createdByName?: string; createdAt: number; } interface CommentPinData { messages: CommentMessage[]; anchor?: { x: number; y: number }; resolved?: boolean; createdBy?: string; createdByName?: string; createdAt?: number; } export class RStackCommentBell extends HTMLElement { #shadow: ShadowRoot; #count = 0; #open = false; #pollTimer: ReturnType | null = null; #syncRef: any = null; constructor() { super(); this.#shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.#render(); this.#syncRef = (window as any).__communitySync || null; this.#refreshCount(); this.#pollTimer = setInterval(() => this.#refreshCount(), POLL_INTERVAL); window.addEventListener("comment-pins-changed", this.#onPinsChanged); document.addEventListener("community-sync-ready", this.#onSyncReady); } disconnectedCallback() { if (this.#pollTimer) clearInterval(this.#pollTimer); window.removeEventListener("comment-pins-changed", this.#onPinsChanged); document.removeEventListener("community-sync-ready", this.#onSyncReady); } #onSyncReady = (e: Event) => { const sync = (e as CustomEvent).detail?.sync; if (sync) { this.#syncRef = sync; this.#refreshCount(); } }; #onPinsChanged = () => { this.#refreshCount(); if (this.#open) this.#render(); }; #refreshCount() { const sync = this.#syncRef || (window as any).__communitySync; if (sync && !this.#syncRef) this.#syncRef = sync; const pins = sync?.doc?.commentPins; if (!pins) { if (this.#count !== 0) { this.#count = 0; this.#updateBadge(); } return; } const newCount = Object.values(pins).filter( (p: any) => !p.resolved ).length; if (newCount !== this.#count) { this.#count = newCount; this.#updateBadge(); } } #togglePanel() { this.#open = !this.#open; this.#render(); } #getPins(): [string, CommentPinData][] { const sync = this.#syncRef || (window as any).__communitySync; const pins = sync?.doc?.commentPins; if (!pins) return []; const entries = Object.entries(pins) as [string, CommentPinData][]; // Sort by most recent message timestamp, descending entries.sort((a, b) => { const aTime = this.#latestMessageTime(a[1]); const bTime = this.#latestMessageTime(b[1]); return bTime - aTime; }); return entries.slice(0, 50); } #latestMessageTime(pin: CommentPinData): number { if (!pin.messages || pin.messages.length === 0) return pin.createdAt || 0; return Math.max(...pin.messages.map(m => m.createdAt || 0)); } #timeAgo(ts: number): string { if (!ts) return ""; const diff = Date.now() - ts; const mins = Math.floor(diff / 60_000); if (mins < 1) return "just now"; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); return `${days}d ago`; } #esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } #truncate(s: string, max = 80): string { if (s.length <= max) return s; return s.slice(0, max) + "\u2026"; } #render() { const badge = this.#count > 0 ? `${this.#count > 99 ? "99+" : this.#count}` : ""; let panelHTML = ""; if (this.#open) { const pins = this.#getPins(); const header = `
Comments
`; let body: string; if (pins.length === 0) { body = `
No comments yet
`; } else { body = pins.map(([pinId, pin]) => { const lastMsg = pin.messages?.[pin.messages.length - 1]; const authorName = lastMsg?.createdByName || pin.createdByName || "Unknown"; const initial = authorName.charAt(0).toUpperCase(); const text = lastMsg?.text || "(no message)"; const time = this.#timeAgo(this.#latestMessageTime(pin)); const threadCount = (pin.messages?.length || 0); const resolvedBadge = pin.resolved ? `Resolved` : `Open`; return `
${initial}
${this.#esc(authorName)} ${resolvedBadge}
${this.#esc(this.#truncate(text))}
${threadCount > 1 ? `${threadCount} messages` : ""} ${time}
`; }).join(""); } panelHTML = `
${header}
${body}
`; } this.#shadow.innerHTML = `
${panelHTML}
`; // Toggle button this.#shadow.getElementById("comment-toggle")?.addEventListener("click", (e) => { e.stopPropagation(); this.#togglePanel(); }); // Close on outside click if (this.#open) { const closeHandler = () => { if (this.#open) { this.#open = false; this.#render(); } }; document.addEventListener("click", closeHandler, { once: true }); // Stop propagation from panel clicks this.#shadow.querySelector(".panel")?.addEventListener("click", (e) => e.stopPropagation()); } // New Comment button this.#shadow.querySelector('[data-action="new-comment"]')?.addEventListener("click", (e) => { e.stopPropagation(); this.#open = false; this.#render(); window.dispatchEvent(new CustomEvent("comment-pin-activate")); }); // Comment item clicks — focus the pin on canvas this.#shadow.querySelectorAll(".comment-item").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const pinId = (el as HTMLElement).dataset.pinId; if (pinId) { this.#open = false; this.#render(); window.dispatchEvent(new CustomEvent("comment-pin-focus", { detail: { pinId } })); } }); }); } /** Lightweight badge update when panel is closed. */ #updateBadge() { if (this.#open) { this.#render(); return; } const badge = this.#shadow.querySelector(".badge") as HTMLElement | null; if (!badge && this.#count > 0) { // Need full re-render to add badge this.#render(); return; } if (badge) { if (this.#count > 0) { badge.textContent = this.#count > 99 ? "99+" : String(this.#count); badge.style.display = ""; } else { badge.style.display = "none"; } } } static define(tag = "rstack-comment-bell") { if (!customElements.get(tag)) customElements.define(tag, RStackCommentBell); } } const STYLES = ` :host { display: inline-flex; align-items: center; } .bell-wrapper { position: relative; } .comment-btn { position: relative; background: none; border: none; color: var(--rs-text-muted, #94a3b8); cursor: pointer; padding: 6px; border-radius: 8px; display: flex; align-items: center; justify-content: center; transition: color 0.15s, background 0.15s; } .comment-btn:hover { color: var(--rs-text-primary, #e2e8f0); background: var(--rs-bg-hover, rgba(255,255,255,0.05)); } .badge { position: absolute; top: 2px; right: 2px; min-width: 16px; height: 16px; border-radius: 8px; background: #ef4444; color: white; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; line-height: 1; pointer-events: none; } .panel { position: absolute; top: 100%; right: 0; margin-top: 8px; width: 380px; max-height: 480px; display: flex; flex-direction: column; border-radius: 10px; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, rgba(255,255,255,0.1)); box-shadow: var(--rs-shadow-lg, 0 8px 30px rgba(0,0,0,0.3)); z-index: 200; animation: dropDown 0.15s ease; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } @keyframes dropDown { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } } .panel-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.1)); flex-shrink: 0; } .panel-title { font-size: 0.875rem; font-weight: 600; color: var(--rs-text-primary, #e2e8f0); } .new-comment-btn { background: linear-gradient(135deg, #14b8a6, #0d9488); border: none; color: white; font-size: 0.75rem; font-weight: 600; padding: 5px 12px; border-radius: 6px; cursor: pointer; transition: opacity 0.15s; } .new-comment-btn:hover { opacity: 0.85; } .panel-body { flex: 1; overflow-y: auto; } .panel-empty { padding: 32px 16px; text-align: center; color: var(--rs-text-muted, #94a3b8); font-size: 0.8rem; } .comment-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 16px; cursor: pointer; transition: background 0.15s; border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06)); } .comment-item:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.05)); } .comment-item:last-child { border-bottom: none; } .comment-item.resolved { opacity: 0.6; } .comment-avatar { width: 28px; height: 28px; border-radius: 50%; background: rgba(20,184,166,0.15); color: #14b8a6; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.75rem; flex-shrink: 0; margin-top: 2px; } .comment-content { flex: 1; min-width: 0; } .comment-top { display: flex; align-items: center; gap: 6px; margin-bottom: 2px; } .comment-author { font-size: 0.8rem; font-weight: 600; color: var(--rs-text-primary, #e2e8f0); } .resolved-badge, .open-badge { font-size: 0.6rem; font-weight: 700; padding: 1px 5px; border-radius: 9999px; text-transform: uppercase; letter-spacing: 0.03em; } .resolved-badge { background: rgba(148,163,184,0.15); color: #94a3b8; } .open-badge { background: rgba(20,184,166,0.15); color: #14b8a6; } .comment-text { font-size: 0.78rem; color: var(--rs-text-secondary, #cbd5e1); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .comment-meta { display: flex; gap: 8px; margin-top: 3px; font-size: 0.68rem; color: var(--rs-text-muted, #64748b); } .thread-count { font-weight: 500; } @media (max-width: 640px) { .comment-btn { display: none; } .panel { position: fixed; top: 60px; right: 8px; left: 8px; width: auto; max-height: calc(100vh - 80px); } } `;