diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 8e24d40..7a87b0b 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -8,6 +8,8 @@ import type { MembranePermeability, } from "./connection-types"; import { computeMembranePermeability } from "./connection-types"; +import { makeChangeMessage, parseChangeMessage } from "../shared/local-first/change-message"; +import type { HistoryEntry } from "../shared/components/rstack-history-panel"; // Shape data stored in Automerge document export interface ShapeData { @@ -506,7 +508,7 @@ export class CommunitySync extends EventTarget { #updateShapeInDoc(shape: FolkShape): void { const shapeData = this.#shapeToData(shape); - this.#doc = Automerge.change(this.#doc, `Update shape ${shape.id}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update shape ${shape.id}`), (doc) => { if (!doc.shapes) doc.shapes = {}; doc.shapes[shape.id] = JSON.parse(JSON.stringify(shapeData)); }); @@ -575,7 +577,7 @@ export class CommunitySync extends EventTarget { * Add raw shape data directly (for shapes without DOM elements, like wb-svg drawings). */ addShapeData(shapeData: ShapeData): void { - this.#doc = Automerge.change(this.#doc, `Add shape ${shapeData.id}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add shape ${shapeData.id}`), (doc) => { if (!doc.shapes) doc.shapes = {}; doc.shapes[shapeData.id] = shapeData; }); @@ -592,7 +594,7 @@ export class CommunitySync extends EventTarget { const existing = this.#doc.shapes?.[shapeId]; if (!existing) return; - this.#doc = Automerge.change(this.#doc, `Update shape ${shapeId}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update shape ${shapeId}`), (doc) => { if (doc.shapes && doc.shapes[shapeId]) { for (const [key, value] of Object.entries(fields)) { (doc.shapes[shapeId] as Record)[key] = value; @@ -614,7 +616,7 @@ export class CommunitySync extends EventTarget { * Three-state: present → forgotten (faded) → deleted */ forgetShape(shapeId: string, did: string): void { - this.#doc = Automerge.change(this.#doc, `Forget shape ${shapeId}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Forget shape ${shapeId}`), (doc) => { if (doc.shapes && doc.shapes[shapeId]) { const shape = doc.shapes[shapeId] as Record; if (!shape.forgottenBy || typeof shape.forgottenBy !== 'object') { @@ -644,7 +646,7 @@ export class CommunitySync extends EventTarget { const wasDeleted = !!(shapeData as Record).deleted; - this.#doc = Automerge.change(this.#doc, `Remember shape ${shapeId}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remember shape ${shapeId}`), (doc) => { if (doc.shapes && doc.shapes[shapeId]) { const shape = doc.shapes[shapeId] as Record; shape.forgottenBy = {}; @@ -672,7 +674,7 @@ export class CommunitySync extends EventTarget { * Shape stays in Automerge doc for restore from memory panel. */ hardDeleteShape(shapeId: string): void { - this.#doc = Automerge.change(this.#doc, `Delete shape ${shapeId}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Delete shape ${shapeId}`), (doc) => { if (doc.shapes && doc.shapes[shapeId]) { (doc.shapes[shapeId] as Record).deleted = true; } @@ -1089,7 +1091,7 @@ export class CommunitySync extends EventTarget { /** Add a layer to the document */ addLayer(layer: Layer): void { - this.#doc = Automerge.change(this.#doc, `Add layer ${layer.id}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add layer ${layer.id}`), (doc) => { if (!doc.layers) doc.layers = {}; doc.layers[layer.id] = layer; }); @@ -1100,7 +1102,7 @@ export class CommunitySync extends EventTarget { /** Remove a layer */ removeLayer(layerId: string): void { - this.#doc = Automerge.change(this.#doc, `Remove layer ${layerId}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remove layer ${layerId}`), (doc) => { if (doc.layers && doc.layers[layerId]) { delete doc.layers[layerId]; } @@ -1125,7 +1127,7 @@ export class CommunitySync extends EventTarget { /** Update a layer's properties */ updateLayer(layerId: string, updates: Partial): void { - this.#doc = Automerge.change(this.#doc, `Update layer ${layerId}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update layer ${layerId}`), (doc) => { if (doc.layers && doc.layers[layerId]) { for (const [key, value] of Object.entries(updates)) { (doc.layers[layerId] as unknown as Record)[key] = value; @@ -1138,7 +1140,7 @@ export class CommunitySync extends EventTarget { /** Set active layer */ setActiveLayer(layerId: string): void { - this.#doc = Automerge.change(this.#doc, `Switch to layer ${layerId}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Switch to layer ${layerId}`), (doc) => { doc.activeLayerId = layerId; }); this.#scheduleSave(); @@ -1148,7 +1150,7 @@ export class CommunitySync extends EventTarget { /** Set layer view mode */ setLayerViewMode(mode: "flat" | "stack"): void { - this.#doc = Automerge.change(this.#doc, `Set view mode ${mode}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Set view mode ${mode}`), (doc) => { doc.layerViewMode = mode; }); this.#scheduleSave(); @@ -1157,7 +1159,7 @@ export class CommunitySync extends EventTarget { /** Add a flow between layers */ addFlow(flow: LayerFlow): void { - this.#doc = Automerge.change(this.#doc, `Add flow ${flow.id}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add flow ${flow.id}`), (doc) => { if (!doc.flows) doc.flows = {}; doc.flows[flow.id] = flow; }); @@ -1168,7 +1170,7 @@ export class CommunitySync extends EventTarget { /** Remove a flow */ removeFlow(flowId: string): void { - this.#doc = Automerge.change(this.#doc, `Remove flow ${flowId}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remove flow ${flowId}`), (doc) => { if (doc.flows && doc.flows[flowId]) { delete doc.flows[flowId]; } @@ -1179,7 +1181,7 @@ export class CommunitySync extends EventTarget { /** Update flow properties */ updateFlow(flowId: string, updates: Partial): void { - this.#doc = Automerge.change(this.#doc, `Update flow ${flowId}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update flow ${flowId}`), (doc) => { if (doc.flows && doc.flows[flowId]) { for (const [key, value] of Object.entries(updates)) { (doc.flows[flowId] as unknown as Record)[key] = value; @@ -1213,7 +1215,7 @@ export class CommunitySync extends EventTarget { /** Add a connection to the document */ addConnection(conn: SpaceConnection): void { - this.#doc = Automerge.change(this.#doc, `Add connection ${conn.id}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add connection ${conn.id}`), (doc) => { if (!doc.connections) doc.connections = {}; doc.connections[conn.id] = conn; }); @@ -1224,7 +1226,7 @@ export class CommunitySync extends EventTarget { /** Remove a connection */ removeConnection(connId: string): void { - this.#doc = Automerge.change(this.#doc, `Remove connection ${connId}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remove connection ${connId}`), (doc) => { if (doc.connections && doc.connections[connId]) { delete doc.connections[connId]; } @@ -1236,7 +1238,7 @@ export class CommunitySync extends EventTarget { /** Update connection properties */ updateConnection(connId: string, updates: Partial): void { - this.#doc = Automerge.change(this.#doc, `Update connection ${connId}`, (doc) => { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update connection ${connId}`), (doc) => { if (doc.connections && doc.connections[connId]) { for (const [key, value] of Object.entries(updates)) { if (key === 'id') continue; @@ -1270,6 +1272,40 @@ export class CommunitySync extends EventTarget { return computeMembranePermeability(this.getConnections(), this.#communitySlug); } + // ── History API ── + + /** + * View document state at a specific point in history. + */ + viewAt(heads: string[]): CommunityDoc { + return Automerge.view(this.#doc, heads); + } + + /** + * Get parsed history entries for the activity feed. + */ + getHistoryEntries(limit?: number): HistoryEntry[] { + try { + const changes = Automerge.getHistory(this.#doc); + const entries: HistoryEntry[] = changes.map((entry, i) => { + const change = entry.change; + const parsed = parseChangeMessage(change.message || null); + return { + hash: change.hash || `change-${i}`, + actor: change.actor || "unknown", + username: parsed.user || "Unknown", + time: parsed.ts || (change.time ? change.time * 1000 : 0), + message: parsed.text, + opsCount: change.ops?.length || 0, + seq: change.seq || i, + }; + }); + return limit ? entries.slice(-limit) : entries; + } catch { + return []; + } + } + /** * Disconnect from server */ diff --git a/server/shell.ts b/server/shell.ts index 916eb00..ecf6ac6 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -128,7 +128,7 @@ export function renderShell(opts: ShellOptions): string {
- ${spaceEncrypted ? '🔒' : ''} + ${spaceEncrypted ? '🔒' : ''}
@@ -137,10 +137,12 @@ export function renderShell(opts: ShellOptions): string { Try Demo +
+
@@ -158,6 +160,27 @@ export function renderShell(opts: ShellOptions): string { if (panel) panel.toggle(); }); + // ── History panel toggle ── + document.getElementById('history-btn')?.addEventListener('click', () => { + const panel = document.querySelector('rstack-history-panel'); + if (panel) panel.toggle(); + }); + + // Wire history panel to offline runtime doc (module pages) + { + const hp = document.querySelector('rstack-history-panel'); + if (hp && window.__rspaceOfflineRuntime) { + const rt = window.__rspaceOfflineRuntime; + rt.init().then(() => { + const docs = rt.documentManager?.listAll() || []; + if (docs.length > 0) { + const doc = rt.documentManager.get(docs[0]); + if (doc) hp.setDoc(doc); + } + }).catch(() => {}); + } + } + // ── Invite acceptance on page load ── (function() { var params = new URLSearchParams(window.location.search); diff --git a/shared/components/rstack-history-panel.ts b/shared/components/rstack-history-panel.ts new file mode 100644 index 0000000..d720a19 --- /dev/null +++ b/shared/components/rstack-history-panel.ts @@ -0,0 +1,699 @@ +/** + * — Document history slide-out panel. + * + * Activity feed shows Automerge change history with author attribution, + * grouped by author within time windows. Time Machine tab provides a + * scrubber over the change timeline to view document state at any point. + */ + +import * as Automerge from "@automerge/automerge"; +import { parseChangeMessage, type ParsedChangeMessage } from "../local-first/change-message"; + +// ── Types ── + +export interface HistoryEntry { + hash: string; + actor: string; + username: string; + time: number; + message: string; + opsCount: number; + seq: number; +} + +export interface GroupedEntry { + username: string; + entries: HistoryEntry[]; + timeRange: { start: number; end: number }; + summary: string; +} + +interface TimeGroup { + label: string; + groups: GroupedEntry[]; +} + +// ── Component ── + +export class RStackHistoryPanel extends HTMLElement { + private _open = false; + private _tab: "activity" | "timemachine" = "activity"; + private _doc: Automerge.Doc | null = null; + private _entries: HistoryEntry[] = []; + private _visibleCount = 50; + private _timeMachineIndex = -1; + private _timeMachineSnapshot: any = null; + private _docChangeHandler: (() => void) | null = null; + + static define() { + if (!customElements.get("rstack-history-panel")) { + customElements.define("rstack-history-panel", RStackHistoryPanel); + } + } + + connectedCallback() { + this.attachShadow({ mode: "open" }); + this._render(); + } + + open() { + this._open = true; + this._refreshHistory(); + this._render(); + // Toggle active state on button + document.getElementById("history-btn")?.classList.add("active"); + } + + close() { + this._open = false; + this._timeMachineSnapshot = null; + this._render(); + document.getElementById("history-btn")?.classList.remove("active"); + } + + toggle() { + if (this._open) this.close(); else this.open(); + } + + setDoc(doc: Automerge.Doc) { + this._doc = doc; + if (this._open) { + this._refreshHistory(); + this._render(); + } + } + + // ── History extraction ── + + private _refreshHistory() { + if (!this._doc) { + this._entries = []; + return; + } + + try { + const changes = Automerge.getHistory(this._doc); + this._entries = changes.map((entry, i) => { + const change = entry.change; + const parsed = parseChangeMessage(change.message || null); + return { + hash: change.hash || `change-${i}`, + actor: change.actor || "unknown", + username: parsed.user || "Unknown", + time: parsed.ts || (change.time ? change.time * 1000 : 0), + message: parsed.text, + opsCount: change.ops?.length || 0, + seq: change.seq || i, + }; + }); + } catch (e) { + console.warn("[HistoryPanel] Failed to extract history:", e); + this._entries = []; + } + } + + // ── Grouping ── + + private _groupEntries(): TimeGroup[] { + const entries = this._entries.slice(-this._visibleCount).reverse(); + if (entries.length === 0) return []; + + // Group by same author within 5-minute windows + const grouped: GroupedEntry[] = []; + let current: GroupedEntry | null = null; + const WINDOW = 5 * 60 * 1000; + + for (const entry of entries) { + if ( + current && + current.username === entry.username && + entry.time - current.timeRange.end < WINDOW + ) { + current.entries.push(entry); + current.timeRange.start = Math.min(current.timeRange.start, entry.time); + current.timeRange.end = Math.max(current.timeRange.end, entry.time); + } else { + current = { + username: entry.username, + entries: [entry], + timeRange: { start: entry.time, end: entry.time }, + summary: "", + }; + grouped.push(current); + } + } + + // Build summaries + for (const g of grouped) { + g.summary = this._buildSummary(g.entries); + } + + // Nest into time groups + const now = Date.now(); + const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); + const yesterdayStart = new Date(todayStart); yesterdayStart.setDate(yesterdayStart.getDate() - 1); + const weekStart = new Date(todayStart); weekStart.setDate(weekStart.getDate() - 7); + + const timeGroups: TimeGroup[] = []; + const buckets: Record = {}; + + for (const g of grouped) { + const t = g.timeRange.end; + let label: string; + if (t >= todayStart.getTime()) label = "Today"; + else if (t >= yesterdayStart.getTime()) label = "Yesterday"; + else if (t >= weekStart.getTime()) label = "This Week"; + else label = "Older"; + + if (!buckets[label]) buckets[label] = []; + buckets[label].push(g); + } + + for (const label of ["Today", "Yesterday", "This Week", "Older"]) { + if (buckets[label]?.length) { + timeGroups.push({ label, groups: buckets[label] }); + } + } + + return timeGroups; + } + + private _buildSummary(entries: HistoryEntry[]): string { + const actions: Record = {}; + for (const e of entries) { + const msg = e.message.toLowerCase(); + if (msg.startsWith("update")) actions["edited"] = (actions["edited"] || 0) + 1; + else if (msg.startsWith("add")) actions["added"] = (actions["added"] || 0) + 1; + else if (msg.startsWith("delete") || msg.startsWith("remove")) actions["removed"] = (actions["removed"] || 0) + 1; + else if (msg.startsWith("forget")) actions["forgot"] = (actions["forgot"] || 0) + 1; + else if (msg.startsWith("remember")) actions["remembered"] = (actions["remembered"] || 0) + 1; + else actions["changed"] = (actions["changed"] || 0) + 1; + } + return Object.entries(actions) + .map(([action, count]) => `${action} ${count} item${count > 1 ? "s" : ""}`) + .join(", "); + } + + // ── Time Machine ── + + private _onScrub(index: number) { + if (!this._doc || this._entries.length === 0) return; + this._timeMachineIndex = index; + + try { + const changes = Automerge.getHistory(this._doc); + if (index >= 0 && index < changes.length) { + // Get heads at this point in history + const hash = changes[index].change.hash; + if (hash) { + this._timeMachineSnapshot = Automerge.view(this._doc, [hash]); + } + } + } catch (e) { + console.warn("[HistoryPanel] Time machine error:", e); + } + + this._renderTimeMachineDetails(); + } + + // ── Rendering ── + + private _render() { + if (!this.shadowRoot) return; + + if (!this._open) { + this.shadowRoot.innerHTML = ""; + return; + } + + const timeGroups = this._groupEntries(); + + const activityHTML = timeGroups.length === 0 + ? '
No history available yet. Make some changes to see them here.
' + : timeGroups.map(tg => ` +
+
${tg.label}
+ ${tg.groups.map(g => ` +
+
+
${this._initial(g.username)}
+
+ ${this._esc(g.username)} + ${this._relativeTime(g.timeRange.end)} +
+
+
${this._esc(g.summary)}
+
+ ${g.entries.slice(0, 5).map(e => ` +
+ ${this._esc(e.message)} + ${e.opsCount} op${e.opsCount !== 1 ? "s" : ""} +
+ `).join("")} + ${g.entries.length > 5 ? `
+${g.entries.length - 5} more
` : ""} +
+
+ `).join("")} +
+ `).join(""); + + const totalChanges = this._entries.length; + const scrubberMax = Math.max(0, totalChanges - 1); + const scrubberVal = this._timeMachineIndex >= 0 ? this._timeMachineIndex : scrubberMax; + + const timeMachineHTML = totalChanges === 0 + ? '
No history to scrub. Make some changes first.
' + : ` +
+
+ + +
+
+
Drag the slider to view document state at any point in time.
+
+
+ `; + + this.shadowRoot.innerHTML = ` + +
+
+
+

