feat(comments): spatial comment pins on all rApp module pages
Adds Figma-style threaded comment markers anchored to data-collab-id elements across all module pages. Comments stored in per-space Automerge doc, synced via existing local-first stack. Bell is now context-aware (canvas pins on canvas, module pins on module pages). Notifications route through existing WS/push/email system with new module_comment and module_mention event types. New files: module-comment-types, module-comment-schemas, rstack-module-comments component. Updated: shell, comment bell, notification routes. Added data-collab-id to crowdsurf, rtime, rmeets. Fixed pre-existing SKILL_LABELS import error in rtime/mod.ts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f0039bcb7c
commit
1de038eeab
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() : ""}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)}`)) : ''}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
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 {
|
|||
? `<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) {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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">↩</button>`
|
||||
: `<button class="mcp-btn" data-action="resolve" title="Resolve">✓</button>`}
|
||||
<button class="mcp-btn mcp-btn-danger" data-action="delete" title="Delete">✕</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, '&').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, '<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;
|
||||
}
|
||||
`;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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) ──
|
||||
|
|
|
|||
Loading…
Reference in New Issue