fix(shell): header button reliability — history, settings, comments

History panel: lazy doc acquisition from CommunitySync/offline runtime
on open + listen for community-sync-ready event for late connections.

Space settings: reposition panel after every re-render (async data loads
were destroying inline positioning styles), clamp max-height to viewport,
fix wrong global name (__rspaceCommunitySync → __communitySync).

Comment bell: render DOM once, update badge without innerHTML churn,
listen for community-sync-ready event, cache sync reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 16:04:11 -07:00
parent c35f39380e
commit 772e5e4352
3 changed files with 81 additions and 15 deletions

View File

@ -17,6 +17,7 @@ export class RStackCommentBell extends HTMLElement {
#shadow: ShadowRoot; #shadow: ShadowRoot;
#count = 0; #count = 0;
#pollTimer: ReturnType<typeof setInterval> | null = null; #pollTimer: ReturnType<typeof setInterval> | null = null;
#syncRef: any = null;
constructor() { constructor() {
super(); super();
@ -25,27 +26,42 @@ export class RStackCommentBell extends HTMLElement {
connectedCallback() { connectedCallback() {
this.#render(); this.#render();
// Try to pick up existing CommunitySync
this.#syncRef = (window as any).__communitySync || null;
this.#refreshCount(); this.#refreshCount();
this.#pollTimer = setInterval(() => this.#refreshCount(), POLL_INTERVAL); this.#pollTimer = setInterval(() => this.#refreshCount(), POLL_INTERVAL);
window.addEventListener("comment-pins-changed", this.#onPinsChanged); window.addEventListener("comment-pins-changed", this.#onPinsChanged);
// Listen for CommunitySync becoming available (may connect after mount)
document.addEventListener("community-sync-ready", this.#onSyncReady);
} }
disconnectedCallback() { disconnectedCallback() {
if (this.#pollTimer) clearInterval(this.#pollTimer); if (this.#pollTimer) clearInterval(this.#pollTimer);
window.removeEventListener("comment-pins-changed", this.#onPinsChanged); 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 = () => { #onPinsChanged = () => {
this.#refreshCount(); this.#refreshCount();
}; };
#refreshCount() { #refreshCount() {
const sync = (window as any).__communitySync; // 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; const pins = sync?.doc?.commentPins;
if (!pins) { if (!pins) {
if (this.#count !== 0) { if (this.#count !== 0) {
this.#count = 0; this.#count = 0;
this.#render(); this.#updateBadge();
} }
return; return;
} }
@ -54,23 +70,19 @@ export class RStackCommentBell extends HTMLElement {
).length; ).length;
if (newCount !== this.#count) { if (newCount !== this.#count) {
this.#count = newCount; this.#count = newCount;
this.#render(); this.#updateBadge();
} }
} }
/** Full render — only called once in connectedCallback. */
#render() { #render() {
const badge =
this.#count > 0
? `<span class="badge">${this.#count > 99 ? "99+" : this.#count}</span>`
: "";
this.#shadow.innerHTML = ` this.#shadow.innerHTML = `
<style>${STYLES}</style> <style>${STYLES}</style>
<button class="comment-btn" aria-label="Leave Comment" title="Leave Comment (/)"> <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"> <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"/> <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> </svg>
${badge} <span class="badge" style="display:none"></span>
</button> </button>
`; `;
@ -82,6 +94,18 @@ export class RStackCommentBell extends HTMLElement {
}); });
} }
/** Lightweight badge update — no innerHTML churn. */
#updateBadge() {
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";
}
}
static define(tag = "rstack-comment-bell") { static define(tag = "rstack-comment-bell") {
if (!customElements.get(tag)) customElements.define(tag, RStackCommentBell); if (!customElements.get(tag)) customElements.define(tag, RStackCommentBell);
} }

View File

