diff --git a/shared/components/rstack-collab-overlay.ts b/shared/components/rstack-collab-overlay.ts index fe8a023..2b4c9dd 100644 --- a/shared/components/rstack-collab-overlay.ts +++ b/shared/components/rstack-collab-overlay.ts @@ -56,6 +56,8 @@ export class RStackCollabOverlay extends HTMLElement { #connState: 'connected' | 'offline' | 'reconnecting' | 'connecting' = 'connecting'; #externalPeers = false; // true for canvas — peers fed in via public API #openActionsId: string | null = null; // which peer's actions dropdown is open + #spaceMembers: { did: string; displayName: string; role: string }[] = []; + #space: string | null = null; constructor() { super(); @@ -64,6 +66,7 @@ export class RStackCollabOverlay extends HTMLElement { connectedCallback() { this.#moduleId = this.getAttribute('module-id'); + this.#space = this.getAttribute('space'); this.#badgeOnly = this.getAttribute('mode') === 'badge-only'; this.#soloMode = localStorage.getItem('rspace_solo_mode') === '1'; @@ -72,6 +75,8 @@ export class RStackCollabOverlay extends HTMLElement { this.#externalPeers = true; // Dispatch initial solo-mode event for canvas PresenceManager document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } })); + // Canvas: load members immediately (no runtime wait) + this.#loadSpaceMembers(); } // Resolve local identity @@ -186,6 +191,9 @@ export class RStackCollabOverlay extends HTMLElement { this.#localColor = this.#colorForPeer(this.#localPeerId!); this.#connState = 'connected'; + // Load space members for offline display + this.#loadSpaceMembers(); + if (this.#docId) { this.#connectToDoc(); } @@ -228,6 +236,58 @@ export class RStackCollabOverlay extends HTMLElement { }); } + async #loadSpaceMembers(retry = true) { + let members: Record = {}; + + // Try CRDT doc first (same pattern as rstack-space-settings) + const sync = (window as any).__communitySync; + if (sync?.doc?.members) { + members = sync.doc.members; + } else if (this.#space) { + // Fallback: fetch from API + const host = window.location.hostname; + const isSubdomain = host.split('.').length >= 3 && !host.startsWith('www.'); + const metaUrl = isSubdomain ? '/rspace/api/meta' : `/${this.#space}/rspace/api/meta`; + try { + const token = localStorage.getItem('encryptid_token'); + const res = await fetch(metaUrl, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (res.ok) { + const json = await res.json(); + if (json.meta?.members) members = json.meta.members; + } + } catch { /* offline or error */ } + } + + const dids = Object.keys(members); + if (!dids.length) { + // CRDT doc may not be synced yet — retry once after 5s + if (retry) setTimeout(() => this.#loadSpaceMembers(false), 5000); + return; + } + + // Resolve display names via EncryptID + let resolved: Record = {}; + try { + const res = await fetch('/api/users/resolve-dids', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dids }), + }); + if (res.ok) resolved = await res.json(); + } catch { /* offline */ } + + this.#spaceMembers = dids.map(did => ({ + did, + displayName: resolved[did]?.displayName || resolved[did]?.username || members[did].displayName || did.slice(0, 12), + role: members[did].role || 'member', + })); + + this.#renderBadge(); + if (this.#panelOpen) this.#renderPanel(); + } + #connectToDoc() { const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime || !this.#docId) return; @@ -415,8 +475,8 @@ export class RStackCollabOverlay extends HTMLElement {
-

People Online

- 1 +

People

+ 1 online
@@ -448,8 +508,8 @@ export class RStackCollabOverlay extends HTMLElement { const countEl = this.#shadow.getElementById('panel-count'); if (!list) return; - const count = this.#peers.size + 1; - if (countEl) countEl.textContent = this.#connState === 'connected' ? String(count) : '\u2014'; + const onlineCount = this.#peers.size + 1; + if (countEl) countEl.textContent = this.#connState === 'connected' ? `${onlineCount} online` : '\u2014'; const fragments: string[] = []; @@ -506,6 +566,35 @@ export class RStackCollabOverlay extends HTMLElement { } } + // Offline space members (not currently online) + if (this.#spaceMembers.length > 0) { + // Build set of online usernames (case-insensitive) for cross-referencing + const onlineNames = new Set(); + onlineNames.add(this.#localUsername.toLowerCase()); + for (const peer of this.#peers.values()) { + onlineNames.add(peer.username.toLowerCase()); + } + + const offlineMembers = this.#spaceMembers.filter( + m => !onlineNames.has(m.displayName.toLowerCase()) + ); + + if (offlineMembers.length > 0) { + fragments.push(`
Offline
`); + for (const m of offlineMembers) { + fragments.push(` +
+ +
+ ${this.#escHtml(m.displayName)} + ${this.#escHtml(m.role)} +
+
+ `); + } + } + } + list.innerHTML = fragments.join(''); // Wire up event delegation on the list @@ -868,6 +957,26 @@ const OVERLAY_CSS = ` font-weight: normal; } + /* ── Offline members ── */ + + .section-divider { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--rs-text-muted, #64748b); + padding: 8px 10px 4px; + margin-top: 4px; + } + + .people-row.offline .dot { + background: #6b7280 !important; + } + + .people-row.offline .name { + color: var(--rs-text-muted, #64748b); + } + /* ── Mode toggle (Online/Offline) ── */ .mode-toggle {