Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-23 12:11:11 -07:00
commit 5660a880fd
5 changed files with 140 additions and 27 deletions

View File

@ -113,16 +113,20 @@ class FolkJitsiRoom extends HTMLElement {
disableDeepLinking: true,
hideConferenceSubject: false,
toolbarButtons: [
"camera", "chat", "closedcaptions", "desktop",
"fullscreen", "hangup", "microphone", "participants-pane",
"raisehand", "select-background", "settings",
"tileview", "toggle-camera",
"camera", "microphone", "desktop", "hangup",
"raisehand", "tileview", "toggle-camera",
"fullscreen", "select-background",
],
// Hide panels that add stray close (×) buttons
disableChat: false,
participantsPane: { enabled: false },
},
interfaceConfigOverwrite: {
SHOW_JITSI_WATERMARK: false,
SHOW_WATERMARK_FOR_GUESTS: false,
SHOW_BRAND_WATERMARK: false,
CLOSE_PAGE_GUEST_HINT: false,
SHOW_PROMOTIONAL_CLOSE_PAGE: false,
},
});

View File

@ -104,30 +104,31 @@ const MI_STYLES = `<style>
routes.get("/room/:room", (c) => {
const space = c.req.param("space") || "demo";
const room = c.req.param("room");
const useApi = c.req.query("api") === "1";
const director = c.req.query("director") === "1";
const sessionId = c.req.query("session") || "";
if (useApi) {
const director = c.req.query("director") === "1";
const sessionId = c.req.query("session") || "";
return c.html(renderShell({
// Use Jitsi External API by default (cleaner toolbar, no stray close buttons).
// Pass ?iframe=1 to fall back to raw iframe embed.
if (c.req.query("iframe") === "1") {
return c.html(renderExternalAppShell({
title: `${room} — rMeets | rSpace`,
moduleId: "rmeets",
spaceSlug: space,
modules: getModuleInfoList(),
appUrl: `${JITSI_URL}/${encodeURIComponent(room)}`,
appName: "Jitsi Meet",
theme: "dark",
body: `<folk-jitsi-room room="${escapeHtml(room)}" jitsi-url="${escapeHtml(JITSI_URL)}" space="${escapeHtml(space)}"${director ? ` director="1" session="${escapeHtml(sessionId)}"` : ""}></folk-jitsi-room>`,
scripts: `<script type="module" src="/modules/rmeets/components/folk-jitsi-room.js"></script>`,
}));
}
return c.html(renderExternalAppShell({
return c.html(renderShell({
title: `${room} — rMeets | rSpace`,
moduleId: "rmeets",
spaceSlug: space,
modules: getModuleInfoList(),
appUrl: `${JITSI_URL}/${encodeURIComponent(room)}`,
appName: "Jitsi Meet",
theme: "dark",
body: `<folk-jitsi-room room="${escapeHtml(room)}" jitsi-url="${escapeHtml(JITSI_URL)}" space="${escapeHtml(space)}"${director ? ` director="1" session="${escapeHtml(sessionId)}"` : ""}></folk-jitsi-room>`,
scripts: `<script type="module" src="/modules/rmeets/components/folk-jitsi-room.js"></script>`,
}));
});

View File

@ -18,7 +18,7 @@ declare module '@encryptid/sdk/server' {
options: { getSpaceConfig: (slug: string) => Promise<SpaceAuthConfig | null> },
): Promise<{ allowed: boolean; readOnly: boolean; reason?: string; claims?: EncryptIDClaims }>;
export function extractToken(headers: Headers): string | null;
export function authenticateWSUpgrade(req: Request): Promise<EncryptIDClaims | null>;
export function authenticateWSUpgrade(req: Request, options?: VerifyOptions): Promise<EncryptIDClaims | null>;
export interface EncryptIDClaims {
sub: string;

View File

@ -1743,7 +1743,7 @@ app.post("/api/prompt", async (c) => {
// Record tool calls and build function responses
const fnResponseParts: any[] = [];
for (const part of fnCalls) {
const fc = part.functionCall;
const fc = part.functionCall!;
const tool = findTool(fc.name);
const label = tool?.actionLabel(fc.args) || fc.name;
toolCalls.push({ name: fc.name, args: fc.args, label });

View File

@ -223,6 +223,38 @@ function storeSession(token: string, username: string, did: string): void {
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
if (username) localStorage.setItem("rspace-username", username);
_setSessionCookie(token);
if (username && did) addKnownPersona(username, did);
}
// ── Persona helpers (client-side multi-account) ──
const PERSONAS_KEY = "rspace-known-personas";
interface KnownPersona {
username: string;
did: string;
}
function getKnownPersonas(): KnownPersona[] {
try {
return JSON.parse(localStorage.getItem(PERSONAS_KEY) || "[]");
} catch { return []; }
}
function addKnownPersona(username: string, did: string): void {
const personas = getKnownPersonas();
const idx = personas.findIndex(p => p.did === did);
if (idx >= 0) {
personas[idx] = { username, did };
} else {
personas.push({ username, did });
}
localStorage.setItem(PERSONAS_KEY, JSON.stringify(personas));
}
function removeKnownPersona(did: string): void {
const personas = getKnownPersonas().filter(p => p.did !== did);
localStorage.setItem(PERSONAS_KEY, JSON.stringify(personas));
}
// ── Auto-space resolution after auth ──
@ -311,11 +343,24 @@ export class RStackIdentity extends HTMLElement {
if (session?.accessToken && session.claims.username) {
autoProvisionSpace(session.accessToken);
}
// Propagate login/logout across tabs via storage events
window.addEventListener("storage", this.#onStorageChange);
}
disconnectedCallback() {
window.removeEventListener("storage", this.#onStorageChange);
}
#onStorageChange = (e: StorageEvent) => {
if (e.key === "encryptid_session" || e.key === PERSONAS_KEY) {
this.#render();
if (e.key === "encryptid_session") {
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
}
}
};
async #refreshIfNeeded() {
let session = getSession();
let token = session?.accessToken ?? null;
@ -369,6 +414,8 @@ export class RStackIdentity extends HTMLElement {
const did = session.claims.did || session.claims.sub;
const displayName = username || (did.length > 24 ? did.slice(0, 16) + "..." + did.slice(-6) : did);
const initial = username ? username[0].toUpperCase() : did.slice(8, 10).toUpperCase();
const currentDid = session.claims.did || "";
const otherPersonas = getKnownPersonas().filter(p => p.did !== currentDid);
this.#shadow.innerHTML = `
<style>${STYLES}</style>
@ -379,6 +426,21 @@ export class RStackIdentity extends HTMLElement {
<span class="name">${displayName}</span>
<div class="dropdown" id="dropdown">
<div class="dropdown-header">${displayName}</div>
${otherPersonas.length > 0 ? `
<div class="dropdown-divider"></div>
<div class="dropdown-label">Switch Persona</div>
${otherPersonas.map(p => `
<div class="persona-row">
<button class="dropdown-item persona-item" data-action="switch-persona" data-did="${p.did}">
<div class="persona-avatar">${p.username[0].toUpperCase()}</div>
<span>${p.username}</span>
</button>
<button class="persona-remove" data-action="remove-persona" data-did="${p.did}">&times;</button>
</div>
`).join("")}
` : ""}
<div class="dropdown-divider"></div>
<button class="dropdown-item" data-action="add-persona"> Add Persona</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item" data-action="my-account">👤 My Account</button>
<button class="dropdown-item" data-action="my-spaces">🌐 My Spaces</button>
@ -446,6 +508,23 @@ export class RStackIdentity extends HTMLElement {
}
} else if (action === "settings") {
(document.getElementById("settings-btn") as HTMLElement)?.click();
} else if (action === "switch-persona") {
const targetDid = (el as HTMLElement).dataset.did || "";
const persona = getKnownPersonas().find(p => p.did === targetDid);
if (!persona) return;
clearSession();
resetDocBridge();
this.showAuthModal({ onSuccess: () => {}, onCancel: () => { this.#render(); } }, persona.username);
} else if (action === "add-persona") {
clearSession();
resetDocBridge();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
this.showAuthModal();
} else if (action === "remove-persona") {
const targetDid = (el as HTMLElement).dataset.did || "";
removeKnownPersona(targetDid);
this.#render();
}
});
});
@ -493,8 +572,9 @@ export class RStackIdentity extends HTMLElement {
return getSession() !== null;
}
/** Public method: show the auth modal programmatically */
showAuthModal(callbacks?: { onSuccess?: () => void; onCancel?: () => void }): void {
/** Public method: show the auth modal programmatically.
* Pass usernameHint to auto-trigger passkey sign-in for a specific persona. */
showAuthModal(callbacks?: { onSuccess?: () => void; onCancel?: () => void }, usernameHint?: string): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const overlay = document.createElement("div");
@ -542,17 +622,16 @@ export class RStackIdentity extends HTMLElement {
};
const handleSignIn = async () => {
const errEl = overlay.querySelector("#auth-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement;
errEl.textContent = "";
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Authenticating...';
const errEl = overlay.querySelector("#auth-error") as HTMLElement | null;
const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement | null;
if (errEl) errEl.textContent = "";
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Authenticating...'; }
try {
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
body: JSON.stringify(usernameHint ? { username: usernameHint } : {}),
});
if (!startRes.ok) throw new Error("Failed to start authentication");
const { options: serverOptions } = await startRes.json();
@ -586,9 +665,11 @@ export class RStackIdentity extends HTMLElement {
// Auto-redirect to personal space
autoResolveSpace(data.token, data.username || "");
} catch (err: any) {
btn.disabled = false;
btn.innerHTML = "🔑 Sign In with Passkey";
errEl.textContent = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed.";
if (btn) { btn.disabled = false; btn.innerHTML = "🔑 Sign In with Passkey"; }
const msg = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed.";
if (errEl) errEl.textContent = msg;
// If auto-triggered persona switch was cancelled, close modal and restore previous state
if (usernameHint) { close(); this.#render(); callbacks?.onCancel?.(); }
}
};
@ -696,6 +777,11 @@ export class RStackIdentity extends HTMLElement {
document.body.appendChild(overlay);
render();
// If switching persona, auto-trigger sign-in immediately
if (usernameHint) {
handleSignIn();
}
}
// ── Account modal (consolidated) ──
@ -1668,6 +1754,28 @@ const STYLES = `
padding: 0 4px; border: 2px solid var(--rs-bg-surface); line-height: 1;
}
/* Persona switcher in dropdown */
.dropdown-label {
padding: 6px 16px; font-size: 0.7rem; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--rs-text-secondary); font-weight: 600;
}
.persona-row {
display: flex; align-items: center; padding-right: 8px;
}
.persona-row .persona-item { flex: 1; }
.persona-avatar {
width: 24px; height: 24px; border-radius: 50%;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.65rem; color: white; flex-shrink: 0;
}
.persona-remove {
opacity: 0.4; cursor: pointer; font-size: 0.75rem;
padding: 2px 6px; border-radius: 4px; border: none;
background: none; color: var(--rs-text-secondary); flex-shrink: 0;
}
.persona-remove:hover { opacity: 1; background: var(--rs-bg-hover); }
/* Notification items in dropdown */
.dropdown-section-label {
padding: 8px 16px 4px; font-size: 0.65rem; font-weight: 600;