Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m51s Details

This commit is contained in:
Jeff Emmett 2026-04-10 11:15:15 -04:00
commit 5e204df357
12 changed files with 1104 additions and 49 deletions

View File

@ -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 `
<div class="cs-card-stack">
<div class="cs-card" id="cs-current-card">
<div class="cs-card" id="cs-current-card" data-collab-id="prompt:${prompt.id}">
<div class="cs-swipe-indicator cs-swipe-left"> Pass</div>
<div class="cs-swipe-indicator cs-swipe-right"> Join</div>
@ -604,7 +615,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
<div class="cs-section-label">Elo Leaderboard</div>
<div class="cs-leaderboard">
${leaderboard.map((p, i) => `
<div class="cs-lb-row${this.rankLastResult?.winnerId === p.id ? ' cs-lb-winner' : ''}">
<div class="cs-lb-row${this.rankLastResult?.winnerId === p.id ? ' cs-lb-winner' : ''}" data-collab-id="response:${p.id}">
<span class="cs-lb-rank">#${i + 1}</span>
<span class="cs-lb-text">${this.esc(p.text)}</span>
<span class="cs-lb-elo"> ${p.elo ?? 1500}</span>

View File

@ -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; }
</style>
<div class="jitsi-container" id="jitsi-meet">
<div class="jitsi-container" id="jitsi-meet" data-collab-id="session:${this.sessionId || 'lobby'}">
<div class="loading">Loading Jitsi Meet...</div>
</div>
${this.isDirector && this.sessionId ? this.renderDirectorStrip() : ""}

View File

@ -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<string, string> = {
@ -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') {

View File

@ -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,

View File

@ -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<string>([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,
`<p>${preview}</p><p><a href="https://${space}.rspace.online/rspace#pin-${pinId}">View comment</a></p>`,
`<p>${preview}</p><p><a href="https://${space}.rspace.online/${effectiveModule}#pin-${pinId}">View comment</a></p>`,
);
})
.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<Response | null> {
return null;
}
// ── Module ID aliases (plural/misspelling → canonical) ──
const MODULE_ALIASES: Record<string, string> = { rsheet: "rsheets" };
function resolveModuleAlias(id: string): string { return MODULE_ALIASES[id] ?? id; }
// ── Standalone domain → module lookup ──
const domainToModule = new Map<string, string>();
for (const mod of getAllModules()) {
@ -3953,7 +3961,7 @@ const server = Bun.serve<WSData>({
}
// 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<WSData>({
}
}
// 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<WSData>({
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);

View File

@ -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'

View File

@ -342,6 +342,7 @@ export function renderShell(opts: ShellOptions): string {
<div class="rapp-info-panel__body" id="rapp-info-body"></div>
</div>
<rstack-user-dashboard space="${escapeAttr(spaceSlug)}" style="display:none"></rstack-user-dashboard>
${moduleId !== "rspace" ? `<rstack-module-comments module-id="${escapeAttr(moduleId)}" space="${escapeAttr(spaceSlug)}"></rstack-module-comments>` : ''}
<main id="app"${moduleId === "rspace" ? ' class="canvas-layout"' : ''}>
${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)}`)) : ''}

View File

@ -2,15 +2,18 @@
* <rstack-comment-bell> 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<typeof setInterval> | null = null;
#syncRef: any = null;
#pinSource: PinSource = 'canvas';
// Module comments state
#moduleDocId: DocumentId | null = null;
#runtime: any = null;
#unsubModuleChange: (() => void) | null = null;
#runtimePollInterval: ReturnType<typeof setInterval> | null = null;
constructor() {
super();
@ -43,19 +55,71 @@ export class RStackCommentBell extends HTMLElement {
connectedCallback() {
this.#render();
// 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();
};
// ── 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,
}]);
}
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;
const pins = sync?.doc?.commentPins;
if (!pins) {
if (this.#count !== 0) {
this.#count = 0;
this.#updateBadge();
pins = this.#getCanvasPins();
}
return;
}
const newCount = Object.values(pins).filter(
(p: any) => !p.resolved
).length;
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 {
? `<span class="resolved-badge">Resolved</span>`
: `<span class="open-badge">Open</span>`;
// Show element anchor for module comments
const anchorLabel = pin.anchor?.elementId
? `<span class="anchor-label">${this.#esc(pin.anchor.elementId)}</span>`
: "";
return `
<div class="comment-item ${pin.resolved ? "resolved" : ""}" data-pin-id="${this.#esc(pinId)}">
<div class="comment-item ${pin.resolved ? "resolved" : ""}" data-pin-id="${this.#esc(pinId)}" data-source="${this.#pinSource}" data-element-id="${this.#esc(pin.anchor?.elementId || "")}">
<div class="comment-avatar">${initial}</div>
<div class="comment-content">
<div class="comment-top">
<span class="comment-author">${this.#esc(authorName)}</span>
${resolvedBadge}
</div>
${anchorLabel}
<div class="comment-text">${this.#esc(this.#truncate(text))}</div>
<div class="comment-meta">
${threadCount > 1 ? `<span class="thread-count">${threadCount} messages</span>` : ""}
@ -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) {
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;

View File

@ -0,0 +1,829 @@
/**
* <rstack-module-comments> 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<T extends Record<string, any>>(docId: DocumentId, schema: any): Promise<any>;
unsubscribe(docId: DocumentId): void;
change<T>(docId: DocumentId, message: string, fn: (doc: T) => void): void;
get<T>(docId: DocumentId): any;
onChange<T>(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<typeof setTimeout> | null = null;
#runtimePollInterval: ReturnType<typeof setInterval> | 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<ModuleCommentsDoc>(this.#docId, moduleCommentsSchema);
this.#unsubChange = runtime.onChange<ModuleCommentsDoc>(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<ModuleCommentsDoc>(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<ModuleCommentsDoc>(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<ModuleCommentsDoc>(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<ModuleCommentsDoc>(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<ModuleCommentsDoc>(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<ModuleCommentsDoc>(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 = `<style>${POPOVER_STYLES}</style>`;
// Header
html += `
<div class="mcp-header">
<span class="mcp-pin-num">Pin #${pinIndex}</span>
<div class="mcp-actions">
${pin.resolved
? `<button class="mcp-btn" data-action="unresolve" title="Reopen">&#x21A9;</button>`
: `<button class="mcp-btn" data-action="resolve" title="Resolve">&#x2713;</button>`}
<button class="mcp-btn mcp-btn-danger" data-action="delete" title="Delete">&#x2715;</button>
</div>
</div>
`;
// Element anchor label
html += `<div class="mcp-anchor-label">${this.#esc(pin.anchor.elementId)}</div>`;
// Messages
if (pin.messages.length > 0) {
html += `<div class="mcp-messages">`;
for (const msg of pin.messages) {
const time = new Date(msg.createdAt).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
});
html += `
<div class="mcp-msg">
<div class="mcp-msg-top">
<span class="mcp-msg-author">${this.#esc(msg.authorName)}</span>
<span class="mcp-msg-time">${time}</span>
</div>
<div class="mcp-msg-text">${this.#formatText(msg.text)}</div>
</div>
`;
}
html += `</div>`;
}
// Input row
html += `
<div class="mcp-input-row">
<input type="text" class="mcp-input" placeholder="Add a comment... (@ to mention)" />
<button class="mcp-send">Send</button>
</div>
`;
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 = `
<span style="font-weight:600;">${this.#esc(m.displayName || m.username)}</span>
<span style="color:#888;">@${this.#esc(m.username)}</span>
`;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
#formatText(text: string): string {
// Escape first, then render @mentions with highlight
let escaped = this.#esc(text);
escaped = escaped.replace(/@(\w+)/g, '<span style="color:#14b8a6;font-weight:600;">@$1</span>');
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;
}
`;

View File

@ -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<ModuleCommentsDoc> = {
module: 'module-comments',
collection: 'pins',
version: 1,
init: () => ({ pins: {} }),
};
export function moduleCommentsDocId(space: string): DocumentId {
return `${space}:module-comments:pins` as DocumentId;
}

View File

@ -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 };
}

View File

@ -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) ──