607 lines
17 KiB
TypeScript
607 lines
17 KiB
TypeScript
/**
|
|
* <rstack-comment-bell> — Comment button with dropdown panel.
|
|
*
|
|
* Shows a chat-bubble icon in the header bar. Badge displays the count
|
|
* of unresolved comment pins. Context-aware data source:
|
|
* - Canvas page: reads from `window.__communitySync?.doc?.commentPins`
|
|
* - Module pages: reads from the module-comments Automerge doc via runtime
|
|
*
|
|
* Listens for `comment-pins-changed` + `module-comment-pins-changed` on `window`.
|
|
* Polls every 30s as fallback (sync may appear after component mounts).
|
|
*/
|
|
|
|
import { moduleCommentsSchema, moduleCommentsDocId } from '../module-comment-schemas';
|
|
import type { ModuleCommentPin, ModuleCommentsDoc } from '../module-comment-types';
|
|
import type { DocumentId } from '../local-first/document';
|
|
|
|
const POLL_INTERVAL = 30_000;
|
|
|
|
interface CommentMessage {
|
|
text: string;
|
|
createdBy: string;
|
|
createdByName?: string;
|
|
createdAt: number;
|
|
}
|
|
|
|
interface CommentPinData {
|
|
messages: CommentMessage[];
|
|
anchor?: { x: number; y: number; type?: string; elementId?: string; moduleId?: string };
|
|
resolved?: boolean;
|
|
createdBy?: string;
|
|
createdByName?: string;
|
|
createdAt?: number;
|
|
}
|
|
|
|
type PinSource = 'canvas' | 'module';
|
|
|
|
export class RStackCommentBell extends HTMLElement {
|
|
#shadow: ShadowRoot;
|
|
#count = 0;
|
|
#open = false;
|
|
#pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
#syncRef: any = null;
|
|
#pinSource: PinSource = 'canvas';
|
|
|
|
// Module comments state
|
|
#moduleDocId: DocumentId | null = null;
|
|
#runtime: any = null;
|
|
#unsubModuleChange: (() => void) | null = null;
|
|
#runtimePollInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
constructor() {
|
|
super();
|
|
this.#shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.#render();
|
|
|
|
// Determine context: module page or canvas
|
|
const moduleCommentsEl = document.querySelector('rstack-module-comments');
|
|
if (moduleCommentsEl) {
|
|
this.#pinSource = 'module';
|
|
this.#tryConnectRuntime();
|
|
} else {
|
|
this.#pinSource = 'canvas';
|
|
this.#syncRef = (window as any).__communitySync || null;
|
|
}
|
|
|
|
this.#refreshCount();
|
|
this.#pollTimer = setInterval(() => this.#refreshCount(), POLL_INTERVAL);
|
|
window.addEventListener("comment-pins-changed", this.#onPinsChanged);
|
|
window.addEventListener("module-comment-pins-changed", this.#onPinsChanged);
|
|
document.addEventListener("community-sync-ready", this.#onSyncReady);
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
if (this.#pollTimer) clearInterval(this.#pollTimer);
|
|
if (this.#runtimePollInterval) clearInterval(this.#runtimePollInterval);
|
|
if (this.#unsubModuleChange) this.#unsubModuleChange();
|
|
window.removeEventListener("comment-pins-changed", this.#onPinsChanged);
|
|
window.removeEventListener("module-comment-pins-changed", this.#onPinsChanged);
|
|
document.removeEventListener("community-sync-ready", this.#onSyncReady);
|
|
}
|
|
|
|
// ── Runtime Connection (for module comments) ──
|
|
|
|
#tryConnectRuntime() {
|
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
|
if (runtime?.isInitialized) {
|
|
this.#onRuntimeReady(runtime);
|
|
} else {
|
|
let polls = 0;
|
|
this.#runtimePollInterval = setInterval(() => {
|
|
const rt = (window as any).__rspaceOfflineRuntime;
|
|
if (rt?.isInitialized) {
|
|
clearInterval(this.#runtimePollInterval!);
|
|
this.#runtimePollInterval = null;
|
|
this.#onRuntimeReady(rt);
|
|
} else if (++polls > 20) {
|
|
clearInterval(this.#runtimePollInterval!);
|
|
this.#runtimePollInterval = null;
|
|
}
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
async #onRuntimeReady(runtime: any) {
|
|
this.#runtime = runtime;
|
|
const space = document.body?.getAttribute("data-space-slug");
|
|
if (!space) return;
|
|
|
|
this.#moduleDocId = moduleCommentsDocId(space);
|
|
await runtime.subscribe(this.#moduleDocId, moduleCommentsSchema);
|
|
|
|
this.#unsubModuleChange = runtime.onChange(this.#moduleDocId, () => {
|
|
this.#refreshCount();
|
|
if (this.#open) this.#render();
|
|
});
|
|
|
|
this.#refreshCount();
|
|
}
|
|
|
|
#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();
|
|
};
|
|
|
|
// ── Data Access ──
|
|
|
|
#getCurrentModuleId(): string {
|
|
return document.body?.getAttribute("data-module-id") || "rspace";
|
|
}
|
|
|
|
#getModulePins(): [string, CommentPinData][] {
|
|
if (!this.#runtime || !this.#moduleDocId) return [];
|
|
const doc = this.#runtime.get(this.#moduleDocId) as ModuleCommentsDoc | undefined;
|
|
if (!doc?.pins) return [];
|
|
|
|
const moduleId = this.#getCurrentModuleId();
|
|
const entries: [string, CommentPinData][] = [];
|
|
for (const [pinId, pin] of Object.entries(doc.pins)) {
|
|
if (pin.anchor.moduleId !== moduleId) continue;
|
|
entries.push([pinId, {
|
|
messages: (pin.messages || []).map(m => ({
|
|
text: m.text,
|
|
createdBy: m.authorId,
|
|
createdByName: m.authorName,
|
|
createdAt: m.createdAt,
|
|
})),
|
|
anchor: { x: 0, y: 0, type: 'element', elementId: pin.anchor.elementId, moduleId: pin.anchor.moduleId },
|
|
resolved: pin.resolved,
|
|
createdBy: pin.createdBy,
|
|
createdByName: pin.createdByName,
|
|
createdAt: pin.createdAt,
|
|
}]);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
#getCanvasPins(): [string, CommentPinData][] {
|
|
const sync = this.#syncRef || (window as any).__communitySync;
|
|
const pins = sync?.doc?.commentPins;
|
|
if (!pins) return [];
|
|
return Object.entries(pins) as [string, CommentPinData][];
|
|
}
|
|
|
|
#refreshCount() {
|
|
let pins: [string, CommentPinData][];
|
|
if (this.#pinSource === 'module') {
|
|
pins = this.#getModulePins();
|
|
} else {
|
|
const sync = this.#syncRef || (window as any).__communitySync;
|
|
if (sync && !this.#syncRef) this.#syncRef = sync;
|
|
pins = this.#getCanvasPins();
|
|
}
|
|
|
|
const newCount = pins.filter(([, p]) => !p.resolved).length;
|
|
if (newCount !== this.#count) {
|
|
this.#count = newCount;
|
|
this.#updateBadge();
|
|
}
|
|
}
|
|
|
|
#togglePanel() {
|
|
this.#open = !this.#open;
|
|
this.#render();
|
|
}
|
|
|
|
#getPins(): [string, CommentPinData][] {
|
|
const entries = this.#pinSource === 'module'
|
|
? this.#getModulePins()
|
|
: this.#getCanvasPins();
|
|
|
|
// 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, ">").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
|
|
? `<span class="badge">${this.#count > 99 ? "99+" : this.#count}</span>`
|
|
: "";
|
|
|
|
let panelHTML = "";
|
|
if (this.#open) {
|
|
const pins = this.#getPins();
|
|
const header = `
|
|
<div class="panel-header">
|
|
<span class="panel-title">Comments</span>
|
|
<button class="new-comment-btn" data-action="new-comment">+ New Comment</button>
|
|
</div>
|
|
`;
|
|
|
|
let body: string;
|
|
if (pins.length === 0) {
|
|
body = `<div class="panel-empty">No comments yet</div>`;
|
|
} 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
|
|
? `<span class="resolved-badge">Resolved</span>`
|
|
: `<span class="open-badge">Open</span>`;
|
|
|
|
// Show element anchor for module comments
|
|
const anchorLabel = pin.anchor?.elementId
|
|
? `<span class="anchor-label">${this.#esc(pin.anchor.elementId)}</span>`
|
|
: "";
|
|
|
|
return `
|
|
<div class="comment-item ${pin.resolved ? "resolved" : ""}" data-pin-id="${this.#esc(pinId)}" data-source="${this.#pinSource}" data-element-id="${this.#esc(pin.anchor?.elementId || "")}">
|
|
<div class="comment-avatar">${initial}</div>
|
|
<div class="comment-content">
|
|
<div class="comment-top">
|
|
<span class="comment-author">${this.#esc(authorName)}</span>
|
|
${resolvedBadge}
|
|
</div>
|
|
${anchorLabel}
|
|
<div class="comment-text">${this.#esc(this.#truncate(text))}</div>
|
|
<div class="comment-meta">
|
|
${threadCount > 1 ? `<span class="thread-count">${threadCount} messages</span>` : ""}
|
|
<span class="comment-time">${time}</span>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join("");
|
|
}
|
|
|
|
panelHTML = `<div class="panel">${header}<div class="panel-body">${body}</div></div>`;
|
|
}
|
|
|
|
this.#shadow.innerHTML = `
|
|
<style>${STYLES}</style>
|
|
<div class="bell-wrapper">
|
|
<button class="comment-btn" id="comment-toggle" aria-label="Comments" title="Comments">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/>
|
|
</svg>
|
|
${badge}
|
|
</button>
|
|
${panelHTML}
|
|
</div>
|
|
`;
|
|
|
|
// 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("pointerdown", closeHandler, { once: true });
|
|
|
|
// Stop propagation from panel clicks
|
|
this.#shadow.querySelector(".panel")?.addEventListener("pointerdown", (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 (context-dependent)
|
|
this.#shadow.querySelectorAll(".comment-item").forEach((el) => {
|
|
el.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const pinId = (el as HTMLElement).dataset.pinId;
|
|
const source = (el as HTMLElement).dataset.source;
|
|
const elementId = (el as HTMLElement).dataset.elementId;
|
|
if (!pinId) return;
|
|
|
|
this.#open = false;
|
|
this.#render();
|
|
|
|
if (source === 'module') {
|
|
window.dispatchEvent(new CustomEvent("module-comment-pin-focus", {
|
|
detail: { pinId, elementId },
|
|
}));
|
|
} else {
|
|
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, .comment-btn:active {
|
|
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, .new-comment-btn:active {
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.panel-body {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
touch-action: pan-y;
|
|
}
|
|
|
|
.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, .comment-item:active {
|
|
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);
|
|
}
|
|
|
|
.anchor-label {
|
|
font-size: 0.65rem;
|
|
color: var(--rs-text-muted, #64748b);
|
|
margin-bottom: 2px;
|
|
display: block;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
}
|
|
`;
|