History

+ +
+
+ + +
+
+ ${this._tab === "activity" ? activityHTML : timeMachineHTML} +
+ ${this._entries.length > this._visibleCount && this._tab === "activity" ? ` +
+ +
+ ` : ""} +
+ `; + + this._bindEvents(); + } + + private _renderTimeMachineDetails() { + const details = this.shadowRoot?.getElementById("tm-details"); + if (!details) return; + + const entry = this._entries[this._timeMachineIndex]; + if (!entry) { + details.innerHTML = '
Select a point in history.
'; + return; + } + + const parsed = parseChangeMessage(entry.message); + + // Show snapshot info + const shapeCount = this._timeMachineSnapshot?.shapes + ? Object.keys(this._timeMachineSnapshot.shapes).length + : 0; + + details.innerHTML = ` +
+
+ Time + ${entry.time ? new Date(entry.time).toLocaleString() : "Unknown"} +
+
+ Author + ${this._esc(entry.username)} +
+
+ Message + ${this._esc(entry.message)} +
+
+ Operations + ${entry.opsCount} +
+ ${this._timeMachineSnapshot ? ` +
+ Shapes at this point + ${shapeCount} +
+ ` : ""} +
+ `; + + // Update the label + const label = this.shadowRoot?.querySelector(".time-machine__label"); + if (label) label.textContent = `Change ${this._timeMachineIndex + 1} of ${this._entries.length}`; + } + + private _bindEvents() { + const sr = this.shadowRoot!; + + sr.getElementById("close-btn")?.addEventListener("click", () => this.close()); + sr.getElementById("overlay")?.addEventListener("click", () => this.close()); + + // Tab switching + sr.querySelectorAll(".tab").forEach(btn => { + btn.addEventListener("click", () => { + this._tab = (btn as HTMLElement).dataset.tab as "activity" | "timemachine"; + this._render(); + }); + }); + + // Load more + sr.getElementById("load-more-btn")?.addEventListener("click", () => { + this._visibleCount += 50; + this._render(); + }); + + // Time machine scrubber + const scrubber = sr.getElementById("tm-scrubber") as HTMLInputElement; + if (scrubber) { + scrubber.addEventListener("input", () => { + this._onScrub(parseInt(scrubber.value, 10)); + }); + } + } + + // ── Helpers ── + + private _initial(name: string): string { + return (name.charAt(0) || "?").toUpperCase(); + } + + private _relativeTime(ts: number): string { + if (!ts) return ""; + const diff = Date.now() - ts; + if (diff < 60_000) return "just now"; + if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`; + return new Date(ts).toLocaleDateString(); + } + + private _esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } +} + +// ── CSS ── + +const PANEL_CSS = ` +:host { + display: contents; +} + +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 200000; + animation: fadeIn 0.2s ease; +} + +.panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(420px, 92vw); + background: var(--rs-bg-surface, #1e293b); + border-left: 1px solid var(--rs-border, #334155); + z-index: 200001; + display: flex; + flex-direction: column; + animation: slideIn 0.25s ease; + color: var(--rs-text-primary, #e2e8f0); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +@keyframes slideIn { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--rs-btn-secondary-bg, #334155); + flex-shrink: 0; +} + +.panel-header h2 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + background: linear-gradient(135deg, #14b8a6, #22d3ee); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.close-btn { + background: none; + border: none; + color: var(--rs-text-muted, #64748b); + font-size: 1.5rem; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + line-height: 1; +} +.close-btn:hover { color: var(--rs-text-primary); background: var(--rs-btn-secondary-bg); } + +/* Tabs */ +.tabs { + display: flex; + border-bottom: 1px solid var(--rs-btn-secondary-bg, #334155); + flex-shrink: 0; +} + +.tab { + flex: 1; + padding: 10px 16px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--rs-text-secondary, #94a3b8); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.tab:hover { color: var(--rs-text-primary); } +.tab.active { + color: #14b8a6; + border-bottom-color: #14b8a6; +} + +.panel-content { + flex: 1; + overflow-y: auto; + padding: 0; +} + +.empty-state { + padding: 32px 20px; + text-align: center; + color: var(--rs-text-muted, #64748b); + font-size: 0.85rem; + line-height: 1.5; +} + +/* Time groups */ +.time-group { + padding: 0; +} + +.time-group__label { + position: sticky; + top: 0; + padding: 8px 20px; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--rs-text-muted, #64748b); + background: var(--rs-bg-surface, #1e293b); + border-bottom: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.05)); + z-index: 1; +} + +/* Activity groups */ +.activity-group { + padding: 12px 20px; + border-bottom: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.05)); + transition: background 0.15s; +} +.activity-group:hover { + background: var(--rs-bg-hover, rgba(255,255,255,0.03)); +} + +.activity-group__header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.activity-group__avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(20,184,166,0.15); + color: #14b8a6; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 0.75rem; + flex-shrink: 0; +} + +.activity-group__info { + display: flex; + align-items: baseline; + gap: 8px; + flex: 1; + min-width: 0; +} + +.activity-group__user { + font-size: 0.82rem; + font-weight: 600; + color: var(--rs-text-primary); +} + +.activity-group__time { + font-size: 0.7rem; + color: var(--rs-text-muted, #64748b); +} + +.activity-group__summary { + font-size: 0.78rem; + color: var(--rs-text-secondary, #94a3b8); + margin-bottom: 6px; + padding-left: 38px; +} + +.activity-group__details { + padding-left: 38px; +} + +.activity-entry { + display: flex; + align-items: center; + justify-content: space-between; + padding: 2px 0; + font-size: 0.72rem; + color: var(--rs-text-muted, #64748b); +} + +.activity-entry__msg { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-entry__ops { + flex-shrink: 0; + margin-left: 8px; + font-size: 0.65rem; + color: var(--rs-text-muted); + opacity: 0.7; +} + +.activity-entry--more { + color: #14b8a6; + font-style: italic; +} + +/* Time Machine */ +.time-machine { + padding: 20px; +} + +.time-machine__scrubber { + margin-bottom: 20px; +} + +.time-machine__label { + display: block; + font-size: 0.78rem; + font-weight: 600; + color: var(--rs-text-secondary); + margin-bottom: 8px; +} + +.time-machine__range { + width: 100%; + accent-color: #14b8a6; + cursor: pointer; +} + +.time-machine__details { + min-height: 100px; +} + +.tm-info { + display: flex; + flex-direction: column; + gap: 8px; +} + +.tm-info__row { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.tm-info__label { + flex: 0 0 90px; + font-size: 0.72rem; + font-weight: 600; + color: var(--rs-text-muted, #64748b); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.tm-info__value { + flex: 1; + font-size: 0.82rem; + color: var(--rs-text-primary); + word-break: break-word; +} + +/* Load more */ +.load-more { + padding: 12px 20px; + border-top: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.05)); + flex-shrink: 0; +} + +.load-more__btn { + width: 100%; + padding: 8px; + background: rgba(20,184,166,0.1); + border: 1px solid rgba(20,184,166,0.2); + color: #14b8a6; + border-radius: 8px; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.load-more__btn:hover { + background: rgba(20,184,166,0.2); +} +`; diff --git a/shared/local-first/change-message.ts b/shared/local-first/change-message.ts new file mode 100644 index 0000000..40fc9d5 --- /dev/null +++ b/shared/local-first/change-message.ts @@ -0,0 +1,66 @@ +/** + * Author-attributed Automerge change messages. + * + * Embeds user identity as a JSON envelope in the change message string: + * {"msg":"Update shape abc","did":"did:key:z6Mk...","user":"jeffemmett","ts":1709913600000} + * + * Falls back to plain string for old messages — fully backward compatible. + */ + +const SESSION_KEY = "encryptid_session"; + +export interface ParsedChangeMessage { + text: string; + did?: string; + user?: string; + ts?: number; +} + +/** + * Wrap a plain change message with user identity from the current session. + * Returns JSON envelope if session exists, plain message otherwise. + */ +export function makeChangeMessage(msg: string): string { + try { + const raw = typeof localStorage !== "undefined" ? localStorage.getItem(SESSION_KEY) : null; + if (!raw) return msg; + + const session = JSON.parse(raw); + const did = session?.claims?.sub; + const user = session?.claims?.username; + if (!did && !user) return msg; + + return JSON.stringify({ + msg, + did: did || undefined, + user: user || undefined, + ts: Date.now(), + }); + } catch { + return msg; + } +} + +/** + * Parse a change message — handles both JSON envelopes and plain strings. + */ +export function parseChangeMessage(msg: string | null): ParsedChangeMessage { + if (!msg) return { text: "" }; + + // Try JSON envelope + if (msg.startsWith("{")) { + try { + const parsed = JSON.parse(msg); + return { + text: parsed.msg || msg, + did: parsed.did, + user: parsed.user, + ts: parsed.ts, + }; + } catch { + // Not valid JSON — treat as plain string + } + } + + return { text: msg }; +} diff --git a/shared/local-first/document.ts b/shared/local-first/document.ts index 9083c23..e14b4b8 100644 --- a/shared/local-first/document.ts +++ b/shared/local-first/document.ts @@ -8,6 +8,7 @@ */ import * as Automerge from '@automerge/automerge'; +import { makeChangeMessage } from './change-message'; // ============================================================================ // TYPES @@ -191,7 +192,7 @@ export class DocumentManager { throw new Error(`Document not open: ${id}`); } - const updated = Automerge.change(doc, message, fn as any); + const updated = Automerge.change(doc, makeChangeMessage(message), fn as any); this.#docs.set(id, updated); // Update metadata timestamp diff --git a/website/canvas.html b/website/canvas.html index 0e9090d..2cbec8c 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -1772,15 +1772,19 @@ +
+ +
@@ -2553,6 +2557,10 @@ const sync = new CommunitySync(communitySlug, offlineStore); window.__communitySync = sync; + // Wire history panel to CommunitySync doc + const historyPanel = document.querySelector('rstack-history-panel'); + if (historyPanel) historyPanel.setDoc(sync.doc); + // Non-blocking: open IndexedDB + load cache in background // UI handlers below register immediately regardless of this outcome (async () => { @@ -3014,6 +3022,9 @@ sync.addEventListener("synced", (e) => { document.getElementById("canvas-loading")?.remove(); console.log("[Canvas] Initial sync complete:", e.detail.shapes); + // Refresh history panel with latest doc + const hp = document.querySelector('rstack-history-panel'); + if (hp) hp.setDoc(sync.doc); }); // FUN: New — handle new shape from remote sync @@ -4725,19 +4736,15 @@ } }); - // ── Inject history button into header ── - { - const headerRight = document.querySelector('.rstack-header__right'); - if (headerRight) { - const historyBtn = document.createElement('button'); - historyBtn.id = 'toggle-memory'; - historyBtn.className = 'canvas-header-history'; - historyBtn.title = 'Recent Changes'; - historyBtn.innerHTML = ``; - const identity = headerRight.querySelector('rstack-identity'); - headerRight.insertBefore(historyBtn, identity); - } - } + // ── Settings + History panel toggles ── + document.getElementById('settings-btn')?.addEventListener('click', () => { + const panel = document.querySelector('rstack-space-settings'); + if (panel) panel.toggle(); + }); + document.getElementById('history-btn')?.addEventListener('click', () => { + const panel = document.querySelector('rstack-history-panel'); + if (panel) panel.toggle(); + }); // Memory panel — browse and remember forgotten shapes const memoryPanel = document.getElementById("memory-panel"); diff --git a/website/public/shell.css b/website/public/shell.css index fb3bf3c..9c075f0 100644 --- a/website/public/shell.css +++ b/website/public/shell.css @@ -98,6 +98,33 @@ body { -webkit-text-fill-color: transparent; } +/* ── Header icon buttons (settings gear, history clock) ── */ + +.rstack-header__settings-btn, +.rstack-header__history-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + border-radius: 6px; + background: none; + color: var(--rs-text-secondary); + cursor: pointer; + transition: color 0.15s, background 0.15s; +} + +.rstack-header__settings-btn:hover, +.rstack-header__history-btn:hover { + color: var(--rs-text-primary); + background: var(--rs-btn-secondary-bg, rgba(255,255,255,0.08)); +} + +.rstack-header__history-btn.active { + color: #fff; + background: var(--rs-accent, #14b8a6); +} + /* ── Tab row (below header) ── */ .rstack-tab-row { diff --git a/website/shell.ts b/website/shell.ts index 57d4943..1717c70 100644 --- a/website/shell.ts +++ b/website/shell.ts @@ -14,6 +14,7 @@ import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher" import { RStackTabBar } from "../shared/components/rstack-tab-bar"; import { RStackMi } from "../shared/components/rstack-mi"; import { RStackSpaceSettings } from "../shared/components/rstack-space-settings"; +import { RStackHistoryPanel } from "../shared/components/rstack-history-panel"; import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator"; import { rspaceNavUrl } from "../shared/url-helpers"; import { TabCache } from "../shared/tab-cache"; @@ -33,6 +34,7 @@ RStackSpaceSwitcher.define(); RStackTabBar.define(); RStackMi.define(); RStackSpaceSettings.define(); +RStackHistoryPanel.define(); RStackOfflineIndicator.define(); // ── Offline Runtime ──