diff --git a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts index b3b5b6bb..4d599cfd 100644 --- a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts +++ b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts @@ -10,8 +10,9 @@ import type { TourStep } from '../../../shared/tour-engine'; import { startPresenceHeartbeat } from '../../../shared/collab-presence'; import { CrowdSurfLocalFirstClient } from '../local-first-client'; import type { CrowdSurfDoc, CrowdSurfPrompt, Contribution } from '../schemas'; -import { getDecayProgress, getTimeRemaining, getRightSwipeCount, isReadyToTrigger, getUrgency, parseContributions } from '../schemas'; +import { getDecayProgress, getTimeRemaining, getRightSwipeCount, isReadyToTrigger, getUrgency, parseContributions, crowdsurfSchema, crowdsurfDocId } from '../schemas'; import { getModuleApiBase } from "../../../shared/url-helpers"; +import type { DocumentId } from "../../../shared/local-first/document"; // ── Auth helpers ── function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null { @@ -133,11 +134,21 @@ class FolkCrowdSurfDashboard extends HTMLElement { if (!localStorage.getItem('crowdsurf_tour_done')) { setTimeout(() => this._tour.start(), 800); } + this.subscribeCollabOverlay(); // Check expiry every 30s this._expiryTimer = window.setInterval(() => this.checkExpiry(), 30000); } + private async subscribeCollabOverlay() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + try { + const docId = crowdsurfDocId(this.space) as DocumentId; + await runtime.subscribe(docId, crowdsurfSchema); + } catch { /* runtime unavailable */ } + } + private extractPrompts(doc: CrowdSurfDoc) { const myDid = getMyDid(); const all = doc.prompts ? Object.values(doc.prompts) : []; @@ -425,7 +436,7 @@ class FolkCrowdSurfDashboard extends HTMLElement { return `
-
+
✗ Pass
✓ Join
@@ -604,7 +615,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
${leaderboard.map((p, i) => ` -
+
#${i + 1} ${this.esc(p.text)} ⚡ ${p.elo ?? 1500} diff --git a/modules/rmeets/components/folk-jitsi-room.ts b/modules/rmeets/components/folk-jitsi-room.ts index bf207d9d..c4096d6f 100644 --- a/modules/rmeets/components/folk-jitsi-room.ts +++ b/modules/rmeets/components/folk-jitsi-room.ts @@ -7,6 +7,8 @@ */ import { startPresenceHeartbeat } from '../../../shared/collab-presence'; +import type { DocumentId } from "../../../shared/local-first/document"; +import { meetsSchema, meetsDocId } from "../schemas"; class FolkJitsiRoom extends HTMLElement { private shadow: ShadowRoot; @@ -25,6 +27,7 @@ class FolkJitsiRoom extends HTMLElement { private directorAnimFrame: number | null = null; private directorError = ""; private _stopPresence: (() => void) | null = null; + private _offlineUnsub: (() => void) | null = null; constructor() { super(); @@ -41,13 +44,26 @@ class FolkJitsiRoom extends HTMLElement { this.render(); this.loadJitsiApi(); this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rmeets', context: this.room || 'Meeting' })); + if (this.space && this.space !== "demo") { + this.subscribeOffline(); + } } disconnectedCallback() { this._stopPresence?.(); + this._offlineUnsub?.(); this._offlineUnsub = null; this.dispose(); } + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + try { + const docId = meetsDocId(this.space) as DocumentId; + await runtime.subscribe(docId, meetsSchema); + } catch { /* runtime unavailable */ } + } + getApi() { return this.api; } executeCommand(cmd: string, ...args: any[]) { @@ -75,7 +91,7 @@ class FolkJitsiRoom extends HTMLElement { .director-info { font-size: 0.75rem; color: var(--rs-text-muted, #888); padding: 0 8px; white-space: nowrap; font-family: system-ui, sans-serif; } .director-error { font-size: 0.8rem; color: #ef4444; padding: 8px; font-family: system-ui, sans-serif; } -
+
Loading Jitsi Meet...
${this.isDirector && this.sessionId ? this.renderDirectorStrip() : ""} diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts index a03dc72a..ea3205c9 100644 --- a/modules/rtime/components/folk-timebank-app.ts +++ b/modules/rtime/components/folk-timebank-app.ts @@ -5,6 +5,10 @@ * Two views: "pool" (canvas orbs) and "weave" (SVG node editor). */ +import type { DocumentId } from "../../../shared/local-first/document"; +import { startPresenceHeartbeat } from '../../../shared/collab-presence'; +import { commitmentsSchema, commitmentsDocId } from "../schemas"; + // ── Constants ── const SKILL_COLORS: Record = { @@ -320,6 +324,8 @@ class FolkTimebankApp extends HTMLElement { private _currentExecTaskId: string | null = null; private _cyclosMembers: { id: string; name: string; balance: number }[] = []; private _theme: 'dark' | 'light' = 'dark'; + private _stopPresence: (() => void) | null = null; + private _offlineUnsub: (() => void) | null = null; constructor() { super(); @@ -349,6 +355,17 @@ class FolkTimebankApp extends HTMLElement { this.setupCanvas(); this.setupCollaborate(); this.fetchData(); + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rtime', context: 'Timebank' })); + if (this.space !== 'demo') this.subscribeOffline(); + } + + private async subscribeOffline() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + try { + const docId = commitmentsDocId(this.space) as DocumentId; + await runtime.subscribe(docId, commitmentsSchema); + } catch { /* runtime unavailable */ } } /** Derive API base from the current pathname — works for both subdomain and path routing. */ @@ -376,6 +393,7 @@ class FolkTimebankApp extends HTMLElement { disconnectedCallback() { if (this.animFrame) cancelAnimationFrame(this.animFrame); + this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null; } private async fetchData() { @@ -1318,6 +1336,7 @@ class FolkTimebankApp extends HTMLElement { private renderNode(node: WeaveNode): SVGGElement { const g = ns('g') as SVGGElement; g.setAttribute('data-id', node.id); + g.setAttribute('data-collab-id', `${node.type}:${node.id}`); g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')'); if (node.type === 'commitment') { diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts index 52e8a9fc..c12652af 100644 --- a/modules/rtime/mod.ts +++ b/modules/rtime/mod.ts @@ -23,6 +23,7 @@ import type { SyncServer } from '../../server/local-first/sync-server'; import { commitmentsSchema, tasksSchema, externalTimeLogsSchema, commitmentsDocId, tasksDocId, externalTimeLogsDocId, + SKILL_LABELS, } from './schemas'; import type { CommitmentsDoc, TasksDoc, ExternalTimeLogsDoc, diff --git a/server/index.ts b/server/index.ts index a5bd42ab..8410dd98 100644 --- a/server/index.ts +++ b/server/index.ts @@ -87,7 +87,7 @@ import { vnbModule } from "../modules/rvnb/mod"; import { crowdsurfModule } from "../modules/crowdsurf/mod"; import { timeModule } from "../modules/rtime/mod"; import { govModule } from "../modules/rgov/mod"; -import { sheetModule } from "../modules/rsheet/mod"; +import { sheetsModule } from "../modules/rsheets/mod"; import { exchangeModule } from "../modules/rexchange/mod"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import type { SpaceRoleString } from "./spaces"; @@ -154,7 +154,7 @@ registerModule(forumModule); registerModule(tubeModule); registerModule(tripsModule); registerModule(booksModule); -registerModule(sheetModule); +registerModule(sheetsModule); // registerModule(docsModule); // placeholder — not yet an rApp // ── Config ── @@ -943,11 +943,13 @@ app.post("/:space/api/comment-pins/notify", async (c) => { const space = c.req.param("space"); try { const body = await c.req.json(); - const { pinId, authorDid, authorName, text, pinIndex, isReply, mentionedDids } = body; + const { pinId, authorDid, authorName, text, pinIndex, isReply, mentionedDids, moduleId } = body; if (!pinId || !authorDid) { return c.json({ error: "Missing fields" }, 400); } + const effectiveModule = moduleId || "rspace"; + const isModuleComment = effectiveModule !== "rspace"; const members = await listSpaceMembers(space); const excludeDids = new Set([authorDid, ...(mentionedDids || [])]); const title = isReply @@ -963,12 +965,12 @@ app.post("/:space/api/comment-pins/notify", async (c) => { notify({ userDid: m.userDID, category: "module", - eventType: "canvas_comment", + eventType: isModuleComment ? "module_comment" : "canvas_comment", title, body: preview || `Comment pin #${pinIndex || "?"} in ${space}`, spaceSlug: space, - moduleId: "rspace", - actionUrl: `/rspace#pin-${pinId}`, + moduleId: effectiveModule, + actionUrl: `/${effectiveModule}#pin-${pinId}`, actorDid: authorDid, actorUsername: authorName, }), @@ -981,7 +983,7 @@ app.post("/:space/api/comment-pins/notify", async (c) => { sendSpaceNotification( space, title, - `

${preview}

View comment

`, + `

${preview}

View comment

`, ); }) .catch(() => {}); @@ -998,20 +1000,22 @@ app.post("/:space/api/comment-pins/notify-mention", async (c) => { const space = c.req.param("space"); try { const body = await c.req.json(); - const { pinId, authorDid, authorName, mentionedDids, pinIndex } = body; + const { pinId, authorDid, authorName, mentionedDids, pinIndex, moduleId } = body; if (!pinId || !authorDid || !mentionedDids?.length) { return c.json({ error: "Missing fields" }, 400); } + const effectiveModule = moduleId || "rspace"; + const isModuleComment = effectiveModule !== "rspace"; for (const did of mentionedDids) { await notify({ userDid: did, category: "module", - eventType: "canvas_mention", + eventType: isModuleComment ? "module_mention" : "canvas_mention", title: `${authorName} mentioned you in a comment`, body: `Comment pin #${pinIndex || "?"} in ${space}`, spaceSlug: space, - moduleId: "rspace", - actionUrl: `/rspace#pin-${pinId}`, + moduleId: effectiveModule, + actionUrl: `/${effectiveModule}#pin-${pinId}`, actorDid: authorDid, actorUsername: authorName, }); @@ -3643,6 +3647,10 @@ async function serveStatic(path: string, url?: URL): Promise { return null; } +// ── Module ID aliases (plural/misspelling → canonical) ── +const MODULE_ALIASES: Record = { rsheet: "rsheets" }; +function resolveModuleAlias(id: string): string { return MODULE_ALIASES[id] ?? id; } + // ── Standalone domain → module lookup ── const domainToModule = new Map(); for (const mod of getAllModules()) { @@ -3953,7 +3961,7 @@ const server = Bun.serve({ } // Block disabled modules before rewriting — redirect to space root - const firstModId = pathSegments[0].toLowerCase(); + const firstModId = resolveModuleAlias(pathSegments[0].toLowerCase()); if (firstModId !== "rspace") { await loadCommunity(subdomain); const spaceDoc = getDocumentData(subdomain); @@ -3962,9 +3970,9 @@ const server = Bun.serve({ } } - // Normalize module ID to lowercase (rTrips → rtrips) + // Normalize module ID to lowercase + resolve aliases (rTrips → rtrips, rsheet → rsheets) const normalizedPath = "/" + pathSegments.map((seg, i) => - i === 0 ? seg.toLowerCase() : seg + i === 0 ? resolveModuleAlias(seg.toLowerCase()) : seg ).join("/"); // Rewrite: /{moduleId}/... → /{space}/{moduleId}/... @@ -3985,7 +3993,7 @@ const server = Bun.serve({ const pathSegments = url.pathname.split("/").filter(Boolean); if (pathSegments.length >= 1) { - const firstSegment = pathSegments[0].toLowerCase(); + const firstSegment = resolveModuleAlias(pathSegments[0].toLowerCase()); const allModules = getAllModules(); const knownModuleIds = new Set(allModules.map((m) => m.id)); const mod = allModules.find((m) => m.id === firstSegment); diff --git a/server/notification-service.ts b/server/notification-service.ts index ca247120..da6fcd58 100644 --- a/server/notification-service.ts +++ b/server/notification-service.ts @@ -77,6 +77,7 @@ export type NotificationEventType = // Module | 'inbox_new_mail' | 'inbox_approval_needed' | 'choices_result' | 'notes_shared' | 'canvas_mention' | 'canvas_comment' + | 'module_comment' | 'module_mention' // System | 'guardian_invite' | 'guardian_accepted' | 'recovery_initiated' | 'recovery_approved' | 'device_linked' | 'security_alert' diff --git a/server/shell.ts b/server/shell.ts index 0b3c6c75..28a82a6d 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -342,6 +342,7 @@ export function renderShell(opts: ShellOptions): string {
+ ${moduleId !== "rspace" ? `` : ''}
${renderModuleSubNav(moduleId, spaceSlug, visibleModules, opts.isSubdomain ?? IS_PRODUCTION)} ${opts.tabs ? renderTabBar(opts.tabs, opts.activeTab, opts.tabBasePath || ((opts.isSubdomain ?? IS_PRODUCTION) ? `/${escapeAttr(moduleId)}` : `/${escapeAttr(spaceSlug)}/${escapeAttr(moduleId)}`)) : ''} diff --git a/shared/components/rstack-comment-bell.ts b/shared/components/rstack-comment-bell.ts index 61f6170e..fc3a96f7 100644 --- a/shared/components/rstack-comment-bell.ts +++ b/shared/components/rstack-comment-bell.ts @@ -2,15 +2,18 @@ * — Comment button with dropdown panel. * * Shows a chat-bubble icon in the header bar. Badge displays the count - * of unresolved comment pins on the current canvas. Clicking toggles a - * dropdown panel showing all comment threads, sorted by most recent - * message. Includes a "New Comment" button to enter pin-placement mode. + * of unresolved comment pins. Context-aware data source: + * - Canvas page: reads from `window.__communitySync?.doc?.commentPins` + * - Module pages: reads from the module-comments Automerge doc via runtime * - * Data source: `window.__communitySync?.doc?.commentPins` - * Listens for `comment-pins-changed` on `window` (re-dispatched by canvas). - * Polls every 5s as fallback (sync may appear after component mounts). + * Listens for `comment-pins-changed` + `module-comment-pins-changed` on `window`. + * Polls every 30s as fallback (sync may appear after component mounts). */ +import { moduleCommentsSchema, moduleCommentsDocId } from '../module-comment-schemas'; +import type { ModuleCommentPin, ModuleCommentsDoc } from '../module-comment-types'; +import type { DocumentId } from '../local-first/document'; + const POLL_INTERVAL = 30_000; interface CommentMessage { @@ -22,19 +25,28 @@ interface CommentMessage { interface CommentPinData { messages: CommentMessage[]; - anchor?: { x: number; y: number }; + anchor?: { x: number; y: number; type?: string; elementId?: string; moduleId?: string }; resolved?: boolean; createdBy?: string; createdByName?: string; createdAt?: number; } +type PinSource = 'canvas' | 'module'; + export class RStackCommentBell extends HTMLElement { #shadow: ShadowRoot; #count = 0; #open = false; #pollTimer: ReturnType | null = null; #syncRef: any = null; + #pinSource: PinSource = 'canvas'; + + // Module comments state + #moduleDocId: DocumentId | null = null; + #runtime: any = null; + #unsubModuleChange: (() => void) | null = null; + #runtimePollInterval: ReturnType | null = null; constructor() { super(); @@ -43,19 +55,71 @@ export class RStackCommentBell extends HTMLElement { connectedCallback() { this.#render(); - this.#syncRef = (window as any).__communitySync || null; + + // Determine context: module page or canvas + const moduleCommentsEl = document.querySelector('rstack-module-comments'); + if (moduleCommentsEl) { + this.#pinSource = 'module'; + this.#tryConnectRuntime(); + } else { + this.#pinSource = 'canvas'; + this.#syncRef = (window as any).__communitySync || null; + } + this.#refreshCount(); this.#pollTimer = setInterval(() => this.#refreshCount(), POLL_INTERVAL); window.addEventListener("comment-pins-changed", this.#onPinsChanged); + window.addEventListener("module-comment-pins-changed", this.#onPinsChanged); document.addEventListener("community-sync-ready", this.#onSyncReady); } disconnectedCallback() { if (this.#pollTimer) clearInterval(this.#pollTimer); + if (this.#runtimePollInterval) clearInterval(this.#runtimePollInterval); + if (this.#unsubModuleChange) this.#unsubModuleChange(); window.removeEventListener("comment-pins-changed", this.#onPinsChanged); + window.removeEventListener("module-comment-pins-changed", this.#onPinsChanged); document.removeEventListener("community-sync-ready", this.#onSyncReady); } + // ── Runtime Connection (for module comments) ── + + #tryConnectRuntime() { + const runtime = (window as any).__rspaceOfflineRuntime; + if (runtime?.isInitialized) { + this.#onRuntimeReady(runtime); + } else { + let polls = 0; + this.#runtimePollInterval = setInterval(() => { + const rt = (window as any).__rspaceOfflineRuntime; + if (rt?.isInitialized) { + clearInterval(this.#runtimePollInterval!); + this.#runtimePollInterval = null; + this.#onRuntimeReady(rt); + } else if (++polls > 20) { + clearInterval(this.#runtimePollInterval!); + this.#runtimePollInterval = null; + } + }, 500); + } + } + + async #onRuntimeReady(runtime: any) { + this.#runtime = runtime; + const space = document.body?.getAttribute("data-space-slug"); + if (!space) return; + + this.#moduleDocId = moduleCommentsDocId(space); + await runtime.subscribe(this.#moduleDocId, moduleCommentsSchema); + + this.#unsubModuleChange = runtime.onChange(this.#moduleDocId, () => { + this.#refreshCount(); + if (this.#open) this.#render(); + }); + + this.#refreshCount(); + } + #onSyncReady = (e: Event) => { const sync = (e as CustomEvent).detail?.sync; if (sync) { @@ -69,20 +133,56 @@ export class RStackCommentBell extends HTMLElement { if (this.#open) this.#render(); }; - #refreshCount() { - const sync = this.#syncRef || (window as any).__communitySync; - if (sync && !this.#syncRef) this.#syncRef = sync; - const pins = sync?.doc?.commentPins; - if (!pins) { - if (this.#count !== 0) { - this.#count = 0; - this.#updateBadge(); - } - return; + // ── Data Access ── + + #getCurrentModuleId(): string { + return document.body?.getAttribute("data-module-id") || "rspace"; + } + + #getModulePins(): [string, CommentPinData][] { + if (!this.#runtime || !this.#moduleDocId) return []; + const doc = this.#runtime.get(this.#moduleDocId) as ModuleCommentsDoc | undefined; + if (!doc?.pins) return []; + + const moduleId = this.#getCurrentModuleId(); + const entries: [string, CommentPinData][] = []; + for (const [pinId, pin] of Object.entries(doc.pins)) { + if (pin.anchor.moduleId !== moduleId) continue; + entries.push([pinId, { + messages: (pin.messages || []).map(m => ({ + text: m.text, + createdBy: m.authorId, + createdByName: m.authorName, + createdAt: m.createdAt, + })), + anchor: { x: 0, y: 0, type: 'element', elementId: pin.anchor.elementId, moduleId: pin.anchor.moduleId }, + resolved: pin.resolved, + createdBy: pin.createdBy, + createdByName: pin.createdByName, + createdAt: pin.createdAt, + }]); } - const newCount = Object.values(pins).filter( - (p: any) => !p.resolved - ).length; + return entries; + } + + #getCanvasPins(): [string, CommentPinData][] { + const sync = this.#syncRef || (window as any).__communitySync; + const pins = sync?.doc?.commentPins; + if (!pins) return []; + return Object.entries(pins) as [string, CommentPinData][]; + } + + #refreshCount() { + let pins: [string, CommentPinData][]; + if (this.#pinSource === 'module') { + pins = this.#getModulePins(); + } else { + const sync = this.#syncRef || (window as any).__communitySync; + if (sync && !this.#syncRef) this.#syncRef = sync; + pins = this.#getCanvasPins(); + } + + const newCount = pins.filter(([, p]) => !p.resolved).length; if (newCount !== this.#count) { this.#count = newCount; this.#updateBadge(); @@ -95,11 +195,10 @@ export class RStackCommentBell extends HTMLElement { } #getPins(): [string, CommentPinData][] { - const sync = this.#syncRef || (window as any).__communitySync; - const pins = sync?.doc?.commentPins; - if (!pins) return []; + const entries = this.#pinSource === 'module' + ? this.#getModulePins() + : this.#getCanvasPins(); - const entries = Object.entries(pins) as [string, CommentPinData][]; // Sort by most recent message timestamp, descending entries.sort((a, b) => { const aTime = this.#latestMessageTime(a[1]); @@ -165,14 +264,20 @@ export class RStackCommentBell extends HTMLElement { ? `Resolved` : `Open`; + // Show element anchor for module comments + const anchorLabel = pin.anchor?.elementId + ? `${this.#esc(pin.anchor.elementId)}` + : ""; + return ` -
+
${initial}
${this.#esc(authorName)} ${resolvedBadge}
+ ${anchorLabel}
${this.#esc(this.#truncate(text))}
${threadCount > 1 ? `${threadCount} messages` : ""} @@ -227,14 +332,23 @@ export class RStackCommentBell extends HTMLElement { window.dispatchEvent(new CustomEvent("comment-pin-activate")); }); - // Comment item clicks — focus the pin on canvas + // Comment item clicks — focus the pin (context-dependent) this.#shadow.querySelectorAll(".comment-item").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const pinId = (el as HTMLElement).dataset.pinId; - if (pinId) { - this.#open = false; - this.#render(); + const source = (el as HTMLElement).dataset.source; + const elementId = (el as HTMLElement).dataset.elementId; + if (!pinId) return; + + this.#open = false; + this.#render(); + + if (source === 'module') { + window.dispatchEvent(new CustomEvent("module-comment-pin-focus", { + detail: { pinId, elementId }, + })); + } else { window.dispatchEvent(new CustomEvent("comment-pin-focus", { detail: { pinId } })); } }); @@ -433,6 +547,13 @@ const STYLES = ` color: var(--rs-text-primary, #e2e8f0); } +.anchor-label { + font-size: 0.65rem; + color: var(--rs-text-muted, #64748b); + margin-bottom: 2px; + display: block; +} + .resolved-badge, .open-badge { font-size: 0.6rem; font-weight: 700; diff --git a/shared/components/rstack-module-comments.ts b/shared/components/rstack-module-comments.ts new file mode 100644 index 00000000..3a970b0f --- /dev/null +++ b/shared/components/rstack-module-comments.ts @@ -0,0 +1,829 @@ +/** + * — Spatial comment pins on rApp module pages. + * + * Anchors Figma-style threaded comment markers to `data-collab-id` elements. + * Stores pins in a per-space Automerge doc: `{space}:module-comments:pins`. + * + * Attributes: `module-id`, `space` (both set by shell). + * + * Events emitted: + * - `module-comment-pins-changed` on window when doc changes + * + * Events consumed: + * - `comment-pin-activate` on window → enter placement mode + * - `module-comment-pin-focus` on window → scroll to + highlight a pin + */ + +import { moduleCommentsSchema, moduleCommentsDocId } from '../module-comment-schemas'; +import type { ModuleCommentPin, ModuleCommentsDoc } from '../module-comment-types'; +import type { CommentPinMessage } from '../comment-pin-types'; +import type { DocumentId } from '../local-first/document'; +import { getModuleApiBase } from '../url-helpers'; + +interface SpaceMember { + userDID: string; + username: string; + displayName?: string; +} + +type OfflineRuntime = { + isInitialized: boolean; + subscribe>(docId: DocumentId, schema: any): Promise; + unsubscribe(docId: DocumentId): void; + change(docId: DocumentId, message: string, fn: (doc: T) => void): void; + get(docId: DocumentId): any; + onChange(docId: DocumentId, cb: (doc: any) => void): () => void; +}; + +export class RStackModuleComments extends HTMLElement { + #moduleId = ''; + #space = ''; + #docId: DocumentId | null = null; + #runtime: OfflineRuntime | null = null; + #unsubChange: (() => void) | null = null; + #overlay: HTMLDivElement | null = null; + #popover: HTMLDivElement | null = null; + #placementMode = false; + #activePinId: string | null = null; + #members: SpaceMember[] | null = null; + #mentionDropdown: HTMLDivElement | null = null; + + // Observers + #resizeObs: ResizeObserver | null = null; + #mutationObs: MutationObserver | null = null; + #intersectionObs: IntersectionObserver | null = null; + #scrollHandler: (() => void) | null = null; + #repositionTimer: ReturnType | null = null; + #runtimePollInterval: ReturnType | null = null; + + connectedCallback() { + this.#moduleId = this.getAttribute('module-id') || ''; + this.#space = this.getAttribute('space') || ''; + + if (!this.#moduleId || !this.#space || this.#moduleId === 'rspace') return; + + this.#docId = moduleCommentsDocId(this.#space); + + // Create overlay layer + this.#overlay = document.createElement('div'); + this.#overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9998;'; + document.body.appendChild(this.#overlay); + + // Create popover container + this.#popover = document.createElement('div'); + this.#popover.style.cssText = ` + display:none;position:fixed;z-index:10001;width:340px;max-height:400px; + background:#1e1e2e;border:1px solid #444;border-radius:10px; + box-shadow:0 8px 30px rgba(0,0,0,0.4);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; + color:#e0e0e0;font-size:13px;overflow:hidden;pointer-events:auto; + `; + document.body.appendChild(this.#popover); + + // Try connecting to runtime + this.#tryConnect(); + + // Listen for events + window.addEventListener('comment-pin-activate', this.#onActivate); + window.addEventListener('module-comment-pin-focus', this.#onPinFocus as EventListener); + document.addEventListener('pointerdown', this.#onDocumentClick); + } + + disconnectedCallback() { + if (this.#unsubChange) this.#unsubChange(); + if (this.#runtimePollInterval) clearInterval(this.#runtimePollInterval); + if (this.#resizeObs) this.#resizeObs.disconnect(); + if (this.#mutationObs) this.#mutationObs.disconnect(); + if (this.#intersectionObs) this.#intersectionObs.disconnect(); + if (this.#scrollHandler) window.removeEventListener('scroll', this.#scrollHandler, true); + if (this.#repositionTimer) clearTimeout(this.#repositionTimer); + if (this.#overlay) this.#overlay.remove(); + if (this.#popover) this.#popover.remove(); + window.removeEventListener('comment-pin-activate', this.#onActivate); + window.removeEventListener('module-comment-pin-focus', this.#onPinFocus as EventListener); + document.removeEventListener('pointerdown', this.#onDocumentClick); + this.#exitPlacementMode(); + } + + // ── Runtime Connection ── + + #tryConnect() { + const runtime = (window as any).__rspaceOfflineRuntime as OfflineRuntime | undefined; + if (runtime?.isInitialized) { + this.#onRuntimeReady(runtime); + } else { + let polls = 0; + this.#runtimePollInterval = setInterval(() => { + const rt = (window as any).__rspaceOfflineRuntime as OfflineRuntime | undefined; + if (rt?.isInitialized) { + clearInterval(this.#runtimePollInterval!); + this.#runtimePollInterval = null; + this.#onRuntimeReady(rt); + } else if (++polls > 20) { + clearInterval(this.#runtimePollInterval!); + this.#runtimePollInterval = null; + } + }, 500); + } + } + + async #onRuntimeReady(runtime: OfflineRuntime) { + this.#runtime = runtime; + if (!this.#docId) return; + + await runtime.subscribe(this.#docId, moduleCommentsSchema); + + this.#unsubChange = runtime.onChange(this.#docId, () => { + this.#renderPins(); + window.dispatchEvent(new CustomEvent('module-comment-pins-changed')); + }); + + // Set up observers for repositioning + this.#setupObservers(); + this.#renderPins(); + } + + // ── Observers ── + + #setupObservers() { + // Debounced reposition + const debouncedReposition = () => { + if (this.#repositionTimer) clearTimeout(this.#repositionTimer); + this.#repositionTimer = setTimeout(() => this.#renderPins(), 100); + }; + + // Scroll (capture phase for inner scrollable elements) + this.#scrollHandler = debouncedReposition; + window.addEventListener('scroll', this.#scrollHandler, true); + + // Resize + this.#resizeObs = new ResizeObserver(debouncedReposition); + this.#resizeObs.observe(document.body); + + // DOM mutations (elements added/removed) + this.#mutationObs = new MutationObserver(debouncedReposition); + this.#mutationObs.observe(document.body, { childList: true, subtree: true }); + } + + // ── Pin Rendering ── + + #getDoc(): ModuleCommentsDoc | undefined { + if (!this.#runtime || !this.#docId) return undefined; + return this.#runtime.get(this.#docId); + } + + #getPinsForModule(): [string, ModuleCommentPin][] { + const doc = this.#getDoc(); + if (!doc?.pins) return []; + return Object.entries(doc.pins).filter( + ([, pin]) => pin.anchor.moduleId === this.#moduleId + ); + } + + #findCollabEl(id: string): Element | null { + const sel = `[data-collab-id="${CSS.escape(id)}"]`; + const found = document.querySelector(sel); + if (found) return found; + // Walk into shadow roots (one level deep — rApp components) + for (const el of document.querySelectorAll('*')) { + if (el.shadowRoot) { + const inner = el.shadowRoot.querySelector(sel); + if (inner) return inner; + } + } + return null; + } + + #renderPins() { + if (!this.#overlay) return; + const pins = this.#getPinsForModule(); + + // Clear existing markers + this.#overlay.innerHTML = ''; + + for (const [pinId, pin] of pins) { + const el = this.#findCollabEl(pin.anchor.elementId); + if (!el) continue; + + const rect = el.getBoundingClientRect(); + // Skip if off-screen + if (rect.bottom < 0 || rect.top > window.innerHeight || + rect.right < 0 || rect.left > window.innerWidth) continue; + + const marker = document.createElement('div'); + marker.dataset.pinId = pinId; + const isActive = this.#activePinId === pinId; + const isResolved = pin.resolved; + const msgCount = pin.messages?.length || 0; + + marker.style.cssText = ` + position:fixed; + left:${rect.right - 12}px; + top:${rect.top - 8}px; + width:24px;height:24px; + border-radius:50% 50% 50% 0; + transform:rotate(-45deg); + cursor:pointer; + pointer-events:auto; + display:flex;align-items:center;justify-content:center; + font-size:10px;font-weight:700;color:white; + transition:all 0.15s ease; + box-shadow:0 2px 8px rgba(0,0,0,0.3); + ${isResolved + ? 'background:#64748b;opacity:0.6;' + : isActive + ? 'background:#8b5cf6;transform:rotate(-45deg) scale(1.15);' + : 'background:#14b8a6;'} + `; + + // Badge with message count + if (msgCount > 0) { + const badge = document.createElement('span'); + badge.textContent = String(msgCount); + badge.style.cssText = 'transform:rotate(45deg);'; + marker.appendChild(badge); + } + + marker.addEventListener('click', (e) => { + e.stopPropagation(); + this.#openPopover(pinId, rect.right + 8, rect.top); + }); + + this.#overlay.appendChild(marker); + } + } + + // ── Placement Mode ── + + #onActivate = () => { + // Only handle if we're on a module page (not canvas) + if (this.#moduleId === 'rspace' || !this.#runtime) return; + if (this.#placementMode) { + this.#exitPlacementMode(); + } else { + this.#enterPlacementMode(); + } + }; + + #enterPlacementMode() { + this.#placementMode = true; + document.body.style.cursor = 'crosshair'; + + // Highlight valid targets + this.#highlightCollabElements(true); + } + + #exitPlacementMode() { + this.#placementMode = false; + document.body.style.cursor = ''; + this.#highlightCollabElements(false); + } + + #highlightCollabElements(show: boolean) { + const els = document.querySelectorAll('[data-collab-id]'); + els.forEach((el) => { + (el as HTMLElement).style.outline = show ? '2px dashed rgba(20,184,166,0.4)' : ''; + (el as HTMLElement).style.outlineOffset = show ? '2px' : ''; + }); + // Also check shadow roots + for (const el of document.querySelectorAll('*')) { + if (el.shadowRoot) { + el.shadowRoot.querySelectorAll('[data-collab-id]').forEach((inner) => { + (inner as HTMLElement).style.outline = show ? '2px dashed rgba(20,184,166,0.4)' : ''; + (inner as HTMLElement).style.outlineOffset = show ? '2px' : ''; + }); + } + } + } + + #onDocumentClick = (e: PointerEvent) => { + if (!this.#placementMode) { + // Close popover on outside click + if (this.#popover?.style.display !== 'none' && + !this.#popover?.contains(e.target as Node) && + !(e.target as HTMLElement)?.closest?.('[data-pin-id]')) { + this.#closePopover(); + } + return; + } + + const target = (e.target as HTMLElement).closest('[data-collab-id]'); + if (!target) return; // Ignore clicks not on collab elements + + e.preventDefault(); + e.stopPropagation(); + + const elementId = target.getAttribute('data-collab-id')!; + this.#exitPlacementMode(); + this.#createPin(elementId); + }; + + // ── CRDT Operations ── + + #getUserInfo(): { did: string; name: string } { + try { + const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}'); + return { + did: sess.did || 'anon', + name: sess.username || sess.displayName || 'Anonymous', + }; + } catch { + return { did: 'anon', name: 'Anonymous' }; + } + } + + #createPin(elementId: string) { + if (!this.#runtime || !this.#docId) return; + const user = this.#getUserInfo(); + const pinId = crypto.randomUUID(); + + this.#runtime.change(this.#docId, 'Add module comment pin', (doc) => { + doc.pins[pinId] = { + id: pinId, + anchor: { type: 'element', elementId, moduleId: this.#moduleId }, + resolved: false, + messages: [], + createdAt: Date.now(), + createdBy: user.did, + createdByName: user.name, + }; + }); + + // Open popover immediately for the new pin + requestAnimationFrame(() => { + const el = this.#findCollabEl(elementId); + if (el) { + const rect = el.getBoundingClientRect(); + this.#openPopover(pinId, rect.right + 8, rect.top, true); + } + }); + } + + #addMessage(pinId: string, text: string) { + if (!this.#runtime || !this.#docId) return; + const user = this.#getUserInfo(); + const msgId = crypto.randomUUID(); + + // Extract @mentions + const mentionedDids = this.#extractMentions(text); + + this.#runtime.change(this.#docId, 'Add comment', (doc) => { + const pin = doc.pins[pinId]; + if (!pin) return; + pin.messages.push({ + id: msgId, + authorId: user.did, + authorName: user.name, + text, + mentionedDids: mentionedDids.length > 0 ? mentionedDids : undefined, + createdAt: Date.now(), + }); + }); + + // Notify space members + this.#notifySpaceMembers(pinId, text, user, mentionedDids); + + // Notify @mentioned users + if (mentionedDids.length > 0) { + this.#notifyMentions(pinId, user, mentionedDids); + } + } + + #resolvePin(pinId: string) { + if (!this.#runtime || !this.#docId) return; + this.#runtime.change(this.#docId, 'Resolve pin', (doc) => { + const pin = doc.pins[pinId]; + if (pin) pin.resolved = true; + }); + this.#closePopover(); + } + + #unresolvePin(pinId: string) { + if (!this.#runtime || !this.#docId) return; + this.#runtime.change(this.#docId, 'Unresolve pin', (doc) => { + const pin = doc.pins[pinId]; + if (pin) pin.resolved = false; + }); + } + + #deletePin(pinId: string) { + if (!this.#runtime || !this.#docId) return; + this.#runtime.change(this.#docId, 'Delete pin', (doc) => { + delete doc.pins[pinId]; + }); + this.#closePopover(); + } + + // ── Notifications ── + + async #notifySpaceMembers(pinId: string, text: string, user: { did: string; name: string }, mentionedDids: string[]) { + const doc = this.#getDoc(); + if (!doc?.pins[pinId]) return; + const pin = doc.pins[pinId]; + const isReply = pin.messages.length > 1; + const pinIndex = Object.values(doc.pins) + .sort((a, b) => a.createdAt - b.createdAt) + .findIndex((p) => p.id === pinId) + 1; + + try { + const base = getModuleApiBase('rspace'); + await fetch(`${base}/api/comment-pins/notify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pinId, authorDid: user.did, authorName: user.name, + text, pinIndex, isReply, mentionedDids, + moduleId: this.#moduleId, + }), + }); + } catch { /* fire and forget */ } + } + + async #notifyMentions(pinId: string, user: { did: string; name: string }, mentionedDids: string[]) { + const doc = this.#getDoc(); + if (!doc?.pins[pinId]) return; + const pinIndex = Object.values(doc.pins) + .sort((a, b) => a.createdAt - b.createdAt) + .findIndex((p) => p.id === pinId) + 1; + + try { + const base = getModuleApiBase('rspace'); + await fetch(`${base}/api/comment-pins/notify-mention`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pinId, authorDid: user.did, authorName: user.name, + mentionedDids, pinIndex, moduleId: this.#moduleId, + }), + }); + } catch { /* fire and forget */ } + } + + #extractMentions(text: string): string[] { + if (!this.#members) return []; + const matches = text.match(/@(\w+)/g); + if (!matches) return []; + const dids: string[] = []; + for (const match of matches) { + const username = match.slice(1).toLowerCase(); + const member = this.#members.find( + (m) => m.username.toLowerCase() === username + ); + if (member) dids.push(member.userDID); + } + return dids; + } + + // ── Popover ── + + #openPopover(pinId: string, x: number, y: number, focusInput = false) { + if (!this.#popover) return; + this.#activePinId = pinId; + const doc = this.#getDoc(); + const pin = doc?.pins[pinId]; + if (!pin) return; + + const sortedPins = Object.values(doc!.pins).sort((a, b) => a.createdAt - b.createdAt); + const pinIndex = sortedPins.findIndex((p) => p.id === pinId) + 1; + + let html = ``; + + // Header + html += ` +
+ Pin #${pinIndex} +
+ ${pin.resolved + ? `` + : ``} + +
+
+ `; + + // Element anchor label + html += `
${this.#esc(pin.anchor.elementId)}
`; + + // Messages + if (pin.messages.length > 0) { + html += `
`; + for (const msg of pin.messages) { + const time = new Date(msg.createdAt).toLocaleString(undefined, { + month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', + }); + html += ` +
+
+ ${this.#esc(msg.authorName)} + ${time} +
+
${this.#formatText(msg.text)}
+
+ `; + } + html += `
`; + } + + // Input row + html += ` +
+ + +
+ `; + + this.#popover.innerHTML = html; + this.#popover.style.display = 'block'; + + // Position — keep within viewport + const popW = 340, popH = 400; + const clampedX = Math.min(x, window.innerWidth - popW - 8); + const clampedY = Math.min(Math.max(y, 8), window.innerHeight - popH - 8); + this.#popover.style.left = `${clampedX}px`; + this.#popover.style.top = `${clampedY}px`; + + // Wire actions + this.#popover.querySelectorAll('.mcp-btn').forEach((btn) => { + btn.addEventListener('click', (e) => { + const action = (e.currentTarget as HTMLElement).dataset.action; + if (action === 'resolve') this.#resolvePin(pinId); + else if (action === 'unresolve') this.#unresolvePin(pinId); + else if (action === 'delete') this.#deletePin(pinId); + }); + }); + + // Wire input + const input = this.#popover.querySelector('.mcp-input') as HTMLInputElement; + const sendBtn = this.#popover.querySelector('.mcp-send') as HTMLButtonElement; + + const submitComment = () => { + const text = input.value.trim(); + if (!text) return; + this.#addMessage(pinId, text); + input.value = ''; + // Re-render popover to show new message + requestAnimationFrame(() => { + const el = this.#findCollabEl(pin.anchor.elementId); + if (el) { + const rect = el.getBoundingClientRect(); + this.#openPopover(pinId, rect.right + 8, rect.top); + } + }); + }; + + sendBtn.addEventListener('click', submitComment); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + submitComment(); + } + if (e.key === 'Escape') this.#closePopover(); + }); + + // @mention autocomplete + input.addEventListener('input', () => this.#handleMentionInput(input)); + + // Prevent popover clicks from closing it + this.#popover.addEventListener('pointerdown', (e) => e.stopPropagation()); + + if (focusInput) { + requestAnimationFrame(() => input.focus()); + } + + this.#renderPins(); // Update active state on markers + } + + #closePopover() { + if (this.#popover) this.#popover.style.display = 'none'; + this.#activePinId = null; + this.#closeMentionDropdown(); + this.#renderPins(); + } + + // ── Pin Focus (from bell) ── + + #onPinFocus = (e: CustomEvent) => { + const { pinId, elementId } = e.detail || {}; + if (!pinId) return; + + const doc = this.#getDoc(); + const pin = doc?.pins[pinId]; + if (!pin || pin.anchor.moduleId !== this.#moduleId) return; + + const el = this.#findCollabEl(pin.anchor.elementId || elementId); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + requestAnimationFrame(() => { + const rect = el.getBoundingClientRect(); + this.#openPopover(pinId, rect.right + 8, rect.top); + }); + } + }; + + // ── @Mention Autocomplete ── + + async #fetchMembers() { + if (this.#members) return this.#members; + try { + const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}'); + const res = await fetch(`${getModuleApiBase('rspace')}/api/space-members`, { + headers: sess?.accessToken ? { Authorization: `Bearer ${sess.accessToken}` } : {}, + }); + if (!res.ok) return []; + const data = await res.json(); + this.#members = data.members || []; + return this.#members!; + } catch { + return []; + } + } + + async #handleMentionInput(input: HTMLInputElement) { + const val = input.value; + const cursorPos = input.selectionStart || 0; + const before = val.slice(0, cursorPos); + const atMatch = before.match(/@(\w*)$/); + + if (!atMatch) { + this.#closeMentionDropdown(); + return; + } + + const query = atMatch[1].toLowerCase(); + const members = await this.#fetchMembers(); + const filtered = members.filter( + (m) => + m.username.toLowerCase().includes(query) || + (m.displayName && m.displayName.toLowerCase().includes(query)), + ); + + if (filtered.length === 0) { + this.#closeMentionDropdown(); + return; + } + + this.#showMentionDropdown(filtered, input, atMatch.index!); + } + + #showMentionDropdown(members: SpaceMember[], input: HTMLInputElement, atIndex: number) { + this.#closeMentionDropdown(); + + const dropdown = document.createElement('div'); + dropdown.style.cssText = ` + position:absolute;bottom:100%;left:12px;right:12px; + background:#2a2a3a;border:1px solid #555;border-radius:6px; + max-height:150px;overflow-y:auto;z-index:10002; + `; + + for (const m of members.slice(0, 8)) { + const item = document.createElement('div'); + item.style.cssText = 'padding:6px 10px;cursor:pointer;font-size:12px;display:flex;justify-content:space-between;'; + item.innerHTML = ` + ${this.#esc(m.displayName || m.username)} + @${this.#esc(m.username)} + `; + item.addEventListener('mousedown', (e) => { + e.preventDefault(); + const val = input.value; + const before = val.slice(0, atIndex); + const after = val.slice(input.selectionStart || atIndex); + input.value = `${before}@${m.username} ${after}`; + input.focus(); + const newPos = atIndex + m.username.length + 2; + input.setSelectionRange(newPos, newPos); + this.#closeMentionDropdown(); + }); + item.addEventListener('mouseenter', () => { item.style.background = '#3a3a4a'; }); + item.addEventListener('mouseleave', () => { item.style.background = ''; }); + dropdown.appendChild(item); + } + + const inputRow = this.#popover?.querySelector('.mcp-input-row'); + if (inputRow) { + (inputRow as HTMLElement).style.position = 'relative'; + inputRow.appendChild(dropdown); + } + this.#mentionDropdown = dropdown; + } + + #closeMentionDropdown() { + if (this.#mentionDropdown) { + this.#mentionDropdown.remove(); + this.#mentionDropdown = null; + } + } + + // ── Helpers ── + + #esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + #formatText(text: string): string { + // Escape first, then render @mentions with highlight + let escaped = this.#esc(text); + escaped = escaped.replace(/@(\w+)/g, '@$1'); + return escaped; + } + + static define(tag = 'rstack-module-comments') { + if (!customElements.get(tag)) customElements.define(tag, RStackModuleComments); + } +} + +// ── Popover Styles ── + +const POPOVER_STYLES = ` +.mcp-header { + padding: 10px 12px; + border-bottom: 1px solid #333; + display: flex; + align-items: center; + justify-content: space-between; +} +.mcp-pin-num { + font-weight: 700; + color: #14b8a6; +} +.mcp-actions { + display: flex; + gap: 4px; +} +.mcp-btn { + background: none; + border: 1px solid #444; + border-radius: 4px; + padding: 2px 6px; + cursor: pointer; + color: #ccc; + font-size: 13px; +} +.mcp-btn:hover { background: #333; } +.mcp-btn-danger { color: #ef4444; } +.mcp-btn-danger:hover { background: #3a1a1a; } +.mcp-anchor-label { + padding: 4px 12px; + font-size: 11px; + color: #64748b; + border-bottom: 1px solid #333; + background: rgba(255,255,255,0.02); +} +.mcp-messages { + max-height: 200px; + overflow-y: auto; + padding: 8px 12px; +} +.mcp-msg { + margin-bottom: 10px; +} +.mcp-msg-top { + display: flex; + justify-content: space-between; + align-items: baseline; +} +.mcp-msg-author { + font-weight: 600; + color: #c4b5fd; + font-size: 12px; +} +.mcp-msg-time { + color: #666; + font-size: 10px; +} +.mcp-msg-text { + margin-top: 3px; + line-height: 1.4; + word-break: break-word; +} +.mcp-input-row { + padding: 8px 12px; + border-top: 1px solid #333; + display: flex; + gap: 6px; +} +.mcp-input { + flex: 1; + background: #2a2a3a; + border: 1px solid #444; + border-radius: 6px; + padding: 6px 10px; + color: #e0e0e0; + font-size: 13px; + outline: none; +} +.mcp-input:focus { + border-color: #14b8a6; +} +.mcp-send { + background: #14b8a6; + color: white; + border: none; + border-radius: 6px; + padding: 6px 12px; + cursor: pointer; + font-size: 13px; + font-weight: 600; +} +.mcp-send:hover { + background: #0d9488; +} +`; diff --git a/shared/module-comment-schemas.ts b/shared/module-comment-schemas.ts new file mode 100644 index 00000000..585799bf --- /dev/null +++ b/shared/module-comment-schemas.ts @@ -0,0 +1,18 @@ +/** + * Automerge schema + doc ID helper for module-level comment pins. + * One doc per space: "{space}:module-comments:pins" + */ + +import type { DocSchema, DocumentId } from './local-first/document'; +import type { ModuleCommentsDoc } from './module-comment-types'; + +export const moduleCommentsSchema: DocSchema = { + module: 'module-comments', + collection: 'pins', + version: 1, + init: () => ({ pins: {} }), +}; + +export function moduleCommentsDocId(space: string): DocumentId { + return `${space}:module-comments:pins` as DocumentId; +} diff --git a/shared/module-comment-types.ts b/shared/module-comment-types.ts new file mode 100644 index 00000000..69de3161 --- /dev/null +++ b/shared/module-comment-types.ts @@ -0,0 +1,28 @@ +/** + * Module Comment Pin Types — Figma-style threaded comment markers + * anchored to `data-collab-id` elements on rApp module pages. + * + * Reuses CommentPinMessage from comment-pin-types.ts for thread messages. + */ + +import type { CommentPinMessage } from './comment-pin-types'; + +export interface ModuleCommentAnchor { + type: 'element'; + elementId: string; // data-collab-id value (e.g. "task:abc123") + moduleId: string; // which rApp (e.g. "rtasks") +} + +export interface ModuleCommentPin { + id: string; + anchor: ModuleCommentAnchor; + resolved: boolean; + messages: CommentPinMessage[]; + createdAt: number; + createdBy: string; // DID + createdByName: string; +} + +export interface ModuleCommentsDoc { + pins: { [pinId: string]: ModuleCommentPin }; +} diff --git a/website/shell.ts b/website/shell.ts index 653d6e89..e89ae4f3 100644 --- a/website/shell.ts +++ b/website/shell.ts @@ -19,6 +19,7 @@ import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indi import { RStackSharePanel } from "../shared/components/rstack-share-panel"; import { RStackCommentBell } from "../shared/components/rstack-comment-bell"; import { RStackCollabOverlay } from "../shared/components/rstack-collab-overlay"; +import { RStackModuleComments } from "../shared/components/rstack-module-comments"; import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard"; import { rspaceNavUrl } from "../shared/url-helpers"; import { TabCache } from "../shared/tab-cache"; @@ -42,6 +43,7 @@ RStackOfflineIndicator.define(); RStackSharePanel.define(); RStackCommentBell.define(); RStackCollabOverlay.define(); +RStackModuleComments.define(); RStackUserDashboard.define(); // ── Offline Runtime (lazy-loaded) ──