Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-25 18:21:08 -07:00
commit 8dd1d53297
1 changed files with 113 additions and 4 deletions

View File

@ -56,6 +56,8 @@ export class RStackCollabOverlay extends HTMLElement {
#connState: 'connected' | 'offline' | 'reconnecting' | 'connecting' = 'connecting'; #connState: 'connected' | 'offline' | 'reconnecting' | 'connecting' = 'connecting';
#externalPeers = false; // true for canvas — peers fed in via public API #externalPeers = false; // true for canvas — peers fed in via public API
#openActionsId: string | null = null; // which peer's actions dropdown is open #openActionsId: string | null = null; // which peer's actions dropdown is open
#spaceMembers: { did: string; displayName: string; role: string }[] = [];
#space: string | null = null;
constructor() { constructor() {
super(); super();
@ -64,6 +66,7 @@ export class RStackCollabOverlay extends HTMLElement {
connectedCallback() { connectedCallback() {
this.#moduleId = this.getAttribute('module-id'); this.#moduleId = this.getAttribute('module-id');
this.#space = this.getAttribute('space');
this.#badgeOnly = this.getAttribute('mode') === 'badge-only'; this.#badgeOnly = this.getAttribute('mode') === 'badge-only';
this.#soloMode = localStorage.getItem('rspace_solo_mode') === '1'; this.#soloMode = localStorage.getItem('rspace_solo_mode') === '1';
@ -72,6 +75,8 @@ export class RStackCollabOverlay extends HTMLElement {
this.#externalPeers = true; this.#externalPeers = true;
// Dispatch initial solo-mode event for canvas PresenceManager // Dispatch initial solo-mode event for canvas PresenceManager
document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } })); document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } }));
// Canvas: load members immediately (no runtime wait)
this.#loadSpaceMembers();
} }
// Resolve local identity // Resolve local identity
@ -186,6 +191,9 @@ export class RStackCollabOverlay extends HTMLElement {
this.#localColor = this.#colorForPeer(this.#localPeerId!); this.#localColor = this.#colorForPeer(this.#localPeerId!);
this.#connState = 'connected'; this.#connState = 'connected';
// Load space members for offline display
this.#loadSpaceMembers();
if (this.#docId) { if (this.#docId) {
this.#connectToDoc(); this.#connectToDoc();
} }
@ -228,6 +236,58 @@ export class RStackCollabOverlay extends HTMLElement {
}); });
} }
async #loadSpaceMembers(retry = true) {
let members: Record<string, { role: string; displayName?: string }> = {};
// 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<string, { username: string; displayName: string }> = {};
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() { #connectToDoc() {
const runtime = (window as any).__rspaceOfflineRuntime; const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime || !this.#docId) return; if (!runtime || !this.#docId) return;
@ -415,8 +475,8 @@ export class RStackCollabOverlay extends HTMLElement {
<div class="collab-badge visible" id="badge"></div> <div class="collab-badge visible" id="badge"></div>
<div class="people-panel" id="people-panel"> <div class="people-panel" id="people-panel">
<div class="people-panel-header"> <div class="people-panel-header">
<h3>People Online</h3> <h3>People</h3>
<span class="panel-count" id="panel-count">1</span> <span class="panel-count" id="panel-count">1 online</span>
</div> </div>
<div class="people-list" id="people-list"></div> <div class="people-list" id="people-list"></div>
</div> </div>
@ -448,8 +508,8 @@ export class RStackCollabOverlay extends HTMLElement {
const countEl = this.#shadow.getElementById('panel-count'); const countEl = this.#shadow.getElementById('panel-count');
if (!list) return; if (!list) return;
const count = this.#peers.size + 1; const onlineCount = this.#peers.size + 1;
if (countEl) countEl.textContent = this.#connState === 'connected' ? String(count) : '\u2014'; if (countEl) countEl.textContent = this.#connState === 'connected' ? `${onlineCount} online` : '\u2014';
const fragments: string[] = []; 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<string>();
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(`<div class="section-divider">Offline</div>`);
for (const m of offlineMembers) {
fragments.push(`
<div class="people-row offline">
<span class="dot"></span>
<div class="name-block">
<span class="name">${this.#escHtml(m.displayName)}</span>
<span class="peer-context">${this.#escHtml(m.role)}</span>
</div>
</div>
`);
}
}
}
list.innerHTML = fragments.join(''); list.innerHTML = fragments.join('');
// Wire up event delegation on the list // Wire up event delegation on the list
@ -868,6 +957,26 @@ const OVERLAY_CSS = `
font-weight: normal; 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 (Online/Offline) ── */
.mode-toggle { .mode-toggle {