feat: settings gear shows members first, comment bell gets dropdown panel

Settings panel now shows Members + Add Member sections at the top
regardless of which rApp is active, making invite/membership the
primary action. Comment bell replaced with a dropdown panel showing
all comment threads sorted by recency, with a "New Comment" button
to enter pin-placement mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-31 10:16:30 -07:00
parent 0c83a8257c
commit c82646c458
2 changed files with 375 additions and 53 deletions

View File

@ -1,10 +1,10 @@
/**
* <rstack-comment-bell> Comment button with unresolved-count badge.
* <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 on the current canvas. Clicking dispatches
* a `comment-pin-activate` event on `window` so canvas.html can enter
* pin-placement mode.
* 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).
@ -13,9 +13,26 @@
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<typeof setInterval> | null = null;
#syncRef: any = null;
@ -26,12 +43,10 @@ export class RStackCommentBell extends HTMLElement {
connectedCallback() {
this.#render();
// Try to pick up existing CommunitySync
this.#syncRef = (window as any).__communitySync || null;
this.#refreshCount();
this.#pollTimer = setInterval(() => this.#refreshCount(), POLL_INTERVAL);
window.addEventListener("comment-pins-changed", this.#onPinsChanged);
// Listen for CommunitySync becoming available (may connect after mount)
document.addEventListener("community-sync-ready", this.#onSyncReady);
}
@ -51,10 +66,10 @@ export class RStackCommentBell extends HTMLElement {
#onPinsChanged = () => {
this.#refreshCount();
if (this.#open) this.#render();
};
#refreshCount() {
// Prefer cached ref, fall back to global
const sync = this.#syncRef || (window as any).__communitySync;
if (sync && !this.#syncRef) this.#syncRef = sync;
const pins = sync?.doc?.commentPins;
@ -74,35 +89,177 @@ export class RStackCommentBell extends HTMLElement {
}
}
/** Full render — only called once in connectedCallback. */
#render() {
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<button class="comment-btn" aria-label="Leave Comment" title="Leave Comment (/)">
<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>
<span class="badge" style="display:none"></span>
</button>
`;
this.#shadow
.querySelector(".comment-btn")
?.addEventListener("click", (e) => {
e.stopPropagation();
window.dispatchEvent(new CustomEvent("comment-pin-activate"));
});
#togglePanel() {
this.#open = !this.#open;
this.#render();
}
/** Lightweight badge update — no innerHTML churn. */
#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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
#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>`;
return `
<div class="comment-item ${pin.resolved ? "resolved" : ""}" data-pin-id="${this.#esc(pinId)}">
<div class="comment-avatar">${initial}</div>
<div class="comment-content">
<div class="comment-top">
<span class="comment-author">${this.#esc(authorName)}</span>
${resolvedBadge}
</div>
<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("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) return;
if (this.#count > 0) {
badge.textContent = this.#count > 99 ? "99+" : String(this.#count);
badge.style.display = "";
} else {
badge.style.display = "none";
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";
}
}
}
@ -117,6 +274,10 @@ const STYLES = `
align-items: center;
}
.bell-wrapper {
position: relative;
}
.comment-btn {
position: relative;
background: none;
@ -154,7 +315,170 @@ const STYLES = `
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);
}
}
`;

View File

@ -413,9 +413,7 @@ export class RStackSpaceSettings extends HTMLElement {
`;
}
const panelTitle = this._moduleConfig
? `${this._moduleConfig.icon} ${this._esc(this._moduleConfig.name)}`
: "Space Settings";
const panelTitle = "Space Settings";
this.shadowRoot.innerHTML = `
<style>${PANEL_CSS}</style>
@ -426,24 +424,6 @@ export class RStackSpaceSettings extends HTMLElement {
</div>
<div class="panel-content">
${moduleSettingsHTML}
${this._isAdmin ? `
<section class="section">
<h3>Space Settings</h3>
<label class="field-label">Visibility</label>
<select class="input" id="space-visibility">
<option value="public" ${this._visibility === "public" ? "selected" : ""}>Public anyone can read</option>
<option value="permissioned" ${this._visibility === "permissioned" ? "selected" : ""}>Permissioned sign in to access</option>
<option value="private" ${this._visibility === "private" ? "selected" : ""}>Private invite only</option>
</select>
<label class="field-label" style="margin-top:8px">Description</label>
<textarea class="input" id="space-description" rows="2" placeholder="Optional description…" style="resize:vertical">${this._esc(this._description)}</textarea>
<button class="add-btn" id="space-save" style="margin-top:8px" ${this._spaceSaving ? "disabled" : ""}>${this._spaceSaving ? "Saving…" : "Save"}</button>
${this._spaceSaveMsg ? `<span class="space-save-msg">${this._esc(this._spaceSaveMsg)}</span>` : ""}
</section>
` : ""}
<section class="section">
<h3>Members <span class="count">${this._members.length}</span></h3>
<div class="members-list">${membersHTML}</div>
@ -507,6 +487,24 @@ export class RStackSpaceSettings extends HTMLElement {
<div class="invites-list">${invitesHTML}</div>
</section>
` : ""}
${moduleSettingsHTML}
${this._isAdmin ? `
<section class="section">
<h3>Space Settings</h3>
<label class="field-label">Visibility</label>
<select class="input" id="space-visibility">
<option value="public" ${this._visibility === "public" ? "selected" : ""}>Public anyone can read</option>
<option value="permissioned" ${this._visibility === "permissioned" ? "selected" : ""}>Permissioned sign in to access</option>
<option value="private" ${this._visibility === "private" ? "selected" : ""}>Private invite only</option>
</select>
<label class="field-label" style="margin-top:8px">Description</label>
<textarea class="input" id="space-description" rows="2" placeholder="Optional description…" style="resize:vertical">${this._esc(this._description)}</textarea>
<button class="add-btn" id="space-save" style="margin-top:8px" ${this._spaceSaving ? "disabled" : ""}>${this._spaceSaving ? "Saving…" : "Save"}</button>
${this._spaceSaveMsg ? `<span class="space-save-msg">${this._esc(this._spaceSaveMsg)}</span>` : ""}
</section>
` : ""}
</div>
</div>
`;