@ -58,20 +58,49 @@ export class RStackHistoryPanel extends HTMLElement {
} }
}; };
private _syncReadyHandler = (e: Event) => {
const sync = (e as CustomEvent).detail?.sync;
if (sync?.doc) {
this._doc = sync.doc;
if (this._open) {
this._refreshHistory();
this._render();
}
}
};
connectedCallback() { connectedCallback() {
if (!this.shadowRoot) this.attachShadow({ mode: "open" }); if (!this.shadowRoot) this.attachShadow({ mode: "open" });
this._render(); this._render();
// Pick up CommunitySync when it connects (may be after mount)
document.addEventListener("community-sync-ready", this._syncReadyHandler);
} }
disconnectedCallback() { disconnectedCallback() {
document.removeEventListener("click", this._clickOutsideHandler, true); document.removeEventListener("click", this._clickOutsideHandler, true);
document.removeEventListener("community-sync-ready", this._syncReadyHandler);
} }
open() { open() {
this._open = true; this._open = true;
// Lazy doc acquisition: try CommunitySync global first, then offline runtime
if (!this._doc) {
const sync = (window as any).__communitySync;
if (sync?.doc) {
this._doc = sync.doc;
} else {
const rt = (window as any).__rspaceOfflineRuntime;
if (rt?.documentManager) {
const docs = rt.documentManager.listAll?.() || [];
if (docs.length > 0) {
const doc = rt.documentManager.get(docs[0]);
if (doc) this._doc = doc;
}
}
}
}
this._refreshHistory(); this._refreshHistory();
this._render(); this._render();
this._positionPanel();
document.getElementById("history-btn")?.classList.add("active"); document.getElementById("history-btn")?.classList.add("active");
document.addEventListener("click", this._clickOutsideHandler, true); document.addEventListener("click", this._clickOutsideHandler, true);
} }
@ -88,13 +117,16 @@ export class RStackHistoryPanel extends HTMLElement {
if (this._open) this.close(); else this.open(); if (this._open) this.close(); else this.open();
} }
/** Position the panel below the history button, right-aligned to button. */ /** Position the panel below the history button, right-aligned to button, clamped to viewport. */
private _positionPanel() { private _positionPanel() {
const panel = this.shadowRoot?.querySelector(".panel") as HTMLElement | null; const panel = this.shadowRoot?.querySelector(".panel") as HTMLElement | null;
const btn = document.getElementById("history-btn"); const btn = document.getElementById("history-btn");
if (!panel || !btn) return; if (!panel || !btn) return;
const rect = btn.getBoundingClientRect(); const rect = btn.getBoundingClientRect();
panel.style.top = `${rect.bottom + 6}px`; const topPos = rect.bottom + 6;
panel.style.top = `${topPos}px`;
// Clamp max-height so panel never overflows bottom of viewport
panel.style.maxHeight = `${window.innerHeight - topPos - 8}px`;
// Right-align to button's right edge, but clamp so left edge stays on screen // Right-align to button's right edge, but clamp so left edge stays on screen
const rightOffset = window.innerWidth - rect.right; const rightOffset = window.innerWidth - rect.right;
panel.style.right = `${rightOffset}px`; panel.style.right = `${rightOffset}px`;
@ -369,6 +401,8 @@ export class RStackHistoryPanel extends HTMLElement {
`; `;
this._bindEvents(); this._bindEvents();
// Re-position after every render since innerHTML replacement destroys inline styles
if (this._open) this._positionPanel();
} }
private _renderTimeMachineDetails() { private _renderTimeMachineDetails() {

View File

@ -115,22 +115,25 @@ export class RStackSpaceSettings extends HTMLElement {
if (this._open) this.close(); else this.open(); if (this._open) this.close(); else this.open();
} }
/** Position the panel below the settings button, left-aligned with screen clamping. */ /** Position the panel below the settings button, clamped to viewport. */
private _positionPanel() { private _positionPanel() {
const panel = this.shadowRoot?.querySelector(".panel") as HTMLElement | null; const panel = this.shadowRoot?.querySelector(".panel") as HTMLElement | null;
const btn = document.getElementById("settings-btn"); const btn = document.getElementById("settings-btn");
if (!panel || !btn) return; if (!panel || !btn) return;
const rect = btn.getBoundingClientRect(); const rect = btn.getBoundingClientRect();
const panelWidth = Math.min(380, window.innerWidth * 0.9); const panelWidth = Math.min(380, window.innerWidth * 0.9);
const topPos = rect.bottom + 6;
// Align panel's left edge with the button, but clamp so it stays on screen // Align panel's left edge with the button, but clamp so it stays on screen
let left = rect.left; let left = rect.left;
if (left + panelWidth > window.innerWidth - 8) { if (left + panelWidth > window.innerWidth - 8) {
left = window.innerWidth - panelWidth - 8; left = window.innerWidth - panelWidth - 8;
} }
if (left < 8) left = 8; if (left < 8) left = 8;
panel.style.top = `${rect.bottom + 6}px`; panel.style.top = `${topPos}px`;
panel.style.left = `${left}px`; panel.style.left = `${left}px`;
panel.style.right = "auto"; panel.style.right = "auto";
// Clamp max-height so panel never overflows bottom of viewport
panel.style.maxHeight = `${window.innerHeight - topPos - 8}px`;
} }
private async _loadData() { private async _loadData() {
@ -140,7 +143,7 @@ export class RStackSpaceSettings extends HTMLElement {
const token = getToken(); const token = getToken();
// Load community data from WS-synced doc (via global) // Load community data from WS-synced doc (via global)
const sync = (window as any).__rspaceCommunitySync; const sync = (window as any).__communitySync;
if (sync?.doc) { if (sync?.doc) {
const data = sync.doc; const data = sync.doc;
this._ownerDID = data.meta?.ownerDID || ""; this._ownerDID = data.meta?.ownerDID || "";
@ -297,6 +300,9 @@ export class RStackSpaceSettings extends HTMLElement {
return; return;
} }
// Track whether this is a re-render (panel already exists)
const isRerender = !!this.shadowRoot.querySelector(".panel");
const roleOptions = (currentRole: string) => { const roleOptions = (currentRole: string) => {
const roles = ["viewer", "member", "moderator", "admin"]; const roles = ["viewer", "member", "moderator", "admin"];
return roles.map(r => `<option value="${r}" ${r === currentRole ? "selected" : ""}>${r}</option>`).join(""); return roles.map(r => `<option value="${r}" ${r === currentRole ? "selected" : ""}>${r}</option>`).join("");
@ -469,6 +475,8 @@ export class RStackSpaceSettings extends HTMLElement {
`; `;
this._bindEvents(); this._bindEvents();
// Re-position after every render since innerHTML replacement destroys inline styles
this._positionPanel();
} }
private _bindEvents() { private _bindEvents() {