feat: magic link responses for polls and RSVPs
HMAC-signed stateless tokens let external respondents vote on rChoices polls or RSVP to rCal events via a single tap — no account required. Routes mounted at /respond/:token bypass space auth. Typed EventAttendee schema replaces unknown[] on CalendarEvent.attendees. Invite endpoints on both modules generate tokens and optionally send email invitations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
17a5922a44
commit
5ddde13b0d
|
|
@ -17,7 +17,8 @@ import { verifyToken, extractToken } from "../../server/auth";
|
||||||
import { renderLanding } from "./landing";
|
import { renderLanding } from "./landing";
|
||||||
import type { SyncServer } from '../../server/local-first/sync-server';
|
import type { SyncServer } from '../../server/local-first/sync-server';
|
||||||
import { calendarSchema, calendarDocId } from './schemas';
|
import { calendarSchema, calendarDocId } from './schemas';
|
||||||
import type { CalendarDoc, CalendarEvent, CalendarSource, SavedCalendarView, ScheduledItemMetadata } from './schemas';
|
import type { CalendarDoc, CalendarEvent, CalendarSource, SavedCalendarView, ScheduledItemMetadata, EventAttendee } from './schemas';
|
||||||
|
import { sendMagicLinks } from "../../server/magic-link/send";
|
||||||
|
|
||||||
let _syncServer: SyncServer | null = null;
|
let _syncServer: SyncServer | null = null;
|
||||||
|
|
||||||
|
|
@ -690,6 +691,69 @@ routes.delete("/api/events/:id", async (c) => {
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── API: RSVP Magic Links ──
|
||||||
|
|
||||||
|
// POST /api/events/:id/invite — create RSVP magic links for an event
|
||||||
|
routes.post("/api/events/:id/invite", 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); }
|
||||||
|
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = c.get("effectiveSpace") || space;
|
||||||
|
const id = c.req.param("id");
|
||||||
|
|
||||||
|
const doc = ensureDoc(dataSpace);
|
||||||
|
const event = doc.events[id];
|
||||||
|
if (!event) return c.json({ error: "Event not found" }, 404);
|
||||||
|
|
||||||
|
const body = await c.req.json<{ participants: Array<{ name: string; email: string }>; sendEmail?: boolean }>();
|
||||||
|
if (!body.participants?.length) return c.json({ error: "participants array required" }, 400);
|
||||||
|
|
||||||
|
const results = await sendMagicLinks({
|
||||||
|
space: dataSpace,
|
||||||
|
type: "rsvp",
|
||||||
|
targetId: id,
|
||||||
|
title: event.title,
|
||||||
|
participants: body.participants,
|
||||||
|
sendEmail: body.sendEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ ok: true, invites: results });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/events/:id/rsvp — get RSVP tallies for an event
|
||||||
|
routes.get("/api/events/:id/rsvp", async (c) => {
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = c.get("effectiveSpace") || space;
|
||||||
|
const id = c.req.param("id");
|
||||||
|
|
||||||
|
const doc = ensureDoc(dataSpace);
|
||||||
|
const event = doc.events[id];
|
||||||
|
if (!event) return c.json({ error: "Event not found" }, 404);
|
||||||
|
|
||||||
|
const attendees = (event.attendees || []) as EventAttendee[];
|
||||||
|
const tallies = {
|
||||||
|
yes: attendees.filter((a) => a.status === "yes").length,
|
||||||
|
no: attendees.filter((a) => a.status === "no").length,
|
||||||
|
maybe: attendees.filter((a) => a.status === "maybe").length,
|
||||||
|
pending: attendees.filter((a) => a.status === "pending").length,
|
||||||
|
total: attendees.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
eventId: id,
|
||||||
|
title: event.title,
|
||||||
|
tallies,
|
||||||
|
attendees: attendees.map((a) => ({
|
||||||
|
name: a.name,
|
||||||
|
status: a.status,
|
||||||
|
respondedAt: a.respondedAt ? new Date(a.respondedAt).toISOString() : null,
|
||||||
|
source: a.source,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── API: Sources ──
|
// ── API: Sources ──
|
||||||
|
|
||||||
routes.get("/api/sources", async (c) => {
|
routes.get("/api/sources", async (c) => {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,16 @@ export interface CalendarSource {
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EventAttendee {
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
status: 'yes' | 'no' | 'maybe' | 'pending';
|
||||||
|
respondedAt: number;
|
||||||
|
source: 'magic-link' | 'app' | 'import';
|
||||||
|
/** Prevent duplicate responses from same magic link */
|
||||||
|
tokenHash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -52,7 +62,7 @@ export interface CalendarEvent {
|
||||||
rToolEntityId: string | null;
|
rToolEntityId: string | null;
|
||||||
locationBreadcrumb: string | null; // "Earth > Europe > Germany > Berlin"
|
locationBreadcrumb: string | null; // "Earth > Europe > Germany > Berlin"
|
||||||
bookingStatus: string | null; // "booked" | "unbooked" | null (placeholder for booking pipeline)
|
bookingStatus: string | null; // "booked" | "unbooked" | null (placeholder for booking pipeline)
|
||||||
attendees: unknown[];
|
attendees: EventAttendee[];
|
||||||
attendeeCount: number;
|
attendeeCount: number;
|
||||||
tags: string[] | null;
|
tags: string[] | null;
|
||||||
metadata: unknown | null;
|
metadata: unknown | null;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ import type { RSpaceModule } from "../../shared/module";
|
||||||
import { renderLanding } from "./landing";
|
import { renderLanding } from "./landing";
|
||||||
import { getModuleInfoList } from "../../shared/module";
|
import { getModuleInfoList } from "../../shared/module";
|
||||||
import { getDocumentData, addShapes } from "../../server/community-store";
|
import { getDocumentData, addShapes } from "../../server/community-store";
|
||||||
|
import { verifyToken, extractToken } from "../../server/auth";
|
||||||
|
import { syncServer } from "../../server/sync-instance";
|
||||||
|
import { choicesDocId } from "./schemas";
|
||||||
|
import type { ChoicesDoc } from "./schemas";
|
||||||
|
import { sendMagicLinks } from "../../server/magic-link/send";
|
||||||
|
|
||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
|
||||||
|
|
@ -46,6 +51,35 @@ routes.get("/api/choices", async (c) => {
|
||||||
return c.json({ choices, total: choices.length });
|
return c.json({ choices, total: choices.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/choices/:sessionId/invite — create magic links for a poll session
|
||||||
|
routes.post("/api/choices/:sessionId/invite", async (c) => {
|
||||||
|
const authToken = extractToken(c.req.raw.headers);
|
||||||
|
if (!authToken) return c.json({ error: "Authentication required" }, 401);
|
||||||
|
try { await verifyToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||||
|
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const sessionId = c.req.param("sessionId");
|
||||||
|
|
||||||
|
const doc = syncServer.getDoc<ChoicesDoc>(choicesDocId(space));
|
||||||
|
if (!doc) return c.json({ error: "Choices doc not found" }, 404);
|
||||||
|
const session = doc.sessions[sessionId];
|
||||||
|
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||||
|
|
||||||
|
const body = await c.req.json<{ participants: Array<{ name: string; email: string }>; sendEmail?: boolean }>();
|
||||||
|
if (!body.participants?.length) return c.json({ error: "participants array required" }, 400);
|
||||||
|
|
||||||
|
const results = await sendMagicLinks({
|
||||||
|
space,
|
||||||
|
type: "poll",
|
||||||
|
targetId: sessionId,
|
||||||
|
title: session.title,
|
||||||
|
participants: body.participants,
|
||||||
|
sendEmail: body.sendEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ ok: true, invites: results });
|
||||||
|
});
|
||||||
|
|
||||||
// GET / — choices page (default tab: spider)
|
// GET / — choices page (default tab: spider)
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ import { notesModule } from "../modules/rnotes/mod";
|
||||||
import { mapsModule } from "../modules/rmaps/mod";
|
import { mapsModule } from "../modules/rmaps/mod";
|
||||||
import { tasksModule } from "../modules/rtasks/mod";
|
import { tasksModule } from "../modules/rtasks/mod";
|
||||||
import { checklistCheckRoutes, checklistApiRoutes } from "../modules/rtasks/checklist-routes";
|
import { checklistCheckRoutes, checklistApiRoutes } from "../modules/rtasks/checklist-routes";
|
||||||
|
import { magicLinkRoutes } from "./magic-link/routes";
|
||||||
import { tripsModule } from "../modules/rtrips/mod";
|
import { tripsModule } from "../modules/rtrips/mod";
|
||||||
import { calModule } from "../modules/rcal/mod";
|
import { calModule } from "../modules/rcal/mod";
|
||||||
import { networkModule } from "../modules/rnetwork/mod";
|
import { networkModule } from "../modules/rnetwork/mod";
|
||||||
|
|
@ -522,6 +523,9 @@ app.route("/api/mi", miRoutes);
|
||||||
app.route("/rtasks/check", checklistCheckRoutes);
|
app.route("/rtasks/check", checklistCheckRoutes);
|
||||||
app.route("/api/rtasks", checklistApiRoutes);
|
app.route("/api/rtasks", checklistApiRoutes);
|
||||||
|
|
||||||
|
// ── Magic Link Responses (top-level, bypasses space auth) ──
|
||||||
|
app.route("/respond", magicLinkRoutes);
|
||||||
|
|
||||||
// ── EncryptID proxy (forward /encryptid/* to encryptid container) ──
|
// ── EncryptID proxy (forward /encryptid/* to encryptid container) ──
|
||||||
const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
|
const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
|
||||||
app.all("/encryptid/*", async (c) => {
|
app.all("/encryptid/*", async (c) => {
|
||||||
|
|
@ -3527,6 +3531,7 @@ const server = Bun.serve<WSData>({
|
||||||
url.pathname.startsWith("/data/") ||
|
url.pathname.startsWith("/data/") ||
|
||||||
url.pathname.startsWith("/encryptid/") ||
|
url.pathname.startsWith("/encryptid/") ||
|
||||||
url.pathname.startsWith("/rtasks/check/") ||
|
url.pathname.startsWith("/rtasks/check/") ||
|
||||||
|
url.pathname.startsWith("/respond/") ||
|
||||||
url.pathname.startsWith("/.well-known/") ||
|
url.pathname.startsWith("/.well-known/") ||
|
||||||
url.pathname === "/about" ||
|
url.pathname === "/about" ||
|
||||||
url.pathname === "/admin" ||
|
url.pathname === "/admin" ||
|
||||||
|
|
@ -3615,7 +3620,7 @@ const server = Bun.serve<WSData>({
|
||||||
// Only match canonical bare domain, not stacked subdomains like rspace.rspace.online
|
// Only match canonical bare domain, not stacked subdomains like rspace.rspace.online
|
||||||
if (!subdomain && (hostClean === "rspace.online" || hostClean === "www.rspace.online")) {
|
if (!subdomain && (hostClean === "rspace.online" || hostClean === "www.rspace.online")) {
|
||||||
// Top-level routes that must bypass module rewriting
|
// Top-level routes that must bypass module rewriting
|
||||||
if (url.pathname.startsWith("/rtasks/check/")) {
|
if (url.pathname.startsWith("/rtasks/check/") || url.pathname.startsWith("/respond/")) {
|
||||||
return app.fetch(req);
|
return app.fetch(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
/** Configuration for magic link responses (polls & RSVPs). */
|
||||||
|
|
||||||
|
export const magicLinkConfig = {
|
||||||
|
hmacSecret: process.env.MAGIC_LINK_HMAC_SECRET || "",
|
||||||
|
baseUrl: process.env.SITE_URL || "https://rspace.online",
|
||||||
|
tokenExpiryDays: parseInt(process.env.MAGIC_LINK_EXPIRY_DAYS || "7", 10),
|
||||||
|
} as const;
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
/** Standalone HTML rendering for magic link response pages. */
|
||||||
|
|
||||||
|
import { magicLinkConfig } from "./config";
|
||||||
|
import type { ChoiceSession, ChoiceOption } from "../../modules/rchoices/schemas";
|
||||||
|
import type { CalendarEvent } from "../../modules/rcal/schemas";
|
||||||
|
|
||||||
|
function esc(str: string): string {
|
||||||
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = `
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8fafc;color:#1e293b;margin:0;padding:20px}
|
||||||
|
.card{max-width:500px;margin:40px auto;background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.1);padding:32px}
|
||||||
|
h1{font-size:20px;margin:0 0 4px;color:#0f172a}
|
||||||
|
.sub{font-size:14px;color:#64748b;margin-bottom:24px}
|
||||||
|
.opt{display:block;width:100%;padding:14px 20px;margin:8px 0;border:2px solid #e2e8f0;border-radius:10px;background:#fff;cursor:pointer;font-size:16px;text-align:left;color:#1e293b;text-decoration:none;box-sizing:border-box;transition:border-color .15s,background .15s}
|
||||||
|
.opt:hover{border-color:#6366f1;background:#eef2ff}
|
||||||
|
.opt.sel{border-color:#22c55e;background:#f0fdf4}
|
||||||
|
.rsvp-row{display:flex;gap:10px;margin:16px 0}
|
||||||
|
.rsvp-btn{flex:1;padding:14px;border:2px solid #e2e8f0;border-radius:10px;background:#fff;cursor:pointer;font-size:15px;text-align:center;text-decoration:none;color:#1e293b;transition:border-color .15s,background .15s}
|
||||||
|
.rsvp-btn:hover{border-color:#6366f1;background:#eef2ff}
|
||||||
|
.rsvp-btn.sel{border-color:#22c55e;background:#f0fdf4}
|
||||||
|
.banner{padding:12px;border-radius:8px;margin-bottom:20px;font-size:14px}
|
||||||
|
.banner-ok{background:#22c55e;color:#fff}
|
||||||
|
.banner-info{background:#3b82f6;color:#fff}
|
||||||
|
.meta{font-size:13px;color:#64748b;margin:4px 0}
|
||||||
|
.ft{text-align:center;font-size:12px;color:#94a3b8;margin-top:24px}
|
||||||
|
.label{font-size:13px;color:#94a3b8;margin-top:16px;margin-bottom:4px}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Poll page ──
|
||||||
|
|
||||||
|
export function renderPollPage(
|
||||||
|
session: ChoiceSession,
|
||||||
|
token: string,
|
||||||
|
respondentName: string,
|
||||||
|
existingChoice?: string,
|
||||||
|
justVoted?: string,
|
||||||
|
): string {
|
||||||
|
let banner = "";
|
||||||
|
if (justVoted) {
|
||||||
|
const opt = session.options.find((o) => o.id === justVoted);
|
||||||
|
banner = `<div class="banner banner-ok">Your vote for <strong>${esc(opt?.label || justVoted)}</strong> has been recorded.</div>`;
|
||||||
|
} else if (existingChoice) {
|
||||||
|
const opt = session.options.find((o) => o.id === existingChoice);
|
||||||
|
banner = `<div class="banner banner-info">You previously voted for <strong>${esc(opt?.label || existingChoice)}</strong>. Tap another option to change your vote.</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionButtons = session.options
|
||||||
|
.map((opt) => {
|
||||||
|
const selected = (existingChoice === opt.id || justVoted === opt.id) ? " sel" : "";
|
||||||
|
return `<form method="POST" action="${magicLinkConfig.baseUrl}/respond/${esc(token)}" style="margin:0">
|
||||||
|
<input type="hidden" name="choice" value="${esc(opt.id)}">
|
||||||
|
<button type="submit" class="opt${selected}" style="border-left:4px solid ${esc(opt.color || '#6366f1')}">${esc(opt.label)}</button>
|
||||||
|
</form>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>${esc(session.title)} - rChoices</title><style>${styles}</style></head>
|
||||||
|
<body><div class="card">
|
||||||
|
${banner}
|
||||||
|
<h1>${esc(session.title)}</h1>
|
||||||
|
<div class="sub">Hi ${esc(respondentName)} — tap your choice below</div>
|
||||||
|
<div class="label">${session.options.length} option${session.options.length === 1 ? "" : "s"} · ${session.type} vote</div>
|
||||||
|
${optionButtons}
|
||||||
|
<div class="ft">rChoices · rspace.online · Link expires in ${magicLinkConfig.tokenExpiryDays} days</div>
|
||||||
|
</div></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RSVP page ──
|
||||||
|
|
||||||
|
const rsvpLabels: Record<string, string> = { yes: "Going", no: "Not Going", maybe: "Maybe" };
|
||||||
|
const rsvpEmoji: Record<string, string> = { yes: "✓", no: "✗", maybe: "?" };
|
||||||
|
|
||||||
|
export function renderRsvpPage(
|
||||||
|
event: CalendarEvent,
|
||||||
|
token: string,
|
||||||
|
respondentName: string,
|
||||||
|
currentStatus?: string,
|
||||||
|
justResponded?: string,
|
||||||
|
): string {
|
||||||
|
let banner = "";
|
||||||
|
if (justResponded) {
|
||||||
|
banner = `<div class="banner banner-ok">You responded <strong>${esc(rsvpLabels[justResponded] || justResponded)}</strong>.</div>`;
|
||||||
|
} else if (currentStatus && currentStatus !== "pending") {
|
||||||
|
banner = `<div class="banner banner-info">You previously responded <strong>${esc(rsvpLabels[currentStatus] || currentStatus)}</strong>. Tap to change.</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = event.startTime
|
||||||
|
? new Date(event.startTime).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })
|
||||||
|
: "";
|
||||||
|
const startTime = event.startTime && !event.allDay
|
||||||
|
? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
|
||||||
|
: "";
|
||||||
|
const endTime = event.endTime && !event.allDay
|
||||||
|
? new Date(event.endTime).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const timeLine = event.allDay
|
||||||
|
? `<p class="meta">All day · ${startDate}</p>`
|
||||||
|
: `<p class="meta">${startDate}${startTime ? ` · ${startTime}` : ""}${endTime ? ` – ${endTime}` : ""}</p>`;
|
||||||
|
|
||||||
|
const location = event.locationName
|
||||||
|
? `<p class="meta">${esc(event.locationName)}</p>`
|
||||||
|
: event.isVirtual && event.virtualUrl
|
||||||
|
? `<p class="meta">Virtual · <a href="${esc(event.virtualUrl)}">${esc(event.virtualPlatform || "Join")}</a></p>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const buttons = ["yes", "no", "maybe"]
|
||||||
|
.map((status) => {
|
||||||
|
const selected = (currentStatus === status || justResponded === status) ? " sel" : "";
|
||||||
|
return `<form method="POST" action="${magicLinkConfig.baseUrl}/respond/${esc(token)}" style="margin:0;flex:1">
|
||||||
|
<input type="hidden" name="status" value="${status}">
|
||||||
|
<button type="submit" class="rsvp-btn${selected}">${rsvpEmoji[status]} ${rsvpLabels[status]}</button>
|
||||||
|
</form>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>RSVP: ${esc(event.title)} - rCal</title><style>${styles}</style></head>
|
||||||
|
<body><div class="card">
|
||||||
|
${banner}
|
||||||
|
<h1>${esc(event.title)}</h1>
|
||||||
|
${timeLine}
|
||||||
|
${location}
|
||||||
|
${event.description ? `<p style="font-size:14px;color:#475569;margin-top:12px">${esc(event.description)}</p>` : ""}
|
||||||
|
<div class="sub" style="margin-top:16px">Hi ${esc(respondentName)} — will you attend?</div>
|
||||||
|
<div class="rsvp-row">
|
||||||
|
${buttons}
|
||||||
|
</div>
|
||||||
|
<div class="ft">rCal · rspace.online · Link expires in ${magicLinkConfig.tokenExpiryDays} days</div>
|
||||||
|
</div></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error page ──
|
||||||
|
|
||||||
|
export function renderError(title: string, message: string): string {
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Error - rSpace</title><style>${styles} .err{color:#ef4444}</style></head>
|
||||||
|
<body><div class="card" style="text-align:center"><h1 class="err">${esc(title)}</h1>
|
||||||
|
<p style="color:#64748b">${esc(message)}</p>
|
||||||
|
<div class="ft">rSpace · rspace.online</div></div></body></html>`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
/**
|
||||||
|
* Magic link routes — mounted at top level to bypass space auth middleware.
|
||||||
|
* GET /respond/:token — verify HMAC, render poll or RSVP page
|
||||||
|
* POST /respond/:token — submit response, re-render with confirmation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { verifyMagicToken } from "./token";
|
||||||
|
import { renderPollPage, renderRsvpPage, renderError } from "./render";
|
||||||
|
import { syncServer } from "../sync-instance";
|
||||||
|
import { choicesDocId } from "../../modules/rchoices/schemas";
|
||||||
|
import { calendarDocId } from "../../modules/rcal/schemas";
|
||||||
|
import type { ChoicesDoc } from "../../modules/rchoices/schemas";
|
||||||
|
import type { CalendarDoc } from "../../modules/rcal/schemas";
|
||||||
|
import type { EventAttendee } from "../../modules/rcal/schemas";
|
||||||
|
|
||||||
|
export const magicLinkRoutes = new Hono();
|
||||||
|
|
||||||
|
// ── GET /respond/:token ──
|
||||||
|
magicLinkRoutes.get("/:token", async (c) => {
|
||||||
|
const token = c.req.param("token");
|
||||||
|
|
||||||
|
let verified;
|
||||||
|
try {
|
||||||
|
verified = await verifyMagicToken(token);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
const title = message === "Token expired" ? "Link Expired" : "Invalid Link";
|
||||||
|
const body = message === "Token expired"
|
||||||
|
? "This response link has expired. Request a new invitation."
|
||||||
|
: "This link is not valid. It may have been corrupted.";
|
||||||
|
return c.html(renderError(title, body), title === "Link Expired" ? 410 : 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (verified.type === "poll") {
|
||||||
|
return c.html(renderPollForToken(verified, token));
|
||||||
|
} else {
|
||||||
|
return c.html(renderRsvpForToken(verified, token));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[MagicLink] Render error:", err);
|
||||||
|
return c.html(renderError("Error", "Could not load the response page."), 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /respond/:token ──
|
||||||
|
magicLinkRoutes.post("/:token", async (c) => {
|
||||||
|
const token = c.req.param("token");
|
||||||
|
|
||||||
|
let verified;
|
||||||
|
try {
|
||||||
|
verified = await verifyMagicToken(token);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
const title = message === "Token expired" ? "Link Expired" : "Invalid Link";
|
||||||
|
const body = message === "Token expired"
|
||||||
|
? "This response link has expired. Request a new invitation."
|
||||||
|
: "This link is not valid. It may have been corrupted.";
|
||||||
|
return c.html(renderError(title, body), title === "Link Expired" ? 410 : 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = await c.req.parseBody();
|
||||||
|
|
||||||
|
if (verified.type === "poll") {
|
||||||
|
const choice = formData.choice as string;
|
||||||
|
if (!choice) return c.html(renderError("Error", "No choice submitted."), 400);
|
||||||
|
|
||||||
|
const docId = choicesDocId(verified.space);
|
||||||
|
const doc = syncServer.getDoc<ChoicesDoc>(docId);
|
||||||
|
if (!doc) return c.html(renderError("Not Found", "Poll data not found."), 404);
|
||||||
|
|
||||||
|
const session = doc.sessions[verified.targetId];
|
||||||
|
if (!session) return c.html(renderError("Not Found", "Poll session not found."), 404);
|
||||||
|
|
||||||
|
// Validate choice is a valid option
|
||||||
|
if (!session.options.some((o) => o.id === choice)) {
|
||||||
|
return c.html(renderError("Error", "Invalid option selected."), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write vote keyed by sessionId:magic:tokenHash (dedup by token)
|
||||||
|
const voteKey = `${verified.targetId}:magic:${verified.tokenHash}`;
|
||||||
|
syncServer.changeDoc<ChoicesDoc>(docId, `magic link vote by ${verified.respondentName}`, (d) => {
|
||||||
|
d.votes[voteKey] = {
|
||||||
|
participantDid: `magic:${verified.tokenHash}`,
|
||||||
|
choices: { [choice]: 1 },
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.html(renderPollForToken(verified, token, choice));
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// RSVP
|
||||||
|
const status = formData.status as string;
|
||||||
|
if (!status || !["yes", "no", "maybe"].includes(status)) {
|
||||||
|
return c.html(renderError("Error", "Invalid RSVP status."), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const docId = calendarDocId(verified.space);
|
||||||
|
const doc = syncServer.getDoc<CalendarDoc>(docId);
|
||||||
|
if (!doc) return c.html(renderError("Not Found", "Calendar data not found."), 404);
|
||||||
|
|
||||||
|
const event = doc.events[verified.targetId];
|
||||||
|
if (!event) return c.html(renderError("Not Found", "Event not found."), 404);
|
||||||
|
|
||||||
|
syncServer.changeDoc<CalendarDoc>(docId, `magic link RSVP by ${verified.respondentName}`, (d) => {
|
||||||
|
const ev = d.events[verified.targetId];
|
||||||
|
const attendees = (ev.attendees || []) as EventAttendee[];
|
||||||
|
|
||||||
|
// Find existing response by tokenHash (update, not duplicate)
|
||||||
|
const existingIdx = attendees.findIndex((a) => a.tokenHash === verified.tokenHash);
|
||||||
|
const attendee: EventAttendee = {
|
||||||
|
name: verified.respondentName,
|
||||||
|
status: status as "yes" | "no" | "maybe",
|
||||||
|
respondedAt: Date.now(),
|
||||||
|
source: "magic-link",
|
||||||
|
tokenHash: verified.tokenHash,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
attendees[existingIdx] = attendee;
|
||||||
|
} else {
|
||||||
|
attendees.push(attendee);
|
||||||
|
}
|
||||||
|
|
||||||
|
ev.attendees = attendees;
|
||||||
|
ev.attendeeCount = attendees.filter((a) => a.status === "yes").length;
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.html(renderRsvpForToken(verified, token, status));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[MagicLink] Submit error:", err);
|
||||||
|
return c.html(renderError("Error", "Could not submit your response. Please try again."), 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function renderPollForToken(
|
||||||
|
verified: { space: string; targetId: string; respondentName: string; tokenHash: string },
|
||||||
|
token: string,
|
||||||
|
justVoted?: string,
|
||||||
|
): string {
|
||||||
|
const docId = choicesDocId(verified.space);
|
||||||
|
const doc = syncServer.getDoc<ChoicesDoc>(docId);
|
||||||
|
if (!doc) return renderError("Not Found", "Poll data not found.");
|
||||||
|
|
||||||
|
const session = doc.sessions[verified.targetId];
|
||||||
|
if (!session) return renderError("Not Found", "Poll session not found.");
|
||||||
|
|
||||||
|
// Check for existing vote by this token
|
||||||
|
const voteKey = `${verified.targetId}:magic:${verified.tokenHash}`;
|
||||||
|
const existingVote = doc.votes[voteKey];
|
||||||
|
const existingChoice = existingVote
|
||||||
|
? Object.entries(existingVote.choices).find(([, v]) => v === 1)?.[0]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return renderPollPage(session, token, verified.respondentName, existingChoice, justVoted);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRsvpForToken(
|
||||||
|
verified: { space: string; targetId: string; respondentName: string; tokenHash: string },
|
||||||
|
token: string,
|
||||||
|
justResponded?: string,
|
||||||
|
): string {
|
||||||
|
const docId = calendarDocId(verified.space);
|
||||||
|
const doc = syncServer.getDoc<CalendarDoc>(docId);
|
||||||
|
if (!doc) return renderError("Not Found", "Calendar data not found.");
|
||||||
|
|
||||||
|
const event = doc.events[verified.targetId];
|
||||||
|
if (!event) return renderError("Not Found", "Event not found.");
|
||||||
|
|
||||||
|
// Check for existing RSVP by this token
|
||||||
|
const attendees = (event.attendees || []) as EventAttendee[];
|
||||||
|
const existing = attendees.find((a) => a.tokenHash === verified.tokenHash);
|
||||||
|
const currentStatus = existing?.status;
|
||||||
|
|
||||||
|
return renderRsvpPage(event, token, verified.respondentName, currentStatus, justResponded);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
/** Send magic link invitations via email. */
|
||||||
|
|
||||||
|
import { magicLinkConfig } from "./config";
|
||||||
|
import { createMagicToken } from "./token";
|
||||||
|
|
||||||
|
export interface MagicLinkRecipient {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMagicLinkOpts {
|
||||||
|
space: string;
|
||||||
|
type: "poll" | "rsvp";
|
||||||
|
targetId: string;
|
||||||
|
/** Title of the poll session or event (for email subject) */
|
||||||
|
title: string;
|
||||||
|
participants: MagicLinkRecipient[];
|
||||||
|
/** If false, only return URLs without sending emails */
|
||||||
|
sendEmail?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MagicLinkResult {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
url: string;
|
||||||
|
sent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMagicLinks(opts: SendMagicLinkOpts): Promise<MagicLinkResult[]> {
|
||||||
|
const results: MagicLinkResult[] = [];
|
||||||
|
|
||||||
|
for (const participant of opts.participants) {
|
||||||
|
const token = await createMagicToken(opts.space, opts.type, opts.targetId, participant.name);
|
||||||
|
const url = `${magicLinkConfig.baseUrl}/respond/${token}`;
|
||||||
|
|
||||||
|
let sent = false;
|
||||||
|
if (opts.sendEmail !== false && participant.email) {
|
||||||
|
try {
|
||||||
|
const nodemailer = await import("nodemailer");
|
||||||
|
const transport = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST || "mailcowdockerized-postfix-mailcow-1",
|
||||||
|
port: Number(process.env.SMTP_PORT) || 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER || "noreply@rmail.online",
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
},
|
||||||
|
tls: { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeLabel = opts.type === "poll" ? "Poll" : "RSVP";
|
||||||
|
const subject = `${typeLabel}: ${opts.title}`;
|
||||||
|
const html = buildInviteEmail(opts, participant, url);
|
||||||
|
|
||||||
|
await transport.sendMail({
|
||||||
|
from: process.env.SMTP_FROM || `rSpace <${process.env.SMTP_USER || "noreply@rmail.online"}>`,
|
||||||
|
to: participant.email,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
sent = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[MagicLink] Email send failed for ${participant.email}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ name: participant.name, email: participant.email, url, sent });
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(str: string): string {
|
||||||
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInviteEmail(opts: SendMagicLinkOpts, participant: MagicLinkRecipient, url: string): string {
|
||||||
|
const typeLabel = opts.type === "poll" ? "Poll" : "Event RSVP";
|
||||||
|
const actionLabel = opts.type === "poll" ? "Vote Now" : "RSVP Now";
|
||||||
|
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
|
||||||
|
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8fafc;color:#1e293b;margin:0;padding:20px">
|
||||||
|
<div style="max-width:500px;margin:40px auto;background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.1);padding:32px">
|
||||||
|
<h1 style="font-size:20px;margin:0 0 4px;color:#0f172a">${esc(opts.title)}</h1>
|
||||||
|
<p style="font-size:14px;color:#64748b;margin:8px 0 24px">Hi ${esc(participant.name)}, you've been invited to respond to this ${typeLabel.toLowerCase()}.</p>
|
||||||
|
<a href="${esc(url)}" style="display:inline-block;padding:14px 28px;background:#6366f1;color:#fff;border-radius:10px;text-decoration:none;font-size:16px;font-weight:600">${actionLabel}</a>
|
||||||
|
<p style="font-size:12px;color:#94a3b8;margin-top:24px">This link expires in ${magicLinkConfig.tokenExpiryDays} days. No account required.</p>
|
||||||
|
<p style="font-size:11px;color:#cbd5e1;margin-top:16px">Sent by rSpace · rspace.online</p>
|
||||||
|
</div></body></html>`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
/** HMAC-SHA256 signed tokens for magic link responses. */
|
||||||
|
|
||||||
|
import { magicLinkConfig } from "./config";
|
||||||
|
|
||||||
|
export interface MagicLinkPayload {
|
||||||
|
/** space slug */
|
||||||
|
s: string;
|
||||||
|
/** target type */
|
||||||
|
t: "poll" | "rsvp";
|
||||||
|
/** target id (sessionId or eventId) */
|
||||||
|
i: string;
|
||||||
|
/** respondent name/label */
|
||||||
|
n: string;
|
||||||
|
/** expiry (unix seconds) */
|
||||||
|
e: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenData extends MagicLinkPayload {
|
||||||
|
/** hex signature */
|
||||||
|
h: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getKey(): Promise<CryptoKey> {
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
new TextEncoder().encode(magicLinkConfig.hmacSecret),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign", "verify"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64Url(data: string): string {
|
||||||
|
return btoa(data).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64Url(b64: string): string {
|
||||||
|
return atob(b64.replace(/-/g, "+").replace(/_/g, "/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(buf: ArrayBuffer): string {
|
||||||
|
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sign(payload: string): Promise<string> {
|
||||||
|
const key = await getKey();
|
||||||
|
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
|
||||||
|
return toHex(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMagicToken(
|
||||||
|
space: string,
|
||||||
|
type: "poll" | "rsvp",
|
||||||
|
targetId: string,
|
||||||
|
respondentName: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const expiry = Math.floor(Date.now() / 1000) + magicLinkConfig.tokenExpiryDays * 86400;
|
||||||
|
const payload: MagicLinkPayload = { s: space, t: type, i: targetId, n: respondentName, e: expiry };
|
||||||
|
const payloadStr = `${payload.s}:${payload.t}:${payload.i}:${payload.n}:${payload.e}`;
|
||||||
|
const sig = await sign(payloadStr);
|
||||||
|
const token: TokenData = { ...payload, h: sig };
|
||||||
|
return toBase64Url(JSON.stringify(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifiedMagicToken {
|
||||||
|
space: string;
|
||||||
|
type: "poll" | "rsvp";
|
||||||
|
targetId: string;
|
||||||
|
respondentName: string;
|
||||||
|
/** Short hash for dedup keying */
|
||||||
|
tokenHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyMagicToken(token: string): Promise<VerifiedMagicToken> {
|
||||||
|
let data: TokenData;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(fromBase64Url(token));
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid token format");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.s || !data.t || !data.i || !data.n || !data.e || !data.h) {
|
||||||
|
throw new Error("Malformed token");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.t !== "poll" && data.t !== "rsvp") {
|
||||||
|
throw new Error("Invalid target type");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.e < Math.floor(Date.now() / 1000)) {
|
||||||
|
throw new Error("Token expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadStr = `${data.s}:${data.t}:${data.i}:${data.n}:${data.e}`;
|
||||||
|
const expected = await sign(payloadStr);
|
||||||
|
if (data.h !== expected) {
|
||||||
|
throw new Error("Invalid signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short hash for dedup (first 12 chars of signature)
|
||||||
|
const tokenHash = data.h.slice(0, 12);
|
||||||
|
|
||||||
|
return {
|
||||||
|
space: data.s,
|
||||||
|
type: data.t,
|
||||||
|
targetId: data.i,
|
||||||
|
respondentName: data.n,
|
||||||
|
tokenHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -52,11 +52,12 @@ const FAVICON_BADGE_MAP: Record<string, { badge: string; color: string }> = {
|
||||||
rstack: { badge: "r✨", color: "#c4b5fd" },
|
rstack: { badge: "r✨", color: "#c4b5fd" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const FAVICON_BADGE_JSON = JSON.stringify(FAVICON_BADGE_MAP);
|
|
||||||
|
|
||||||
/** Generate an inline script that sets the favicon to the module's badge SVG */
|
/** Generate an inline script that sets the favicon to the module's badge SVG */
|
||||||
function faviconScript(moduleId: string): string {
|
function faviconScript(moduleId: string): string {
|
||||||
return `<script data-mod="${moduleId}">(function(){var m=${FAVICON_BADGE_JSON};var id=document.currentScript.dataset.mod;if(!id||!m[id])return;var b=m[id];var s='<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" rx="14" fill="'+b.color+'"/><text x="32" y="44" text-anchor="middle" font-size="26" font-family="sans-serif">'+b.badge+'</text></svg>';var l=document.querySelector('link[rel="icon"]');if(l)l.href="data:image/svg+xml,"+encodeURIComponent(s)})()</script>`;
|
const b = FAVICON_BADGE_MAP[moduleId];
|
||||||
|
if (!b) return '';
|
||||||
|
const json = JSON.stringify(b);
|
||||||
|
return `<script>(function(){var b=${json};var s='<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" rx="14" fill="'+b.color+'"/><text x="32" y="44" text-anchor="middle" font-size="26" font-family="sans-serif">'+b.badge+'</text></svg>';var l=document.querySelector('link[rel="icon"]');if(l)l.href="data:image/svg+xml,"+encodeURIComponent(s)})()</script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Content-hash cache busting ──
|
// ── Content-hash cache busting ──
|
||||||
|
|
@ -283,7 +284,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
<script>if (window.self !== window.parent) document.documentElement.classList.add('rspace-embedded');</script>
|
<script>if (window.self !== window.parent) document.documentElement.classList.add('rspace-embedded');</script>
|
||||||
${styles}
|
${styles}
|
||||||
${head}
|
${head}
|
||||||
<style>${WELCOME_CSS}</style>
|
${spaceSlug === 'demo' ? `<style>${WELCOME_CSS}</style>` : ''}
|
||||||
<style>${ACCESS_GATE_CSS}</style>
|
<style>${ACCESS_GATE_CSS}</style>
|
||||||
<style>${INFO_PANEL_CSS}</style>
|
<style>${INFO_PANEL_CSS}</style>
|
||||||
<style>${SUBNAV_CSS}</style>
|
<style>${SUBNAV_CSS}</style>
|
||||||
|
|
@ -1066,12 +1067,10 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
|
|
||||||
// Reconcile remote layer changes (shared by BroadcastChannel + Automerge)
|
// Reconcile remote layer changes (shared by BroadcastChannel + Automerge)
|
||||||
function reconcileRemoteLayers(remoteLayers) {
|
function reconcileRemoteLayers(remoteLayers) {
|
||||||
console.log('[shell] reconcileRemoteLayers: remote=' + remoteLayers.length + ', local=' + layers.length + ', current=' + currentModuleId);
|
|
||||||
// Guard: never let remote sync wipe all tabs when we have an active module.
|
// Guard: never let remote sync wipe all tabs when we have an active module.
|
||||||
// Empty remote layers indicate a CRDT initial state or sync race, not
|
// Empty remote layers indicate a CRDT initial state or sync race, not
|
||||||
// an intentional "close everything" action.
|
// an intentional "close everything" action.
|
||||||
if (remoteLayers.length === 0 && currentModuleId) {
|
if (remoteLayers.length === 0 && currentModuleId) {
|
||||||
console.log('[shell] BLOCKED empty remote layers (keeping local)');
|
|
||||||
// Keep local layers intact — remote has nothing useful
|
// Keep local layers intact — remote has nothing useful
|
||||||
tabBar.setLayers(layers);
|
tabBar.setLayers(layers);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1160,24 +1159,17 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
// This prevents the visual desync where the tab highlights before content loads.
|
// This prevents the visual desync where the tab highlights before content loads.
|
||||||
tabBar.addEventListener('layer-switch', (e) => {
|
tabBar.addEventListener('layer-switch', (e) => {
|
||||||
const { layerId, moduleId } = e.detail;
|
const { layerId, moduleId } = e.detail;
|
||||||
console.log('[shell] layer-switch:', moduleId, 'layerId:', layerId, 'tabCache:', !!tabCache, 'layers:', layers.length);
|
|
||||||
currentModuleId = moduleId;
|
currentModuleId = moduleId;
|
||||||
saveTabs();
|
saveTabs();
|
||||||
if (tabCache) {
|
if (tabCache) {
|
||||||
const switchId = moduleId; // capture for staleness check
|
const switchId = moduleId; // capture for staleness check
|
||||||
tabCache.switchTo(moduleId).then(ok => {
|
tabCache.switchTo(moduleId).then(ok => {
|
||||||
// If user already clicked a different tab, don't navigate for this one
|
// If user already clicked a different tab, don't navigate for this one
|
||||||
if (currentModuleId !== switchId) {
|
if (currentModuleId !== switchId) return;
|
||||||
console.log('[shell] switchTo result:', ok, 'for', switchId, '(stale, user switched to', currentModuleId + ')');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('[shell] switchTo result:', ok, 'for', moduleId);
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
tabBar.setAttribute('active', layerId);
|
tabBar.setAttribute('active', layerId);
|
||||||
} else {
|
} else {
|
||||||
const url = window.__rspaceNavUrl(spaceSlug, moduleId);
|
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
|
||||||
console.log('[shell] switchTo failed, navigating to:', url);
|
|
||||||
window.location.href = url;
|
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
if (currentModuleId !== switchId) return; // stale
|
if (currentModuleId !== switchId) return; // stale
|
||||||
|
|
@ -1185,7 +1177,6 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
|
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log('[shell] no tabCache, navigating to:', window.__rspaceNavUrl(spaceSlug, moduleId));
|
|
||||||
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
|
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,7 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
this.#render();
|
this.#render();
|
||||||
this.#renderBadge();
|
this.#renderBadge();
|
||||||
|
|
||||||
// GC stale peers every 5s (all modes — prevents lingering ghost peers)
|
// GC timer starts lazily when first peer arrives (see #ensureGcTimer)
|
||||||
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
|
|
||||||
|
|
||||||
if (!this.#externalPeers) {
|
if (!this.#externalPeers) {
|
||||||
// Explicit doc-id attribute (fallback)
|
// Explicit doc-id attribute (fallback)
|
||||||
|
|
@ -462,6 +461,13 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
|
|
||||||
// ── GC stale peers ──
|
// ── GC stale peers ──
|
||||||
|
|
||||||
|
/** Start GC timer lazily — only when peers exist, stop when they're all gone. */
|
||||||
|
#ensureGcTimer() {
|
||||||
|
if (!this.#gcInterval && this.#peers.size > 0) {
|
||||||
|
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#gcPeers() {
|
#gcPeers() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const staleThreshold = this.#externalPeers ? 30000 : 15000;
|
const staleThreshold = this.#externalPeers ? 30000 : 15000;
|
||||||
|
|
@ -480,6 +486,11 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
}
|
}
|
||||||
if (this.#panelOpen) this.#renderPanel();
|
if (this.#panelOpen) this.#renderPanel();
|
||||||
}
|
}
|
||||||
|
// Stop timer when no peers remain
|
||||||
|
if (this.#peers.size === 0 && this.#gcInterval) {
|
||||||
|
clearInterval(this.#gcInterval);
|
||||||
|
this.#gcInterval = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deduplicate peers by username, keeping the most recently seen entry per user. */
|
/** Deduplicate peers by username, keeping the most recently seen entry per user. */
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
* Polls every 5s as fallback (sync may appear after component mounts).
|
* Polls every 5s as fallback (sync may appear after component mounts).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const POLL_INTERVAL = 5_000;
|
const POLL_INTERVAL = 30_000;
|
||||||
|
|
||||||
interface CommentMessage {
|
interface CommentMessage {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,11 @@ export class RStackUserDashboard extends HTMLElement {
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.#render();
|
this.#render();
|
||||||
this.#loadData();
|
// Defer data loading until the dashboard is actually visible.
|
||||||
|
// It starts display:none and only shows when all tabs are closed.
|
||||||
|
if (this.offsetParent !== null) {
|
||||||
|
this.#loadData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
attributeChangedCallback() {
|
attributeChangedCallback() {
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,19 @@ RStackUserDashboard.define();
|
||||||
|
|
||||||
// ── Offline Runtime (lazy-loaded) ──
|
// ── Offline Runtime (lazy-loaded) ──
|
||||||
// Automerge + WASM (~2.5MB) loaded in a separate chunk to avoid blocking first paint.
|
// Automerge + WASM (~2.5MB) loaded in a separate chunk to avoid blocking first paint.
|
||||||
// Components that depend on window.__rspaceOfflineRuntime already handle late init.
|
// Deferred until the browser is idle so it doesn't compete with LCP resources.
|
||||||
const spaceSlug = document.body?.getAttribute("data-space-slug");
|
const spaceSlug = document.body?.getAttribute("data-space-slug");
|
||||||
if (spaceSlug) {
|
if (spaceSlug) {
|
||||||
import('./shell-offline').then(m => m.initOffline(spaceSlug)).catch(e => {
|
const loadOffline = () => {
|
||||||
console.warn("[shell] Failed to load offline chunk:", e);
|
import('./shell-offline').then(m => m.initOffline(spaceSlug)).catch(e => {
|
||||||
});
|
console.warn("[shell] Failed to load offline chunk:", e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if ('requestIdleCallback' in window) {
|
||||||
|
(window as any).requestIdleCallback(loadOffline, { timeout: 3000 });
|
||||||
|
} else {
|
||||||
|
setTimeout(loadOffline, 1500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Track space visits for dashboard recency sorting ──
|
// ── Track space visits for dashboard recency sorting ──
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue