rspace-online/modules/rmeets/mod.ts

1022 lines
48 KiB
TypeScript

/**
* rMeets module — video meetings powered by Jitsi + Meeting Intelligence.
*
* Hub page with Quick Meet + room name input,
* room pages embed Jitsi via renderExternalAppShell,
* recordings/search pages pull from the Meeting Intelligence API.
*/
import { Hono } from "hono";
import { renderShell, renderExternalAppShell, escapeHtml } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { renderLanding } from "./landing";
import * as Automerge from "@automerge/automerge";
import { verifyToken, extractToken } from "../../server/auth";
import type { SyncServer } from '../../server/local-first/sync-server';
import { meetsSchema, meetsDocId } from './schemas';
import type { MeetsDoc, Meeting } from './schemas';
let _syncServer: SyncServer | null = null;
function ensureMeetsDoc(space: string): MeetsDoc {
const docId = meetsDocId(space);
let doc = _syncServer!.getDoc<MeetsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<MeetsDoc>(), 'init meets', (d) => {
const init = meetsSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
const JITSI_URL = process.env.JITSI_URL || "https://jeffsi.localvibe.live";
const MI_API_URL = process.env.MEETING_INTELLIGENCE_API_URL || "http://meeting-intelligence-api:8000";
const MI_INTERNAL_KEY = process.env.MI_INTERNAL_KEY || "";
const routes = new Hono();
// Subdomain-aware base for this module: `/rmeets` on {space}.rspace.online,
// `/{space}/rmeets` on localhost or bare domain.
function rmeetsBase(c: any): string {
const space = c.req.param("space") || "demo";
return c.get("isSubdomain") ? "/rmeets" : `/${escapeHtml(space)}/rmeets`;
}
// ── Meeting Intelligence API helper ──
async function miApiFetch(path: string, options?: { method?: string; body?: any }): Promise<{ ok: boolean; data?: any; error?: string }> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const headers: Record<string, string> = {};
if (MI_INTERNAL_KEY) headers["X-MI-Internal-Key"] = MI_INTERNAL_KEY;
const fetchOpts: RequestInit = { signal: controller.signal, headers };
if (options?.method) fetchOpts.method = options.method;
if (options?.body) {
headers["Content-Type"] = "application/json";
fetchOpts.body = JSON.stringify(options.body);
}
const res = await fetch(`${MI_API_URL}${path}`, fetchOpts);
clearTimeout(timeout);
if (!res.ok) return { ok: false, error: `API returned ${res.status}` };
return { ok: true, data: await res.json() };
} catch (e: any) {
return { ok: false, error: e?.message || "Connection failed" };
}
}
function miUnavailableHtml(message = "Meeting intelligence is temporarily unavailable. Please try again later."): string {
return `<div style="max-width:600px;margin:3rem auto;padding:2rem;text-align:center;color:var(--rs-text-secondary)">
<div style="font-size:2.5rem;margin-bottom:1rem">&#128268;</div>
<p>${escapeHtml(message)}</p>
</div>`;
}
// ── Shared styles for MI pages ──
const MI_STYLES = `<style>
.mi-page{max-width:900px;margin:2rem auto;padding:0 1.5rem}
.mi-page h1{font-size:1.8rem;margin-bottom:.25rem;color:var(--rs-text-primary)}
.mi-page .mi-subtitle{color:var(--rs-text-secondary);margin-bottom:2rem}
.mi-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem}
.mi-card{padding:1.25rem;border-radius:12px;background:var(--rs-bg-surface);border:1px solid var(--rs-border);text-decoration:none;color:inherit;transition:border-color .15s,background .15s;display:block}
.mi-card:hover{border-color:var(--rs-accent);background:var(--rs-bg-hover)}
.mi-card h3{margin:0 0 .5rem;font-size:1.05rem;color:var(--rs-text-primary)}
.mi-card .mi-meta{font-size:.8rem;color:var(--rs-text-secondary);margin-bottom:.5rem}
.mi-chips{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.75rem}
.mi-chip{display:inline-block;padding:.15rem .6rem;border-radius:99px;font-size:.75rem;font-weight:500}
.mi-chip--ok{background:var(--rs-accent);color:#fff;opacity:.8}
.mi-chip--pending{background:var(--rs-border);color:var(--rs-text-secondary)}
.mi-badge{display:inline-block;padding:.2rem .6rem;border-radius:6px;font-size:.75rem;font-weight:600;text-transform:uppercase}
.mi-badge--completed{background:#22c55e22;color:#22c55e}
.mi-badge--processing{background:#eab30822;color:#eab308}
.mi-badge--recording{background:#ef444422;color:#ef4444}
.mi-badge--failed{background:#ef444422;color:#ef4444}
.mi-badge--transcribing,.mi-badge--diarizing,.mi-badge--summarizing,.mi-badge--extracting_audio{background:#3b82f622;color:#3b82f6}
.mi-tabs{display:flex;gap:0;border-bottom:1px solid var(--rs-border);margin-bottom:1.5rem}
.mi-tab{padding:.75rem 1.25rem;text-decoration:none;color:var(--rs-text-secondary);font-size:.9rem;font-weight:500;border-bottom:2px solid transparent;transition:color .15s,border-color .15s}
.mi-tab:hover{color:var(--rs-text-primary)}
.mi-tab--active{color:var(--rs-accent);border-bottom-color:var(--rs-accent)}
.mi-detail-header{margin-bottom:1.5rem}
.mi-detail-header h1{margin-bottom:.5rem}
.mi-detail-meta{display:flex;gap:1.5rem;flex-wrap:wrap;font-size:.85rem;color:var(--rs-text-secondary)}
.mi-transcript{display:flex;flex-direction:column;gap:1rem}
.mi-segment{padding:.75rem 1rem;border-left:3px solid var(--rs-accent);background:var(--rs-bg-surface);border-radius:0 8px 8px 0}
.mi-segment .mi-speaker{font-weight:600;color:var(--rs-accent);font-size:.85rem}
.mi-segment .mi-timestamp{font-size:.75rem;color:var(--rs-text-secondary);margin-left:.75rem}
.mi-segment .mi-text{margin-top:.35rem;color:var(--rs-text-primary);line-height:1.5}
.mi-summary{color:var(--rs-text-primary);line-height:1.6}
.mi-summary h2{font-size:1.2rem;margin:1.5rem 0 .5rem;color:var(--rs-text-primary)}
.mi-summary h2:first-child{margin-top:0}
.mi-summary ul{padding-left:1.5rem;margin:.5rem 0}
.mi-summary li{margin:.25rem 0}
.mi-speakers{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem}
.mi-speaker-card{padding:1rem;border-radius:10px;background:var(--rs-bg-surface);border:1px solid var(--rs-border)}
.mi-speaker-card h4{margin:0 0 .25rem;color:var(--rs-text-primary)}
.mi-speaker-card p{margin:0;font-size:.85rem;color:var(--rs-text-secondary)}
.mi-search-form{display:flex;gap:.75rem;margin-bottom:2rem}
.mi-search-form input{flex:1;padding:.65rem 1rem;border-radius:8px;border:1px solid var(--rs-input-border);background:var(--rs-input-bg);color:var(--rs-input-text);font-size:.95rem}
.mi-search-form input:focus{outline:none;border-color:var(--rs-accent)}
.mi-search-form button{padding:.65rem 1.5rem;border-radius:8px;border:none;background:var(--rs-accent);color:#fff;font-weight:600;cursor:pointer;font-size:.95rem}
.mi-search-form button:hover{background:var(--rs-accent-hover)}
.mi-result{padding:1rem 1.25rem;border-radius:10px;background:var(--rs-bg-surface);border:1px solid var(--rs-border);margin-bottom:.75rem;display:block;text-decoration:none;color:inherit;transition:border-color .15s}
.mi-result:hover{border-color:var(--rs-accent)}
.mi-result h3{margin:0 0 .25rem;font-size:1rem;color:var(--rs-text-primary)}
.mi-result .mi-excerpt{font-size:.85rem;color:var(--rs-text-secondary);line-height:1.4}
.mi-result mark{background:var(--rs-accent);color:#fff;padding:0 .2rem;border-radius:3px}
@media(max-width:600px){.mi-page{margin:1rem auto;padding:0 .75rem}.mi-cards{grid-template-columns:1fr}}
</style>`;
// ── CRUD: Meetings ──
routes.get("/api/meetings", (c) => {
if (!_syncServer) return c.json({ meetings: [] });
const space = c.req.param("space") || "demo";
const doc = ensureMeetsDoc(space);
const meetings = Object.values(doc.meetings || {}).sort((a, b) => b.createdAt - a.createdAt);
return c.json({ meetings });
});
routes.post("/api/meetings", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const space = c.req.param("space") || "demo";
const { title, roomName, scheduledAt, participants = [] } = await c.req.json();
if (!title) return c.json({ error: "title required" }, 400);
const id = crypto.randomUUID();
const docId = meetsDocId(space);
ensureMeetsDoc(space);
_syncServer.changeDoc<MeetsDoc>(docId, `create meeting ${id}`, (d) => {
d.meetings[id] = { id, roomName: roomName || id, title, scheduledAt: scheduledAt || Date.now(), hostDid: null, participants, createdAt: Date.now() };
});
const updated = _syncServer.getDoc<MeetsDoc>(docId)!;
return c.json(updated.meetings[id], 201);
});
routes.delete("/api/meetings/:id", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const space = c.req.param("space") || "demo";
const id = c.req.param("id");
const docId = meetsDocId(space);
const doc = ensureMeetsDoc(space);
if (!doc.meetings[id]) return c.json({ error: "Not found" }, 404);
_syncServer.changeDoc<MeetsDoc>(docId, `delete meeting ${id}`, (d) => { delete d.meetings[id]; });
return c.json({ ok: true });
});
// ── Direct Jitsi lobby ──
routes.get("/meet", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderExternalAppShell({
title: `Jitsi Meet — rMeets | rSpace`,
moduleId: "rmeets",
spaceSlug: space,
modules: getModuleInfoList(),
appUrl: JITSI_URL,
appName: "Jitsi Meet",
theme: "dark",
}));
});
// ── Recordings list ──
routes.get("/recordings", async (c) => {
const space = c.req.param("space") || "demo";
const base = rmeetsBase(c);
const prefix = encodeURIComponent(space + "_");
const result = await miApiFetch(`/meetings?limit=50&sort=-created_at&conference_prefix=${prefix}`);
let body: string;
if (!result.ok) {
body = miUnavailableHtml();
} else {
const meetings = Array.isArray(result.data) ? result.data : (result.data?.meetings ?? result.data?.items ?? []);
if (meetings.length === 0) {
body = `<div class="mi-page"><h1>Recordings</h1><p class="mi-subtitle">No recorded meetings yet. Start a meeting and enable recording to see it here.</p></div>`;
} else {
const cards = meetings.map((m: any) => {
const date = m.created_at ? new Date(m.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "";
const duration = m.duration_seconds ? `${Math.round(m.duration_seconds / 60)}min` : "";
const participants = m.participant_count ?? m.participants?.length ?? "";
const status = (m.status || "completed").toLowerCase();
const badgeClass = `mi-badge--${status}`;
const hasTranscript = m.has_transcript ?? (m.segment_count != null && m.segment_count > 0) ?? m.transcript_status === "completed";
const hasSummary = m.has_summary ?? m.summary_status === "completed";
return `<a class="mi-card" href="${base}/recordings/${escapeHtml(String(m.id))}">
<h3>${escapeHtml(m.title || m.room_name || `Meeting ${m.id}`)}</h3>
<div class="mi-meta">${[date, duration, participants ? `${participants} participants` : ""].filter(Boolean).join(" &middot; ")}</div>
<span class="mi-badge ${badgeClass}">${escapeHtml(status)}</span>
<div class="mi-chips">
${hasTranscript ? `<span class="mi-chip mi-chip--ok">Transcript</span>` : `<span class="mi-chip mi-chip--pending">No transcript</span>`}
${hasSummary ? `<span class="mi-chip mi-chip--ok">Summary</span>` : `<span class="mi-chip mi-chip--pending">No summary</span>`}
</div>
</a>`;
}).join("\n");
body = `<div class="mi-page"><h1>Recordings</h1><p class="mi-subtitle">Past meetings with transcripts and AI summaries</p><div class="mi-cards">${cards}</div></div>`;
}
}
return c.html(renderShell({
title: `Recordings — rMeets | rSpace`,
moduleId: "rmeets",
spaceSlug: space,
modules: getModuleInfoList(),
styles: MI_STYLES,
body,
}));
});
// ── Recording detail redirect ──
routes.get("/recordings/:id", (c) => {
const space = c.req.param("space") || "demo";
const id = c.req.param("id");
return c.redirect(c.get("isSubdomain") ? `/rmeets/recordings/${id}/overview` : `/${space}/rmeets/recordings/${id}/overview`);
});
// ── Recording detail with tabs ──
routes.get("/recordings/:id/:tab", async (c) => {
const space = c.req.param("space") || "demo";
const base = rmeetsBase(c);
const id = c.req.param("id");
const tab = c.req.param("tab") || "overview";
const validTabs = ["overview", "transcript", "summary", "speakers"];
const activeTab = validTabs.includes(tab) ? tab : "overview";
const skip = { ok: false as const, data: undefined, error: "skipped" };
const [meetingRes, transcriptRes, summaryRes, speakersRes] = await Promise.all([
miApiFetch(`/meetings/${id}`),
activeTab === "transcript" ? miApiFetch(`/meetings/${id}/transcript`) : Promise.resolve(skip),
activeTab === "summary" ? miApiFetch(`/meetings/${id}/summary`) : Promise.resolve(skip),
(activeTab === "speakers" || activeTab === "overview") ? miApiFetch(`/meetings/${id}/speakers`) : Promise.resolve(skip),
]);
if (!meetingRes.ok) {
return c.html(renderShell({
title: `Recording — rMeets | rSpace`,
moduleId: "rmeets",
spaceSlug: space,
modules: getModuleInfoList(),
styles: MI_STYLES,
body: miUnavailableHtml("Could not load this recording. The meeting intelligence service may be temporarily unavailable."),
}));
}
const m = meetingRes.data;
const title = m.title || m.room_name || `Meeting ${id}`;
const date = m.created_at ? new Date(m.created_at).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }) : "";
const duration = m.duration_seconds ? `${Math.round(m.duration_seconds / 60)} minutes` : "";
const participants = m.participant_count ?? m.participants?.length ?? "";
const tabs = validTabs.map(t => {
const label = t.charAt(0).toUpperCase() + t.slice(1);
const cls = t === activeTab ? "mi-tab mi-tab--active" : "mi-tab";
return `<a class="${cls}" href="${base}/recordings/${escapeHtml(String(id))}/${t}">${label}</a>`;
}).join("");
let tabContent: string;
if (activeTab === "overview") {
const speakers = speakersRes.ok ? (Array.isArray(speakersRes.data) ? speakersRes.data : speakersRes.data?.speakers ?? []) : [];
const speakerList = speakers.length > 0
? `<div class="mi-speakers">${speakers.map((s: any) => {
const time = s.speaking_time ? `${Math.round(s.speaking_time / 60)}min speaking` : s.speaking_time_seconds ? `${Math.round(s.speaking_time_seconds / 60)}min speaking` : "";
return `<div class="mi-speaker-card"><h4>${escapeHtml(s.name || s.speaker_label || s.label || `Speaker ${s.speaker_id || s.id}`)}</h4><p>${escapeHtml(time)}</p></div>`;
}).join("")}</div>`
: `<p style="color:var(--rs-text-secondary)">No speaker data available.</p>`;
tabContent = `<div class="mi-detail-meta">
${date ? `<span>${escapeHtml(date)}</span>` : ""}
${duration ? `<span>${escapeHtml(duration)}</span>` : ""}
${participants ? `<span>${participants} participants</span>` : ""}
</div>
<h2 style="font-size:1.1rem;margin:1.5rem 0 .75rem;color:var(--rs-text-primary)">Speakers</h2>
${speakerList}`;
} else if (activeTab === "transcript") {
if (!transcriptRes.ok) {
tabContent = `<p style="color:var(--rs-text-secondary)">Transcript not available yet.</p>`;
} else {
const segments = Array.isArray(transcriptRes.data) ? transcriptRes.data : (transcriptRes.data?.segments ?? transcriptRes.data?.transcript ?? []);
if (segments.length === 0) {
tabContent = `<p style="color:var(--rs-text-secondary)">Transcript is empty.</p>`;
} else {
tabContent = `<div class="mi-transcript">${segments.map((seg: any) => {
const speaker = seg.speaker || seg.speaker_name || seg.speaker_label || seg.label || "Unknown";
const ts = seg.start_time != null ? formatTimestamp(seg.start_time) : "";
const text = seg.text || seg.content || "";
return `<div class="mi-segment">
<span class="mi-speaker">${escapeHtml(speaker)}</span><span class="mi-timestamp">${escapeHtml(ts)}</span>
<div class="mi-text">${escapeHtml(text)}</div>
</div>`;
}).join("")}</div>`;
}
}
} else if (activeTab === "summary") {
if (!summaryRes.ok) {
tabContent = `<p style="color:var(--rs-text-secondary)">Summary not available yet.</p>`;
} else {
const summary = summaryRes.data;
const overview = summary.summary_text || summary.overview || summary.summary || summary.text || "";
const keyPoints = summary.key_points ?? summary.keyPoints ?? [];
const actionItems = summary.action_items ?? summary.actionItems ?? [];
const decisions = summary.decisions ?? [];
let html = `<div class="mi-summary">`;
if (overview) html += `<h2>Overview</h2><p>${escapeHtml(overview)}</p>`;
if (keyPoints.length > 0) html += `<h2>Key Points</h2><ul>${keyPoints.map((p: string) => `<li>${escapeHtml(p)}</li>`).join("")}</ul>`;
if (actionItems.length > 0) html += `<h2>Action Items</h2><ul>${actionItems.map((a: any) => `<li>${escapeHtml(typeof a === "string" ? a : a.task || a.text || a.description || "")}</li>`).join("")}</ul>`;
if (decisions.length > 0) html += `<h2>Decisions</h2><ul>${decisions.map((d: any) => `<li>${escapeHtml(typeof d === "string" ? d : d.text || d.description || "")}</li>`).join("")}</ul>`;
if (!overview && keyPoints.length === 0 && actionItems.length === 0) html += `<p style="color:var(--rs-text-secondary)">Summary content is empty.</p>`;
html += `</div>`;
tabContent = html;
}
} else if (activeTab === "speakers") {
const speakers = speakersRes.ok ? (Array.isArray(speakersRes.data) ? speakersRes.data : speakersRes.data?.speakers ?? []) : [];
if (speakers.length === 0) {
tabContent = `<p style="color:var(--rs-text-secondary)">No speaker data available.</p>`;
} else {
tabContent = `<div class="mi-speakers">${speakers.map((s: any) => {
const time = s.speaking_time ? `${Math.round(s.speaking_time / 60)}min speaking` : s.speaking_time_seconds ? `${Math.round(s.speaking_time_seconds / 60)}min speaking` : "";
const segments = s.segment_count ?? s.num_segments ?? "";
return `<div class="mi-speaker-card"><h4>${escapeHtml(s.name || s.speaker_label || s.label || `Speaker ${s.speaker_id || s.id}`)}</h4><p>${[time, segments ? `${segments} segments` : ""].filter(Boolean).join(" &middot; ")}</p></div>`;
}).join("")}</div>`;
}
} else {
tabContent = "";
}
return c.html(renderShell({
title: `${title} — rMeets | rSpace`,
moduleId: "rmeets",
spaceSlug: space,
modules: getModuleInfoList(),
styles: MI_STYLES,
body: `<div class="mi-page">
<div class="mi-detail-header"><h1>${escapeHtml(title)}</h1></div>
<div class="mi-tabs">${tabs}</div>
${tabContent}
</div>`,
}));
});
// ── Search ──
routes.get("/search", async (c) => {
const space = c.req.param("space") || "demo";
const base = rmeetsBase(c);
const q = c.req.query("q") || "";
let resultsHtml = "";
if (q) {
const result = await miApiFetch("/search", { method: "POST", body: { query: q, limit: 30 } });
if (!result.ok) {
resultsHtml = miUnavailableHtml();
} else {
const hits = Array.isArray(result.data) ? result.data : (result.data?.results ?? result.data?.hits ?? []);
if (hits.length === 0) {
resultsHtml = `<p style="color:var(--rs-text-secondary)">No results found for "${escapeHtml(q)}".</p>`;
} else {
resultsHtml = hits.map((h: any) => {
const meetingId = h.meeting_id ?? h.meetingId ?? h.id;
const title = h.meeting_title || h.title || `Meeting ${meetingId}`;
const excerpt = h.highlight || h.excerpt || h.text || "";
return `<a class="mi-result" href="${base}/recordings/${escapeHtml(String(meetingId))}/transcript">
<h3>${escapeHtml(title)}</h3>
<div class="mi-excerpt">${excerpt}</div>
</a>`;
}).join("");
}
}
}
return c.html(renderShell({
title: `Search — rMeets | rSpace`,
moduleId: "rmeets",
spaceSlug: space,
modules: getModuleInfoList(),
styles: MI_STYLES,
body: `<div class="mi-page">
<h1>Search Transcripts</h1>
<p class="mi-subtitle">Full-text search across all meeting transcripts</p>
<form class="mi-search-form" method="get" action="${base}/search">
<input type="text" name="q" placeholder="Search meetings..." value="${escapeHtml(q)}">
<button type="submit">Search</button>
</form>
${resultsHtml}
</div>`,
}));
});
// ── MI API proxy (for client-side JS, avoids CORS) ──
routes.all("/api/mi-proxy/*", async (c) => {
const proxyPath = c.req.path.replace(/^\/[^/]+\/rmeets\/api\/mi-proxy/, "");
const url = new URL(`${MI_API_URL}${proxyPath}`);
// Forward query params
const query = c.req.query();
for (const [k, v] of Object.entries(query)) {
if (typeof v === "string") url.searchParams.set(k, v);
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const proxyHeaders: Record<string, string> = { "Content-Type": "application/json" };
if (MI_INTERNAL_KEY) proxyHeaders["X-MI-Internal-Key"] = MI_INTERNAL_KEY;
const upstream = await fetch(url.toString(), {
method: c.req.method,
headers: proxyHeaders,
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? await c.req.text() : undefined,
signal: controller.signal,
});
clearTimeout(timeout);
const body = await upstream.text();
return new Response(body, {
status: upstream.status,
headers: { "Content-Type": upstream.headers.get("Content-Type") || "application/json" },
});
} catch {
return c.json({ error: "Meeting intelligence service unavailable" }, 502);
}
});
// ── Hub page ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const base = rmeetsBase(c);
const randomId = Math.random().toString(36).slice(2, 10);
return c.html(renderShell({
title: `rMeets — ${space} | rSpace`,
moduleId: "rmeets",
spaceSlug: space,
modules: getModuleInfoList(),
styles: `<style>
.rs-hub{max-width:720px;margin:2rem auto;padding:0 1.5rem}
.rs-hub h1{font-size:1.8rem;margin-bottom:.5rem;color:var(--rs-text-primary)}
.rs-hub p{color:var(--rs-text-secondary);margin-bottom:2rem}
.rs-nav{display:flex;flex-direction:column;gap:1rem}
.rs-nav a,.rs-nav .room-form{display:flex;align-items:center;gap:1rem;padding:1.25rem 1.5rem;border-radius:12px;background:var(--rs-bg-surface);border:1px solid var(--rs-border);text-decoration:none;color:inherit;transition:border-color .15s,background .15s}
.rs-nav a:hover,.rs-nav .room-form:hover{border-color:var(--rs-accent);background:var(--rs-bg-hover)}
.rs-nav .nav-icon{font-size:2rem;flex-shrink:0}
.rs-nav .nav-body h3{margin:0 0 .25rem;font-size:1.1rem;color:var(--rs-text-primary)}
.rs-nav .nav-body p{margin:0;font-size:.85rem;color:var(--rs-text-secondary)}
.room-form{flex-wrap:wrap}
.room-form input[type="text"]{flex:1;min-width:140px;padding:.5rem .75rem;border-radius:8px;border:1px solid var(--rs-input-border);background:var(--rs-input-bg);color:var(--rs-input-text);font-size:.95rem}
.room-form input[type="text"]:focus{outline:none;border-color:var(--rs-accent)}
.room-form button{padding:.5rem 1.25rem;border-radius:8px;border:none;background:var(--rs-accent);color:#fff;font-weight:600;cursor:pointer;font-size:.95rem}
.room-form button:hover{background:var(--rs-accent-hover)}
@media(max-width:600px){.rs-hub{margin:1rem auto;padding:0 .75rem}.rs-nav a,.rs-nav .room-form{padding:1rem;gap:.75rem}.rs-nav .nav-icon{font-size:1.5rem}}
</style>`,
body: `<div class="rs-hub">
<h1>rMeets</h1>
<p>Video meetings powered by Jitsi — no account required</p>
<nav class="rs-nav">
<a href="${base}/${randomId}">
<span class="nav-icon">&#128640;</span>
<div class="nav-body">
<h3>Quick Meet</h3>
<p>Start an instant meeting with a random room name</p>
</div>
</a>
<form class="room-form" onsubmit="event.preventDefault();var n=this.querySelector('input').value.trim();if(n)location.href='${base}/'+encodeURIComponent(n)">
<span class="nav-icon">&#128279;</span>
<div class="nav-body">
<h3>Join a Room</h3>
<p>Enter a room name to join or create a meeting</p>
</div>
<input type="text" placeholder="room-name" required>
<button type="submit">Join</button>
</form>
<a href="${base}/meet">
<span class="nav-icon">&#128249;</span>
<div class="nav-body">
<h3>Jitsi Lobby</h3>
<p>Open the full Jitsi Meet interface directly</p>
</div>
</a>
<a href="${base}/meeting-intelligence">
<span class="nav-icon">&#129504;</span>
<div class="nav-body">
<h3>Meeting Intelligence</h3>
<p>Knowledge objects: action items, decisions, and insights across all meetings</p>
</div>
</a>
<a href="${base}/recordings">
<span class="nav-icon">&#127909;</span>
<div class="nav-body">
<h3>Recordings</h3>
<p>Browse past meetings, transcripts and AI summaries</p>
</div>
</a>
<a href="${base}/search">
<span class="nav-icon">&#128269;</span>
<div class="nav-body">
<h3>Search</h3>
<p>Full-text search across all meeting transcripts</p>
</div>
</a>
</nav>
</div>`,
}));
});
// ── Meeting Intelligence knowledge page ──
routes.get("/meeting-intelligence", async (c) => {
const space = c.req.param("space") || "demo";
const base = rmeetsBase(c);
const prefix = encodeURIComponent(space + "_");
// Fetch space-scoped meetings from MI API
const result = await miApiFetch(`/meetings?limit=50&sort=-created_at&conference_prefix=${prefix}`);
let meetingCards = "";
let aggregateHtml = "";
if (!result.ok) {
meetingCards = miUnavailableHtml();
} else {
const meetings = Array.isArray(result.data) ? result.data : (result.data?.meetings ?? result.data?.items ?? []);
if (meetings.length === 0) {
meetingCards = `<p style="color:var(--rs-text-secondary)">No meetings recorded yet for this space. Start a meeting and enable recording to see it here.</p>`;
} else {
// Build meeting cards
meetingCards = `<div class="mi-cards">${meetings.map((m: any) => {
const date = m.created_at ? new Date(m.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "";
const duration = m.duration_seconds ? `${Math.round(m.duration_seconds / 60)}min` : "";
const status = (m.status || "completed").toLowerCase();
const badgeClass = `mi-badge--${status}`;
const hasTranscript = m.has_transcript ?? (m.segment_count != null && m.segment_count > 0);
const hasSummary = m.has_summary ?? m.summary_status === "completed";
return `<a class="mi-card" href="${base}/recordings/${escapeHtml(String(m.id))}">
<h3>${escapeHtml(m.title || m.room_name || m.conference_name || `Meeting ${m.id}`)}</h3>
<div class="mi-meta">${[date, duration].filter(Boolean).join(" &middot; ")}</div>
<span class="mi-badge ${badgeClass}">${escapeHtml(status)}</span>
<div class="mi-chips">
${hasTranscript ? `<span class="mi-chip mi-chip--ok">Transcript</span>` : `<span class="mi-chip mi-chip--pending">No transcript</span>`}
${hasSummary ? `<span class="mi-chip mi-chip--ok">Summary</span>` : `<span class="mi-chip mi-chip--pending">No summary</span>`}
</div>
</a>`;
}).join("\n")}</div>`;
// Aggregate knowledge from completed meetings with summaries
const readyMeetings = meetings.filter((m: any) => (m.status === "completed" || m.has_summary));
if (readyMeetings.length > 0) {
// Fetch summaries for up to 10 completed meetings
const summaryPromises = readyMeetings.slice(0, 10).map((m: any) =>
miApiFetch(`/meetings/${m.id}/summary`).then(r => r.ok ? r.data : null)
);
const summaries = (await Promise.all(summaryPromises)).filter(Boolean);
const allActionItems: string[] = [];
const allDecisions: string[] = [];
const allTopics: string[] = [];
for (const s of summaries) {
const actions = s.action_items ?? s.actionItems ?? [];
for (const a of actions) {
const text = typeof a === "string" ? a : (a.task || a.text || a.description || "");
if (text) allActionItems.push(text);
}
const decisions = s.decisions ?? [];
for (const d of decisions) {
const text = typeof d === "string" ? d : (d.text || d.description || "");
if (text) allDecisions.push(text);
}
const topics = s.topics ?? [];
for (const t of topics) {
const text = typeof t === "string" ? t : (t.name || t.topic || t.text || "");
if (text) allTopics.push(text);
}
}
if (allActionItems.length > 0 || allDecisions.length > 0 || allTopics.length > 0) {
aggregateHtml = `<div class="mi-aggregate">`;
if (allActionItems.length > 0) {
aggregateHtml += `<h2>Action Items</h2><ul>${allActionItems.map(a => `<li>${escapeHtml(a)}</li>`).join("")}</ul>`;
}
if (allDecisions.length > 0) {
aggregateHtml += `<h2>Decisions</h2><ul>${allDecisions.map(d => `<li>${escapeHtml(d)}</li>`).join("")}</ul>`;
}
if (allTopics.length > 0) {
aggregateHtml += `<h2>Topics</h2><div class="mi-chips">${allTopics.map(t => `<span class="mi-chip mi-chip--ok">${escapeHtml(t)}</span>`).join("")}</div>`;
}
aggregateHtml += `</div>`;
}
}
}
}
const extraStyles = `<style>
.mi-aggregate{margin-bottom:2rem;padding:1.5rem;border-radius:12px;background:var(--rs-bg-surface);border:1px solid var(--rs-border)}
.mi-aggregate h2{font-size:1.1rem;margin:1rem 0 .5rem;color:var(--rs-text-primary)}
.mi-aggregate h2:first-child{margin-top:0}
.mi-aggregate ul{padding-left:1.5rem;margin:.5rem 0}
.mi-aggregate li{margin:.25rem 0;color:var(--rs-text-primary);line-height:1.5}
.mi-section-title{font-size:1.2rem;margin:2rem 0 1rem;color:var(--rs-text-primary)}
</style>`;
return c.html(renderShell({
title: `Meeting Intelligence — rMeets | rSpace`,
moduleId: "rmeets",
spaceSlug: space,
modules: getModuleInfoList(),
styles: MI_STYLES + extraStyles,
body: `<div class="mi-page">
<h1>Meeting Intelligence</h1>
<p class="mi-subtitle">Knowledge objects, action items, and insights across all meetings</p>
<form class="mi-search-form" method="get" action="${base}/search">
<input type="text" name="q" placeholder="Search across all meetings...">
<button type="submit">Search</button>
</form>
${aggregateHtml ? `<h2 class="mi-section-title">Aggregate Knowledge</h2>${aggregateHtml}` : ""}
<h2 class="mi-section-title">Meetings</h2>
${meetingCards}
</div>`,
}));
});
// ── Room embed (catch-all — must be LAST so /meet, /recordings, /search match first) ──
routes.get("/:room", (c) => {
const space = c.req.param("space") || "demo";
const room = c.req.param("room");
const director = c.req.query("director") === "1";
const sessionId = c.req.query("session") || "";
// Full rSpace shell mode — only when explicitly requested via ?shell=1 or director mode
if (c.req.query("shell") === "1" || director) {
return c.html(renderShell({
title: `${room} — rMeets | rSpace`,
moduleId: "rmeets",
spaceSlug: space,
modules: getModuleInfoList(),
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?v=3"></script>`,
}));
}
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",
}));
}
// Default: clean full-screen Jitsi — no rSpace shell, mobile-friendly
const jitsiRoom = encodeURIComponent(space + "_" + room);
const meetsBase = rmeetsBase(c);
return c.html(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#111111">
<title>${escapeHtml(room)} — Meeting</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;overflow:hidden;background:#111;font-family:system-ui,-apple-system,sans-serif;touch-action:manipulation}
#jitsi-container{width:100%;height:100%;height:100dvh;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}
#jitsi-container iframe{width:100%!important;height:100%!important;border:none!important}
.loading{display:flex;align-items:center;justify-content:center;height:100%;color:#94a3b8;flex-direction:column;gap:12px;font-size:0.95rem}
.loading .spinner{width:32px;height:32px;border:3px solid #334155;border-top-color:#6366f1;border-radius:50%;animation:spin 0.8s linear infinite}
.ended{display:flex;align-items:center;justify-content:center;height:100%;color:#e2e8f0;flex-direction:column;gap:16px;font-size:1.1rem}
.ended a{color:#6366f1;text-decoration:none;padding:10px 24px;border:1px solid #6366f1;border-radius:8px;font-size:0.95rem;transition:background 0.15s}
.ended a:hover,.ended a:active{background:#6366f122}
@keyframes spin{to{transform:rotate(360deg)}}
/* MI overlay */
.mi-fab{position:fixed;top:12px;right:12px;z-index:10000;display:flex;align-items:center;gap:6px}
.mi-fab-btn{width:40px;height:40px;border-radius:50%;border:none;background:rgba(99,102,241,0.85);color:#fff;font-size:1.1rem;cursor:pointer;backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;transition:background 0.15s,transform 0.15s;box-shadow:0 2px 8px rgba(0,0,0,0.3)}
.mi-fab-btn:hover{background:rgba(99,102,241,1);transform:scale(1.08)}
.mi-fab-btn.active{background:rgba(99,102,241,1);transform:scale(1.08)}
.mi-dropdown{display:none;position:absolute;top:48px;right:0;background:rgba(30,30,46,0.95);backdrop-filter:blur(12px);border:1px solid rgba(99,102,241,0.3);border-radius:12px;padding:8px 0;min-width:200px;box-shadow:0 8px 24px rgba(0,0,0,0.4)}
.mi-dropdown.open{display:block}
.mi-dropdown a,.mi-dropdown button{display:flex;align-items:center;gap:10px;width:100%;padding:10px 16px;border:none;background:none;color:#e2e8f0;font-size:0.9rem;text-decoration:none;cursor:pointer;text-align:left;font-family:inherit;transition:background 0.12s}
.mi-dropdown a:hover,.mi-dropdown button:hover{background:rgba(99,102,241,0.15)}
.mi-dropdown .mi-icon{font-size:1.1rem;width:22px;text-align:center;flex-shrink:0}
.mi-dropdown .mi-sep{height:1px;background:rgba(99,102,241,0.15);margin:4px 0}
/* Live captions overlay */
#mi-captions{position:fixed;left:50%;bottom:92px;transform:translateX(-50%);z-index:9999;max-width:min(780px,94vw);display:flex;flex-direction:column;gap:4px;pointer-events:none;align-items:center}
#mi-captions.hidden{display:none}
.mi-cap-line{background:rgba(0,0,0,0.72);color:#f8fafc;padding:6px 14px;border-radius:10px;font-size:1rem;line-height:1.35;text-align:center;max-width:100%;backdrop-filter:blur(6px);box-shadow:0 2px 10px rgba(0,0,0,0.4);word-wrap:break-word}
.mi-cap-line .mi-cap-speaker{color:#a5b4fc;font-weight:600;margin-right:6px;font-size:0.85rem;text-transform:uppercase;letter-spacing:0.03em}
.mi-cap-line.interim{opacity:0.75;font-style:italic}
.mi-cap-status{position:fixed;left:12px;bottom:12px;z-index:10000;font-size:0.72rem;color:rgba(226,232,240,0.7);background:rgba(0,0,0,0.5);padding:4px 10px;border-radius:999px;pointer-events:none;max-width:80vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mi-cap-status.hidden{display:none}
@media(max-width:600px){#mi-captions{bottom:72px}.mi-cap-line{font-size:0.9rem;padding:5px 10px}}
</style>
</head>
<body>
<div id="jitsi-container">
<div class="loading"><div class="spinner"></div><span>Connecting to meeting...</span></div>
</div>
<div class="mi-fab">
<button class="mi-fab-btn" id="mi-fab-toggle" title="Meeting Intelligence">&#129504;</button>
<div class="mi-dropdown" id="mi-dropdown">
<button type="button" id="mi-cc-toggle"><span class="mi-icon">&#128488;</span> <span id="mi-cc-label">Turn on live captions</span></button>
<div class="mi-sep"></div>
<a href="${meetsBase}/meeting-intelligence"><span class="mi-icon">&#129504;</span> Meeting Intelligence</a>
<a href="${meetsBase}/recordings"><span class="mi-icon">&#127909;</span> Recordings</a>
<a href="${meetsBase}/search"><span class="mi-icon">&#128269;</span> Search Transcripts</a>
<div class="mi-sep"></div>
<a href="${meetsBase}"><span class="mi-icon">&#127968;</span> rMeets Hub</a>
</div>
</div>
<div id="mi-captions" class="hidden"></div>
<div id="mi-cap-status" class="mi-cap-status hidden"></div>
<script src="${escapeHtml(JITSI_URL)}/libs/external_api.min.js"></script>
<script>
// MI dropdown toggle
(function() {
var fab = document.getElementById("mi-fab-toggle");
var dd = document.getElementById("mi-dropdown");
fab.addEventListener("click", function(e) {
e.stopPropagation();
var open = dd.classList.toggle("open");
fab.classList.toggle("active", open);
});
document.addEventListener("click", function() {
dd.classList.remove("open");
fab.classList.remove("active");
});
})();
try {
var api = new JitsiMeetExternalAPI("${escapeHtml(JITSI_URL.replace(/^https?:\/\//, ""))}", {
roomName: decodeURIComponent("${jitsiRoom}"),
parentNode: document.getElementById("jitsi-container"),
width: "100%",
height: "100%",
configOverwrite: {
startWithAudioMuted: false,
startWithVideoMuted: false,
prejoinConfig: { enabled: true },
requireDisplayName: true,
disableDeepLinking: true,
disableThirdPartyRequests: false,
enableClosePage: false,
disableVirtualBackground: false,
disableProfile: false,
notifications: ['chat'],
toolbarButtons: [
"microphone","camera","desktop","hangup",
"raisehand","tileview","toggle-camera",
"fullscreen","chat","settings",
"participants-pane","select-background",
"sharedvideo","shareaudio","recording",
],
},
customToolbarButtons: [
{ icon: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-4h2v-2h-2v2zm1-10c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z'/%3E%3C/svg%3E", id: "mi-btn", text: "Intelligence" },
],
interfaceConfigOverwrite: {
SHOW_JITSI_WATERMARK: false,
SHOW_BRAND_WATERMARK: false,
SHOW_POWERED_BY: false,
MOBILE_APP_PROMO: false,
HIDE_DEEP_LINKING_LOGO: true,
DISABLE_JOIN_LEAVE_NOTIFICATIONS: false,
CHAT_PANEL_POSITION: 'right',
SETTINGS_SECTIONS: ['devices', 'language', 'moderator', 'profile', 'sounds', 'more'],
},
});
// Remove loading spinner — iframe is already injected by the constructor
var loadingEl = document.querySelector(".loading");
if (loadingEl) loadingEl.remove();
// Custom toolbar button → open our MI page
api.addEventListener("toolbarButtonClicked", function(e) {
if (e.key === "mi-btn") {
window.open("${meetsBase}/meeting-intelligence", "_blank");
}
});
api.addEventListener("readyToClose", function() {
try { window.close(); } catch(e) {}
document.getElementById("jitsi-container").innerHTML =
'<div class="ended"><span>Meeting ended</span>'
+ '<a href="${meetsBase}/recordings">View Transcript &amp; Summary</a>'
+ '<a href="${meetsBase}">Back to rMeets</a></div>';
});
// ── Live captions (Web Speech API, peer-broadcast via Jitsi data channel) ──
(function setupCaptions() {
var capsEl = document.getElementById("mi-captions");
var statusEl = document.getElementById("mi-cap-status");
var ccBtn = document.getElementById("mi-cc-toggle");
var ccLabel = document.getElementById("mi-cc-label");
var myName = "Me";
var myId = null;
var enabled = false;
var recognition = null;
var wantRunning = false;
var lines = []; // { id, speaker, text, interim, ts }
var lineSeq = 0;
var MAX_LINES = 3;
var FADE_MS = 6000;
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) {
ccLabel.textContent = "Captions not supported in this browser";
ccBtn.disabled = true;
ccBtn.style.opacity = "0.5";
return;
}
api.addEventListener("videoConferenceJoined", function(e) {
myId = e.id;
if (e.displayName) myName = e.displayName;
});
api.addEventListener("displayNameChange", function(e) {
if (e.id === myId && e.displayname) myName = e.displayname;
});
function render() {
var now = Date.now();
lines = lines.filter(function(l) { return l.interim || (now - l.ts) < FADE_MS; });
capsEl.innerHTML = "";
var shown = lines.slice(-MAX_LINES);
shown.forEach(function(l) {
var d = document.createElement("div");
d.className = "mi-cap-line" + (l.interim ? " interim" : "");
d.innerHTML = '<span class="mi-cap-speaker">' + escapeText(l.speaker) + '</span>' + escapeText(l.text);
capsEl.appendChild(d);
});
}
function escapeText(s) {
return String(s || '').replace(/[&<>"']/g, function(c) {
return { '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c];
});
}
function pushLine(speaker, text, interim, senderKey) {
// Replace the most recent interim line from the same sender, otherwise append
var key = senderKey || speaker;
for (var i = lines.length - 1; i >= 0; i--) {
if (lines[i].interim && lines[i].senderKey === key) {
lines[i].text = text;
lines[i].interim = !!interim;
lines[i].ts = Date.now();
if (!interim) lines[i].interim = false;
render();
return;
}
}
lines.push({ id: ++lineSeq, speaker: speaker, text: text, interim: !!interim, ts: Date.now(), senderKey: key });
if (lines.length > 20) lines = lines.slice(-20);
render();
}
setInterval(render, 1500);
function broadcast(text, isFinal) {
try {
var msg = JSON.stringify({ type: "caption", v: 1, final: !!isFinal, text: text });
api.executeCommand("sendEndpointTextMessage", "", msg);
} catch(e) { /* pre-join; ignore */ }
}
api.addEventListener("endpointTextMessageReceived", function(event) {
try {
var raw = event && event.data && event.data.eventData && event.data.eventData.text;
if (!raw) return;
var msg = JSON.parse(raw);
if (!msg || msg.type !== "caption" || !msg.text) return;
var sender = event.data.senderInfo || {};
var senderId = sender.id || sender.jid || "peer";
var speaker = "";
try { speaker = api.getDisplayName && api.getDisplayName(senderId); } catch(_) {}
if (!speaker) speaker = sender.displayName || "Guest";
pushLine(speaker, msg.text, !msg.final, "peer:" + senderId);
} catch(_) { /* ignore malformed */ }
});
function startRecognition() {
if (recognition) return;
recognition = new SR();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = (navigator.language || "en-US");
recognition.onresult = function(evt) {
for (var i = evt.resultIndex; i < evt.results.length; i++) {
var res = evt.results[i];
var text = (res[0] && res[0].transcript || "").trim();
if (!text) continue;
var isFinal = !!res.isFinal;
pushLine(myName + " (you)", text, !isFinal, "me");
broadcast(text, isFinal);
}
};
recognition.onerror = function(e) {
if (e.error === "no-speech" || e.error === "aborted") return;
setStatus("Captions paused: " + (e.error || "error"));
};
recognition.onend = function() {
recognition = null;
if (wantRunning) {
// Chrome stops after ~60s of continuous; restart
setTimeout(function() { if (wantRunning) startRecognition(); }, 300);
}
};
try { recognition.start(); setStatus("Captions on — your voice is being shared"); }
catch(_) { /* already started — ignore */ }
}
function stopRecognition() {
wantRunning = false;
if (recognition) { try { recognition.stop(); } catch(_){} recognition = null; }
}
function setStatus(txt) {
if (!txt) { statusEl.classList.add("hidden"); statusEl.textContent = ""; return; }
statusEl.classList.remove("hidden");
statusEl.textContent = txt;
}
ccBtn.addEventListener("click", function(e) {
e.stopPropagation();
enabled = !enabled;
if (enabled) {
capsEl.classList.remove("hidden");
ccLabel.textContent = "Turn off live captions";
wantRunning = true;
startRecognition();
} else {
stopRecognition();
ccLabel.textContent = "Turn on live captions";
setStatus("");
capsEl.classList.add("hidden");
lines = [];
render();
}
});
window.addEventListener("beforeunload", stopRecognition);
})();
} catch(e) {
document.getElementById("jitsi-container").innerHTML =
'<div class="loading" style="color:#ef4444">Failed to connect: ' + e.message + '</div>';
}
</script>
</body>
</html>`);
});
// ── Helpers ──
function formatTimestamp(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function getRecentMeetingsForMI(space: string, limit = 5): { id: string; title: string; roomName: string; scheduledAt: number; participantCount: number; createdAt: number }[] {
if (!_syncServer) return [];
const docId = meetsDocId(space);
const doc = _syncServer.getDoc<MeetsDoc>(docId);
if (!doc?.meetings) return [];
return Object.values(doc.meetings)
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, limit)
.map(m => ({ id: m.id, title: m.title, roomName: m.roomName, scheduledAt: m.scheduledAt, participantCount: m.participants.length, createdAt: m.createdAt }));
}
// ── Module export ──
export const meetsModule: RSpaceModule = {
id: "rmeets",
name: "rMeets",
icon: "📹",
description: "Video meetings powered by Jitsi",
scoping: { defaultScope: "space", userConfigurable: false },
routes,
docSchemas: [{ pattern: '{space}:meets:meetings', description: 'Meeting scheduling per space', init: meetsSchema.init }],
async onInit(ctx) { _syncServer = ctx.syncServer; },
landingPage: renderLanding,
externalApp: { url: JITSI_URL, name: "Jitsi Meet" },
outputPaths: [
{ path: "meet", name: "Create Call", icon: "🚀", description: "Start a new video meeting" },
{ path: "rooms", name: "Rooms", icon: "🚪", description: "Meeting rooms and video calls" },
],
onboardingActions: [
{ label: "Start a Meeting", icon: "🚀", description: "Jump into an instant video call", type: "create", href: "/{space}/rmeets/quick" },
{ label: "View Recordings", icon: "🎥", description: "Browse past meetings with transcripts and AI summaries", type: "link", href: "/{space}/rmeets/recordings" },
],
};