feat(rdocs,encryptid): persist suggestions to Automerge + fix invite join page

rDocs: Suggestion metadata (author, type, text, status) is now persisted
to Automerge alongside comments, surviving page reloads and syncing across
clients. Accept/reject actions record final status (accepted/rejected).

EncryptID: The /join page now has "I'm new" / "I have an account" tabs so
existing users can sign in with their passkey to accept space invites,
instead of being forced to create a duplicate account.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-19 22:11:10 +00:00
parent 7a1ffbe635
commit 519404cb69
4 changed files with 265 additions and 124 deletions

View File

@ -34,6 +34,7 @@ interface CommentThread {
interface NotebookDoc {
items: Record<string, {
comments?: Record<string, CommentThread>;
suggestions?: Record<string, any>;
[key: string]: any;
}>;
[key: string]: any;

View File

@ -12,6 +12,7 @@
import * as Automerge from '@automerge/automerge';
import { makeDraggableAll } from '../../../shared/draggable';
import { notebookSchema } from '../schemas';
import type { SuggestionRecord } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document';
import { getAccessToken } from '../../../shared/components/rstack-identity';
import { broadcastPresence as sharedBroadcastPresence, startPresenceHeartbeat } from '../../../shared/collab-presence';
@ -207,6 +208,8 @@ class FolkDocsApp extends HTMLElement {
// ── Demo data ──
private demoNotebooks: (Notebook & { notes: Note[] })[] = [];
private _demoThreads = new Map<string, Record<string, any>>();
private _demoSuggestions = new Map<string, Record<string, any>>();
private _lastSuggestionAction: { id: string; action: 'accepted' | 'rejected' } | null = null;
constructor() {
super();
@ -2836,6 +2839,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
pop.querySelector('.sp-accept')!.addEventListener('click', () => {
if (this.editor) {
this._lastSuggestionAction = { id: suggestionId, action: 'accepted' };
acceptSuggestion(this.editor, suggestionId);
this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel(true);
@ -2844,6 +2848,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
});
pop.querySelector('.sp-reject')!.addEventListener('click', () => {
if (this.editor) {
this._lastSuggestionAction = { id: suggestionId, action: 'rejected' };
rejectSuggestion(this.editor, suggestionId);
this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel(true);
@ -2899,6 +2904,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
if (!panel) return;
const suggestions = this.collectSuggestions();
panel.suggestions = suggestions;
this.persistSuggestionsToAutomerge(suggestions);
const sidebar = this.shadow.getElementById('comment-sidebar');
if (sidebar && suggestions.length > 0) {
sidebar.classList.add('has-comments');
@ -2911,6 +2917,79 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
}
}
/** Persist suggestion metadata to Automerge so they survive across sessions. */
private persistSuggestionsToAutomerge(editorSuggestions: { id: string; type: 'insert' | 'delete'; text: string; authorId: string; authorName: string; createdAt: number }[]) {
const noteId = this.editorNoteId;
if (!noteId) return;
const editorIds = new Set(editorSuggestions.map(s => s.id));
// ── Demo mode ──
if (this.space === 'demo') {
if (!this._demoSuggestions.has(noteId)) {
this._demoSuggestions.set(noteId, {});
}
const store = this._demoSuggestions.get(noteId)!;
for (const s of editorSuggestions) {
if (!store[s.id]) {
store[s.id] = { ...s, status: 'pending' };
}
}
// Clean up suggestions no longer in the editor (accepted/rejected)
for (const id of Object.keys(store)) {
if (store[id].status === 'pending' && !editorIds.has(id)) {
const action = this._lastSuggestionAction;
store[id].status = (action?.id === id) ? action.action : 'accepted';
}
}
this._lastSuggestionAction = null;
return;
}
// ── Automerge mode ──
if (!this.doc?.items?.[noteId] || !this.subscribedDocId) return;
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
const existing: Record<string, SuggestionRecord> = (this.doc.items[noteId] as any).suggestions || {};
// Check if anything actually changed to avoid no-op writes
const hasNew = editorSuggestions.some(s => !existing[s.id]);
const hasRemoved = Object.entries(existing).some(([id, rec]) =>
rec.status === 'pending' && !editorIds.has(id));
if (!hasNew && !hasRemoved) return;
const lastAction = this._lastSuggestionAction;
this._lastSuggestionAction = null;
runtime.change(this.subscribedDocId as DocumentId, 'Sync suggestions', (d: any) => {
const item = d.items[noteId];
if (!item) return;
if (!item.suggestions) item.suggestions = {};
// Add new suggestions
for (const s of editorSuggestions) {
if (!item.suggestions[s.id]) {
item.suggestions[s.id] = {
id: s.id,
type: s.type,
text: s.text,
authorId: s.authorId,
authorName: s.authorName,
createdAt: s.createdAt,
status: 'pending',
};
}
}
// Mark removed suggestions with final status
for (const id of Object.keys(item.suggestions)) {
if (item.suggestions[id].status === 'pending' && !editorIds.has(id)) {
item.suggestions[id].status = (lastAction?.id === id) ? lastAction.action : 'accepted';
}
}
});
this.doc = runtime.get(this.subscribedDocId as DocumentId);
}
/** Show comment panel for a specific thread. */
private showCommentPanel(threadId?: string) {
const sidebar = this.shadow.getElementById('comment-sidebar');
@ -2928,6 +3007,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
// Listen for suggestion accept/reject from comment panel
panel.addEventListener('suggestion-accept', (e: CustomEvent) => {
if (this.editor && e.detail?.suggestionId) {
this._lastSuggestionAction = { id: e.detail.suggestionId, action: 'accepted' };
acceptSuggestion(this.editor, e.detail.suggestionId);
this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel(true);
@ -2935,6 +3015,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
});
panel.addEventListener('suggestion-reject', (e: CustomEvent) => {
if (this.editor && e.detail?.suggestionId) {
this._lastSuggestionAction = { id: e.detail.suggestionId, action: 'rejected' };
rejectSuggestion(this.editor, e.detail.suggestionId);
this.updateSuggestionReviewBar();
this.syncSuggestionsToPanel(true);

View File

@ -37,6 +37,16 @@ export interface CommentThread {
createdAt: number;
}
export interface SuggestionRecord {
id: string;
type: 'insert' | 'delete';
text: string;
authorId: string;
authorName: string;
createdAt: number;
status: 'pending' | 'accepted' | 'rejected';
}
export interface NoteItem {
id: string;
notebookId: string;
@ -62,6 +72,7 @@ export interface NoteItem {
conflictContent?: string;
collabEnabled?: boolean;
comments?: Record<string, CommentThread>;
suggestions?: Record<string, SuggestionRecord>;
createdAt: number;
updatedAt: number;
visibility?: import('../../shared/membrane').ObjectVisibility;

View File

@ -6215,6 +6215,18 @@ function joinPage(token: string): string {
font-style: italic;
display: none;
}
/* Tabs */
.tabs { display: flex; gap: 0; margin-bottom: 1.5rem; border-radius: 0.5rem; overflow: hidden; border: 1px solid rgba(255,255,255,0.15); }
.tab {
flex: 1; padding: 0.6rem; background: transparent; border: none;
color: #94a3b8; font-size: 0.85rem; font-weight: 500; cursor: pointer;
transition: all 0.2s;
}
.tab.active { background: rgba(124,58,237,0.2); color: #fff; }
.tab:hover:not(.active) { background: rgba(255,255,255,0.05); }
.panel { display: none; }
.panel.active { display: block; }
.form-group { margin-bottom: 1rem; text-align: left; }
.form-group label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; font-weight: 500; }
.form-group input {
@ -6244,15 +6256,6 @@ function joinPage(token: string): string {
color: #86efac; display: none;
}
.status { color: #94a3b8; font-size: 0.85rem; margin-top: 1rem; display: none; }
.step-indicator {
display: flex; justify-content: center; gap: 0.5rem; margin-bottom: 1.5rem;
}
.step {
width: 8px; height: 8px; border-radius: 50%;
background: rgba(255,255,255,0.15); transition: background 0.3s;
}
.step.active { background: #7c3aed; }
.step.done { background: #22c55e; }
.features {
margin-top: 1.5rem; text-align: left; font-size: 0.8rem; color: #94a3b8;
}
@ -6263,31 +6266,41 @@ function joinPage(token: string): string {
<body>
<div class="card">
<div class="logo">🔐</div>
<h1>Claim your rSpace</h1>
<p id="subtitle" class="sub">Create your passkey-protected identity</p>
<div class="step-indicator">
<div id="step1" class="step active"></div>
<div id="step2" class="step"></div>
<div id="step3" class="step"></div>
</div>
<h1 id="title">Join rSpace</h1>
<p id="subtitle" class="sub">Loading invite details...</p>
<div id="messageBox" class="message-box"></div>
<div id="error" class="error"></div>
<div id="success" class="success"></div>
<div id="registerForm">
<div class="form-group">
<label>Username</label>
<input type="text" id="username" placeholder="Choose a username" autocomplete="username" />
<div id="authSection" style="display:none;">
<div class="tabs">
<button class="tab active" onclick="switchTab('new')">I'm new</button>
<button class="tab" onclick="switchTab('existing')">I have an account</button>
</div>
<div class="form-group">
<label>Email</label>
<input type="email" id="email" placeholder="your@email.com" readonly />
<div id="panel-new" class="panel active">
<div class="form-group">
<label>Username</label>
<input type="text" id="username" placeholder="Choose a username" autocomplete="username" />
</div>
<div class="form-group">
<label>Email</label>
<input type="email" id="email" placeholder="your@email.com" readonly />
</div>
<button id="registerBtn" class="btn-primary" onclick="register()">
Create Passkey &amp; Join
</button>
</div>
<div id="panel-existing" class="panel">
<p style="color: #cbd5e1; font-size: 0.9rem; margin-bottom: 1rem;">
Sign in with your existing passkey to accept this invitation.
</p>
<button id="loginBtn" class="btn-primary" onclick="doLogin()">
Sign in with Passkey
</button>
</div>
<button id="registerBtn" class="btn-primary" onclick="register()" disabled>
Create Passkey &amp; Claim
</button>
</div>
<p id="status" class="status"></p>
@ -6325,9 +6338,6 @@ function joinPage(token: string): string {
const errorEl = document.getElementById('error');
const successEl = document.getElementById('success');
const statusEl = document.getElementById('status');
const registerBtn = document.getElementById('registerBtn');
const usernameInput = document.getElementById('username');
const emailInput = document.getElementById('email');
const messageBox = document.getElementById('messageBox');
function showError(msg) {
@ -6341,11 +6351,24 @@ function joinPage(token: string): string {
statusEl.style.display = 'block';
errorEl.style.display = 'none';
}
function setStep(n) {
for (let i = 1; i <= 3; i++) {
const el = document.getElementById('step' + i);
el.className = 'step' + (i < n ? ' done' : i === n ? ' active' : '');
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function switchTab(tab) {
document.querySelectorAll('.tab').forEach((t, i) => {
t.classList.toggle('active', (tab === 'new' && i === 0) || (tab === 'existing' && i === 1));
});
document.getElementById('panel-new').classList.toggle('active', tab === 'new');
document.getElementById('panel-existing').classList.toggle('active', tab === 'existing');
errorEl.style.display = 'none';
}
// base64url helpers
function toB64url(buf) {
return btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
}
function fromB64url(s) {
return Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
}
// Load invite info
@ -6362,21 +6385,51 @@ function joinPage(token: string): string {
return;
}
inviteData = await res.json();
const spaceName = inviteData.spaceSlug || 'rSpace';
document.getElementById('title').textContent = 'Join ' + spaceName;
document.getElementById('subtitle').innerHTML =
'<span class="inviter">' + inviteData.invitedBy + '</span> invited you to join rSpace';
emailInput.value = inviteData.email;
'<span class="inviter">' + esc(inviteData.invitedBy) + '</span> invited you to join <strong>' + esc(spaceName) + '</strong>';
document.getElementById('email').value = inviteData.email;
if (inviteData.message) {
messageBox.textContent = '"' + inviteData.message + '"';
messageBox.textContent = '\\u201c' + inviteData.message + '\\u201d';
messageBox.style.display = 'block';
}
registerBtn.disabled = false;
document.getElementById('authSection').style.display = 'block';
} catch (err) {
showError('Failed to load invite. Please try again.');
}
})();
// Shared: claim invite and show success with redirect
async function claimAndRedirect(sessionToken) {
showStatus('Claiming invitation...');
const claimRes = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionToken }),
});
const claimData = await claimRes.json();
if (claimData.error) {
showError('Invite claim failed: ' + claimData.error);
return;
}
document.getElementById('authSection').style.display = 'none';
statusEl.style.display = 'none';
const destUrl = claimData.spaceSlug
? 'https://' + claimData.spaceSlug + '.rspace.online'
: 'https://rspace.online';
successEl.innerHTML = '<strong>Welcome!</strong><br>Your invitation has been accepted.' +
(claimData.spaceSlug ? '<br>You\\'ve been added to <strong>' + esc(claimData.spaceSlug) + '</strong>.' : '') +
'<br><br><a href="' + esc(destUrl) + '" style="display:inline-block;padding:0.7rem 1.5rem;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;text-decoration:none;border-radius:0.5rem;font-weight:600;">Go to ' + esc(claimData.spaceSlug || 'rSpace') + ' \\u2192</a>' +
'<p style="color:#94a3b8;font-size:0.8rem;margin-top:1rem;">You\\u2019ll sign in with your passkey when you get there.</p>';
successEl.style.display = 'block';
}
// ── New account: register with passkey ──
async function register() {
const username = usernameInput.value.trim();
const username = document.getElementById('username').value.trim();
if (!username || username.length < 2) {
showError('Username must be at least 2 characters');
return;
@ -6386,126 +6439,121 @@ function joinPage(token: string): string {
return;
}
registerBtn.disabled = true;
const btn = document.getElementById('registerBtn');
btn.disabled = true;
errorEl.style.display = 'none';
setStep(2);
showStatus('Starting passkey registration...');
try {
// Step 1: Start registration
const startRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
const startData = await startRes.json();
if (startData.error) {
showError(startData.error);
registerBtn.disabled = false;
setStep(1);
return;
}
if (startData.error) { showError(startData.error); btn.disabled = false; return; }
// Step 2: Create credential
showStatus('Follow your browser prompt to create a passkey...');
const { options } = startData;
const challengeBytes = Uint8Array.from(
atob(options.challenge.replace(/-/g, '+').replace(/_/g, '/')),
c => c.charCodeAt(0)
);
const userIdBytes = Uint8Array.from(
atob(options.user.id.replace(/-/g, '+').replace(/_/g, '/')),
c => c.charCodeAt(0)
);
const publicKeyOptions = {
challenge: challengeBytes,
rp: options.rp,
user: { ...options.user, id: userIdBytes },
pubKeyCredParams: options.pubKeyCredParams,
timeout: options.timeout,
authenticatorSelection: options.authenticatorSelection,
attestation: options.attestation,
};
const credential = await navigator.credentials.create({
publicKey: {
challenge: fromB64url(options.challenge),
rp: options.rp,
user: { ...options.user, id: fromB64url(options.user.id) },
pubKeyCredParams: options.pubKeyCredParams,
timeout: options.timeout,
authenticatorSelection: options.authenticatorSelection,
attestation: options.attestation,
},
});
const credential = await navigator.credentials.create({ publicKey: publicKeyOptions });
// Step 3: Complete registration
setStep(3);
showStatus('Completing registration...');
const credentialId = btoa(String.fromCharCode(...new Uint8Array(credential.rawId)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
const attestationObject = btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
const clientDataJSON = btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
const publicKeyBytes = credential.response.getPublicKey ? credential.response.getPublicKey() : null;
const publicKey = publicKeyBytes
? btoa(String.fromCharCode(...new Uint8Array(publicKeyBytes)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '')
: '';
const completeRes = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: startData.userId,
username,
challenge: options.challenge,
deviceName: detectDeviceName(),
credential: {
credentialId,
publicKey,
attestationObject,
clientDataJSON,
credentialId: toB64url(credential.rawId),
attestationObject: toB64url(credential.response.attestationObject),
clientDataJSON: toB64url(credential.response.clientDataJSON),
transports: credential.response.getTransports ? credential.response.getTransports() : [],
},
}),
});
const completeData = await completeRes.json();
if (!completeData.success) { showError(completeData.error || 'Registration failed'); btn.disabled = false; return; }
if (!completeData.success) {
showError(completeData.error || 'Registration failed');
registerBtn.disabled = false;
setStep(1);
return;
}
// Step 4: Claim the invite
showStatus('Claiming your invite...');
const claimRes = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionToken: completeData.token }),
});
const claimData = await claimRes.json();
if (claimData.error) {
// Registration succeeded but claim failed — still show partial success
showError('Account created but invite claim failed: ' + claimData.error);
return;
}
// Success!
document.getElementById('registerForm').style.display = 'none';
statusEl.style.display = 'none';
const destUrl = claimData.spaceSlug
? 'https://' + claimData.spaceSlug + '.rspace.online'
: 'https://rspace.online';
successEl.innerHTML = '<strong>Welcome to rSpace!</strong><br>Your identity has been created and your passkey is set up.' +
(claimData.spaceSlug ? '<br>You\\'ve been added to <strong>' + claimData.spaceSlug + '</strong>.' : '') +
'<br><br><a href="' + destUrl + '" style="color: #7c3aed;">Go to ' + (claimData.spaceSlug || 'rSpace') + ' →</a>';
successEl.style.display = 'block';
await claimAndRedirect(completeData.token);
} catch (err) {
if (err.name === 'NotAllowedError') {
showError('Passkey creation was cancelled. Please try again.');
} else {
showError(err.message || 'Registration failed');
}
registerBtn.disabled = false;
setStep(1);
btn.disabled = false;
}
}
// ── Existing account: sign in with passkey ──
async function doLogin() {
const btn = document.getElementById('loginBtn');
btn.disabled = true;
errorEl.style.display = 'none';
showStatus('Starting passkey authentication...');
try {
const startRes = await fetch('/api/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const { options } = await startRes.json();
showStatus('Touch your passkey...');
const pubKeyOpts = {
challenge: fromB64url(options.challenge),
rpId: options.rpId,
userVerification: options.userVerification,
timeout: options.timeout,
};
if (options.allowCredentials) {
pubKeyOpts.allowCredentials = options.allowCredentials.map(c => ({
type: c.type,
id: fromB64url(c.id),
transports: c.transports,
}));
}
const assertion = await navigator.credentials.get({ publicKey: pubKeyOpts });
const completeRes = await fetch('/api/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: options.challenge,
credential: {
credentialId: toB64url(assertion.rawId),
authenticatorData: toB64url(assertion.response.authenticatorData),
clientDataJSON: toB64url(assertion.response.clientDataJSON),
signature: toB64url(assertion.response.signature),
},
}),
});
const authResult = await completeRes.json();
if (!authResult.success) { showError(authResult.error || 'Authentication failed'); btn.disabled = false; return; }
await claimAndRedirect(authResult.token);
} catch (err) {
if (err.name === 'NotAllowedError') {
showError('Passkey authentication was cancelled.');
} else {
showError(err.message || 'Authentication failed');
}
btn.disabled = false;
}
}
</script>