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 { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||||
import { CrowdSurfLocalFirstClient } from '../local-first-client';
|
import { CrowdSurfLocalFirstClient } from '../local-first-client';
|
||||||
import type { CrowdSurfDoc, CrowdSurfPrompt, Contribution } from '../schemas';
|
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 { getModuleApiBase } from "../../../shared/url-helpers";
|
||||||
|
import type { DocumentId } from "../../../shared/local-first/document";
|
||||||
|
|
||||||
// ── Auth helpers ──
|
// ── Auth helpers ──
|
||||||
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
|
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')) {
|
if (!localStorage.getItem('crowdsurf_tour_done')) {
|
||||||
setTimeout(() => this._tour.start(), 800);
|
setTimeout(() => this._tour.start(), 800);
|
||||||
}
|
}
|
||||||
|
this.subscribeCollabOverlay();
|
||||||
|
|
||||||
// Check expiry every 30s
|
// Check expiry every 30s
|
||||||
this._expiryTimer = window.setInterval(() => this.checkExpiry(), 30000);
|
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) {
|
private extractPrompts(doc: CrowdSurfDoc) {
|
||||||
const myDid = getMyDid();
|
const myDid = getMyDid();
|
||||||
const all = doc.prompts ? Object.values(doc.prompts) : [];
|
const all = doc.prompts ? Object.values(doc.prompts) : [];
|
||||||
|
|
@ -425,7 +436,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="cs-card-stack">
|
<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-left">✗ Pass</div>
|
||||||
<div class="cs-swipe-indicator cs-swipe-right">✓ Join</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-section-label">Elo Leaderboard</div>
|
||||||
<div class="cs-leaderboard">
|
<div class="cs-leaderboard">
|
||||||
${leaderboard.map((p, i) => `
|
${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-rank">#${i + 1}</span>
|
||||||
<span class="cs-lb-text">${this.esc(p.text)}</span>
|
<span class="cs-lb-text">${this.esc(p.text)}</span>
|
||||||
<span class="cs-lb-elo">⚡ ${p.elo ?? 1500}</span>
|
<span class="cs-lb-elo">⚡ ${p.elo ?? 1500}</span>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||||
|
import type { DocumentId } from "../../../shared/local-first/document";
|
||||||
|
import { meetsSchema, meetsDocId } from "../schemas";
|
||||||
|
|
||||||
class FolkJitsiRoom extends HTMLElement {
|
class FolkJitsiRoom extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
|
|
@ -25,6 +27,7 @@ class FolkJitsiRoom extends HTMLElement {
|
||||||
private directorAnimFrame: number | null = null;
|
private directorAnimFrame: number | null = null;
|
||||||
private directorError = "";
|
private directorError = "";
|
||||||
private _stopPresence: (() => void) | null = null;
|
private _stopPresence: (() => void) | null = null;
|
||||||
|
private _offlineUnsub: (() => void) | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -41,13 +44,26 @@ class FolkJitsiRoom extends HTMLElement {
|
||||||
this.render();
|
this.render();
|
||||||
this.loadJitsiApi();
|
this.loadJitsiApi();
|
||||||
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rmeets', context: this.room || 'Meeting' }));
|
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rmeets', context: this.room || 'Meeting' }));
|
||||||
|
if (this.space && this.space !== "demo") {
|
||||||
|
this.subscribeOffline();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
this._stopPresence?.();
|
this._stopPresence?.();
|
||||||
|
this._offlineUnsub?.(); this._offlineUnsub = null;
|
||||||
this.dispose();
|
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; }
|
getApi() { return this.api; }
|
||||||
|
|
||||||
executeCommand(cmd: string, ...args: any[]) {
|
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-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; }
|
.director-error { font-size: 0.8rem; color: #ef4444; padding: 8px; font-family: system-ui, sans-serif; }
|
||||||
</style>
|
</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 class="loading">Loading Jitsi Meet...</div>
|
||||||
</div>
|
</div>
|
||||||
${this.isDirector && this.sessionId ? this.renderDirectorStrip() : ""}
|
${this.isDirector && this.sessionId ? this.renderDirectorStrip() : ""}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
* Two views: "pool" (canvas orbs) and "weave" (SVG node editor).
|
* 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 ──
|
// ── Constants ──
|
||||||
|
|
||||||
const SKILL_COLORS: Record<string, string> = {
|
const SKILL_COLORS: Record<string, string> = {
|
||||||
|
|
@ -320,6 +324,8 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
private _currentExecTaskId: string | null = null;
|
private _currentExecTaskId: string | null = null;
|
||||||
private _cyclosMembers: { id: string; name: string; balance: number }[] = [];
|
private _cyclosMembers: { id: string; name: string; balance: number }[] = [];
|
||||||
private _theme: 'dark' | 'light' = 'dark';
|
private _theme: 'dark' | 'light' = 'dark';
|
||||||
|
private _stopPresence: (() => void) | null = null;
|
||||||
|
private _offlineUnsub: (() => void) | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -349,6 +355,17 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
this.setupCanvas();
|
this.setupCanvas();
|
||||||
this.setupCollaborate();
|
this.setupCollaborate();
|
||||||
this.fetchData();
|
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. */
|
/** Derive API base from the current pathname — works for both subdomain and path routing. */
|
||||||
|
|
@ -376,6 +393,7 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
if (this.animFrame) cancelAnimationFrame(this.animFrame);
|
if (this.animFrame) cancelAnimationFrame(this.animFrame);
|
||||||
|
this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchData() {
|
private async fetchData() {
|
||||||
|
|
@ -1318,6 +1336,7 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
private renderNode(node: WeaveNode): SVGGElement {
|
private renderNode(node: WeaveNode): SVGGElement {
|
||||||
const g = ns('g') as SVGGElement;
|
const g = ns('g') as SVGGElement;
|
||||||
g.setAttribute('data-id', node.id);
|
g.setAttribute('data-id', node.id);
|
||||||
|
g.setAttribute('data-collab-id', `${node.type}:${node.id}`);
|
||||||
g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')');
|
g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')');
|
||||||
|
|
||||||
if (node.type === 'commitment') {
|
if (node.type === 'commitment') {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import type { SyncServer } from '../../server/local-first/sync-server';
|
||||||
import {
|
import {
|
||||||
commitmentsSchema, tasksSchema, externalTimeLogsSchema,
|
commitmentsSchema, tasksSchema, externalTimeLogsSchema,
|
||||||
commitmentsDocId, tasksDocId, externalTimeLogsDocId,
|
commitmentsDocId, tasksDocId, externalTimeLogsDocId,
|
||||||
|
SKILL_LABELS,
|
||||||
} from './schemas';
|
} from './schemas';
|
||||||
import type {
|
import type {
|
||||||
CommitmentsDoc, TasksDoc, ExternalTimeLogsDoc,
|
CommitmentsDoc, TasksDoc, ExternalTimeLogsDoc,
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ import { vnbModule } from "../modules/rvnb/mod";
|
||||||
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
||||||
import { timeModule } from "../modules/rtime/mod";
|
import { timeModule } from "../modules/rtime/mod";
|
||||||
import { govModule } from "../modules/rgov/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 { exchangeModule } from "../modules/rexchange/mod";
|
||||||
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
||||||
import type { SpaceRoleString } from "./spaces";
|
import type { SpaceRoleString } from "./spaces";
|
||||||
|
|
@ -154,7 +154,7 @@ registerModule(forumModule);
|
||||||
registerModule(tubeModule);
|
registerModule(tubeModule);
|
||||||
registerModule(tripsModule);
|
registerModule(tripsModule);
|
||||||
registerModule(booksModule);
|
registerModule(booksModule);
|
||||||
registerModule(sheetModule);
|
registerModule(sheetsModule);
|
||||||
// registerModule(docsModule); // placeholder — not yet an rApp
|
// registerModule(docsModule); // placeholder — not yet an rApp
|
||||||
|
|
||||||
// ── Config ──
|
// ── Config ──
|
||||||
|
|
@ -943,11 +943,13 @@ app.post("/:space/api/comment-pins/notify", async (c) => {
|
||||||
const space = c.req.param("space");
|
const space = c.req.param("space");
|
||||||
try {
|
try {
|
||||||
const body = await c.req.json();
|
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) {
|
if (!pinId || !authorDid) {
|
||||||
return c.json({ error: "Missing fields" }, 400);
|
return c.json({ error: "Missing fields" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveModule = moduleId || "rspace";
|
||||||
|
const isModuleComment = effectiveModule !== "rspace";
|
||||||
const members = await listSpaceMembers(space);
|
const members = await listSpaceMembers(space);
|
||||||
const excludeDids = new Set<string>([authorDid, ...(mentionedDids || [])]);
|
const excludeDids = new Set<string>([authorDid, ...(mentionedDids || [])]);
|
||||||
const title = isReply
|
const title = isReply
|
||||||
|
|
@ -963,12 +965,12 @@ app.post("/:space/api/comment-pins/notify", async (c) => {
|
||||||
notify({
|
notify({
|
||||||
userDid: m.userDID,
|
userDid: m.userDID,
|
||||||
category: "module",
|
category: "module",
|
||||||
eventType: "canvas_comment",
|
eventType: isModuleComment ? "module_comment" : "canvas_comment",
|
||||||
title,
|
title,
|
||||||
body: preview || `Comment pin #${pinIndex || "?"} in ${space}`,
|
body: preview || `Comment pin #${pinIndex || "?"} in ${space}`,
|
||||||
spaceSlug: space,
|
spaceSlug: space,
|
||||||
moduleId: "rspace",
|
moduleId: effectiveModule,
|
||||||
actionUrl: `/rspace#pin-${pinId}`,
|
actionUrl: `/${effectiveModule}#pin-${pinId}`,
|
||||||
actorDid: authorDid,
|
actorDid: authorDid,
|
||||||
actorUsername: authorName,
|
actorUsername: authorName,
|
||||||
}),
|
}),
|
||||||
|
|
@ -981,7 +983,7 @@ app.post("/:space/api/comment-pins/notify", async (c) => {
|
||||||
sendSpaceNotification(
|
sendSpaceNotification(
|
||||||
space,
|
space,
|
||||||
title,
|
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(() => {});
|
.catch(() => {});
|
||||||
|
|
@ -998,20 +1000,22 @@ app.post("/:space/api/comment-pins/notify-mention", async (c) => {
|
||||||
const space = c.req.param("space");
|
const space = c.req.param("space");
|
||||||
try {
|
try {
|
||||||
const body = await c.req.json();
|
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) {
|
if (!pinId || !authorDid || !mentionedDids?.length) {
|
||||||
return c.json({ error: "Missing fields" }, 400);
|
return c.json({ error: "Missing fields" }, 400);
|
||||||
}
|
}
|
||||||
|
const effectiveModule = moduleId || "rspace";
|
||||||
|
const isModuleComment = effectiveModule !== "rspace";
|
||||||
for (const did of mentionedDids) {
|
for (const did of mentionedDids) {
|
||||||
await notify({
|
await notify({
|
||||||
userDid: did,
|
userDid: did,
|
||||||
category: "module",
|
category: "module",
|
||||||
eventType: "canvas_mention",
|
eventType: isModuleComment ? "module_mention" : "canvas_mention",
|
||||||
title: `${authorName} mentioned you in a comment`,
|
title: `${authorName} mentioned you in a comment`,
|
||||||
body: `Comment pin #${pinIndex || "?"} in ${space}`,
|
body: `Comment pin #${pinIndex || "?"} in ${space}`,
|
||||||
spaceSlug: space,
|
spaceSlug: space,
|
||||||
moduleId: "rspace",
|
moduleId: effectiveModule,
|
||||||
actionUrl: `/rspace#pin-${pinId}`,
|
actionUrl: `/${effectiveModule}#pin-${pinId}`,
|
||||||
actorDid: authorDid,
|
actorDid: authorDid,
|
||||||
actorUsername: authorName,
|
actorUsername: authorName,
|
||||||
});
|
});
|
||||||
|
|
@ -3643,6 +3647,10 @@ async function serveStatic(path: string, url?: URL): Promise<Response | null> {
|
||||||
return 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 ──
|
// ── Standalone domain → module lookup ──
|
||||||
const domainToModule = new Map<string, string>();
|
const domainToModule = new Map<string, string>();
|
||||||
for (const mod of getAllModules()) {
|
for (const mod of getAllModules()) {
|
||||||
|
|
@ -3953,7 +3961,7 @@ const server = Bun.serve<WSData>({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block disabled modules before rewriting — redirect to space root
|
// Block disabled modules before rewriting — redirect to space root
|
||||||
const firstModId = pathSegments[0].toLowerCase();
|
const firstModId = resolveModuleAlias(pathSegments[0].toLowerCase());
|
||||||
if (firstModId !== "rspace") {
|
if (firstModId !== "rspace") {
|
||||||
await loadCommunity(subdomain);
|
await loadCommunity(subdomain);
|
||||||
const spaceDoc = getDocumentData(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) =>
|
const normalizedPath = "/" + pathSegments.map((seg, i) =>
|
||||||
i === 0 ? seg.toLowerCase() : seg
|
i === 0 ? resolveModuleAlias(seg.toLowerCase()) : seg
|
||||||
).join("/");
|
).join("/");
|
||||||
|
|
||||||
// Rewrite: /{moduleId}/... → /{space}/{moduleId}/...
|
// Rewrite: /{moduleId}/... → /{space}/{moduleId}/...
|
||||||
|
|
@ -3985,7 +3993,7 @@ const server = Bun.serve<WSData>({
|
||||||
|
|
||||||
const pathSegments = url.pathname.split("/").filter(Boolean);
|
const pathSegments = url.pathname.split("/").filter(Boolean);
|
||||||
if (pathSegments.length >= 1) {
|
if (pathSegments.length >= 1) {
|
||||||
const firstSegment = pathSegments[0].toLowerCase();
|
const firstSegment = resolveModuleAlias(pathSegments[0].toLowerCase());
|
||||||
const allModules = getAllModules();
|
const allModules = getAllModules();
|
||||||
const knownModuleIds = new Set(allModules.map((m) => m.id));
|
const knownModuleIds = new Set(allModules.map((m) => m.id));
|
||||||
const mod = allModules.find((m) => m.id === firstSegment);
|
const mod = allModules.find((m) => m.id === firstSegment);
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ export type NotificationEventType =
|
||||||
// Module
|
// Module
|
||||||
| 'inbox_new_mail' | 'inbox_approval_needed' | 'choices_result'
|
| 'inbox_new_mail' | 'inbox_approval_needed' | 'choices_result'
|
||||||
| 'notes_shared' | 'canvas_mention' | 'canvas_comment'
|
| 'notes_shared' | 'canvas_mention' | 'canvas_comment'
|
||||||
|
| 'module_comment' | 'module_mention'
|
||||||
// System
|
// System
|
||||||
| 'guardian_invite' | 'guardian_accepted' | 'recovery_initiated'
|
| 'guardian_invite' | 'guardian_accepted' | 'recovery_initiated'
|
||||||
| 'recovery_approved' | 'device_linked' | 'security_alert'
|
| '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 class="rapp-info-panel__body" id="rapp-info-body"></div>
|
||||||
</div>
|
</div>
|
||||||
<rstack-user-dashboard space="${escapeAttr(spaceSlug)}" style="display:none"></rstack-user-dashboard>
|
<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"' : ''}>
|
<main id="app"${moduleId === "rspace" ? ' class="canvas-layout"' : ''}>
|
||||||
${renderModuleSubNav(moduleId, spaceSlug, visibleModules, opts.isSubdomain ?? IS_PRODUCTION)}
|
${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)}`)) : ''}
|
${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.
|
* <rstack-comment-bell> — Comment button with dropdown panel.
|
||||||
*
|
*
|
||||||
* Shows a chat-bubble icon in the header bar. Badge displays the count
|
* Shows a chat-bubble icon in the header bar. Badge displays the count
|
||||||
* of unresolved comment pins on the current canvas. Clicking toggles a
|
* of unresolved comment pins. Context-aware data source:
|
||||||
* dropdown panel showing all comment threads, sorted by most recent
|
* - Canvas page: reads from `window.__communitySync?.doc?.commentPins`
|
||||||
* message. Includes a "New Comment" button to enter pin-placement mode.
|
* - Module pages: reads from the module-comments Automerge doc via runtime
|
||||||
*
|
*
|
||||||
* Data source: `window.__communitySync?.doc?.commentPins`
|
* Listens for `comment-pins-changed` + `module-comment-pins-changed` on `window`.
|
||||||
* Listens for `comment-pins-changed` on `window` (re-dispatched by canvas).
|
* Polls every 30s as fallback (sync may appear after component mounts).
|
||||||
* Polls every 5s 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;
|
const POLL_INTERVAL = 30_000;
|
||||||
|
|
||||||
interface CommentMessage {
|
interface CommentMessage {
|
||||||
|
|
@ -22,19 +25,28 @@ interface CommentMessage {
|
||||||
|
|
||||||
interface CommentPinData {
|
interface CommentPinData {
|
||||||
messages: CommentMessage[];
|
messages: CommentMessage[];
|
||||||
anchor?: { x: number; y: number };
|
anchor?: { x: number; y: number; type?: string; elementId?: string; moduleId?: string };
|
||||||
resolved?: boolean;
|
resolved?: boolean;
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
createdByName?: string;
|
createdByName?: string;
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PinSource = 'canvas' | 'module';
|
||||||
|
|
||||||
export class RStackCommentBell extends HTMLElement {
|
export class RStackCommentBell extends HTMLElement {
|
||||||
#shadow: ShadowRoot;
|
#shadow: ShadowRoot;
|
||||||
#count = 0;
|
#count = 0;
|
||||||
#open = false;
|
#open = false;
|
||||||
#pollTimer: ReturnType<typeof setInterval> | null = null;
|
#pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
#syncRef: any = 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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -43,19 +55,71 @@ export class RStackCommentBell extends HTMLElement {
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.#render();
|
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.#refreshCount();
|
||||||
this.#pollTimer = setInterval(() => this.#refreshCount(), POLL_INTERVAL);
|
this.#pollTimer = setInterval(() => this.#refreshCount(), POLL_INTERVAL);
|
||||||
window.addEventListener("comment-pins-changed", this.#onPinsChanged);
|
window.addEventListener("comment-pins-changed", this.#onPinsChanged);
|
||||||
|
window.addEventListener("module-comment-pins-changed", this.#onPinsChanged);
|
||||||
document.addEventListener("community-sync-ready", this.#onSyncReady);
|
document.addEventListener("community-sync-ready", this.#onSyncReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
if (this.#pollTimer) clearInterval(this.#pollTimer);
|
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("comment-pins-changed", this.#onPinsChanged);
|
||||||
|
window.removeEventListener("module-comment-pins-changed", this.#onPinsChanged);
|
||||||
document.removeEventListener("community-sync-ready", this.#onSyncReady);
|
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) => {
|
#onSyncReady = (e: Event) => {
|
||||||
const sync = (e as CustomEvent).detail?.sync;
|
const sync = (e as CustomEvent).detail?.sync;
|
||||||
if (sync) {
|
if (sync) {
|
||||||
|
|
@ -69,20 +133,56 @@ export class RStackCommentBell extends HTMLElement {
|
||||||
if (this.#open) this.#render();
|
if (this.#open) this.#render();
|
||||||
};
|
};
|
||||||
|
|
||||||
#refreshCount() {
|
// ── Data Access ──
|
||||||
const sync = this.#syncRef || (window as any).__communitySync;
|
|
||||||
if (sync && !this.#syncRef) this.#syncRef = sync;
|
#getCurrentModuleId(): string {
|
||||||
const pins = sync?.doc?.commentPins;
|
return document.body?.getAttribute("data-module-id") || "rspace";
|
||||||
if (!pins) {
|
}
|
||||||
if (this.#count !== 0) {
|
|
||||||
this.#count = 0;
|
#getModulePins(): [string, CommentPinData][] {
|
||||||
this.#updateBadge();
|
if (!this.#runtime || !this.#moduleDocId) return [];
|
||||||
}
|
const doc = this.#runtime.get(this.#moduleDocId) as ModuleCommentsDoc | undefined;
|
||||||
return;
|
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(
|
return entries;
|
||||||
(p: any) => !p.resolved
|
}
|
||||||
).length;
|
|
||||||
|
#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) {
|
if (newCount !== this.#count) {
|
||||||
this.#count = newCount;
|
this.#count = newCount;
|
||||||
this.#updateBadge();
|
this.#updateBadge();
|
||||||
|
|
@ -95,11 +195,10 @@ export class RStackCommentBell extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
#getPins(): [string, CommentPinData][] {
|
#getPins(): [string, CommentPinData][] {
|
||||||
const sync = this.#syncRef || (window as any).__communitySync;
|
const entries = this.#pinSource === 'module'
|
||||||
const pins = sync?.doc?.commentPins;
|
? this.#getModulePins()
|
||||||
if (!pins) return [];
|
: this.#getCanvasPins();
|
||||||
|
|
||||||
const entries = Object.entries(pins) as [string, CommentPinData][];
|
|
||||||
// Sort by most recent message timestamp, descending
|
// Sort by most recent message timestamp, descending
|
||||||
entries.sort((a, b) => {
|
entries.sort((a, b) => {
|
||||||
const aTime = this.#latestMessageTime(a[1]);
|
const aTime = this.#latestMessageTime(a[1]);
|
||||||
|
|
@ -165,14 +264,20 @@ export class RStackCommentBell extends HTMLElement {
|
||||||
? `<span class="resolved-badge">Resolved</span>`
|
? `<span class="resolved-badge">Resolved</span>`
|
||||||
: `<span class="open-badge">Open</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 `
|
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-avatar">${initial}</div>
|
||||||
<div class="comment-content">
|
<div class="comment-content">
|
||||||
<div class="comment-top">
|
<div class="comment-top">
|
||||||
<span class="comment-author">${this.#esc(authorName)}</span>
|
<span class="comment-author">${this.#esc(authorName)}</span>
|
||||||
${resolvedBadge}
|
${resolvedBadge}
|
||||||
</div>
|
</div>
|
||||||
|
${anchorLabel}
|
||||||
<div class="comment-text">${this.#esc(this.#truncate(text))}</div>
|
<div class="comment-text">${this.#esc(this.#truncate(text))}</div>
|
||||||
<div class="comment-meta">
|
<div class="comment-meta">
|
||||||
${threadCount > 1 ? `<span class="thread-count">${threadCount} messages</span>` : ""}
|
${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"));
|
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) => {
|
this.#shadow.querySelectorAll(".comment-item").forEach((el) => {
|
||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const pinId = (el as HTMLElement).dataset.pinId;
|
const pinId = (el as HTMLElement).dataset.pinId;
|
||||||
if (pinId) {
|
const source = (el as HTMLElement).dataset.source;
|
||||||
this.#open = false;
|
const elementId = (el as HTMLElement).dataset.elementId;
|
||||||
this.#render();
|
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 } }));
|
window.dispatchEvent(new CustomEvent("comment-pin-focus", { detail: { pinId } }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -433,6 +547,13 @@ const STYLES = `
|
||||||
color: var(--rs-text-primary, #e2e8f0);
|
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 {
|
.resolved-badge, .open-badge {
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
font-weight: 700;
|
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 { RStackSharePanel } from "../shared/components/rstack-share-panel";
|
||||||
import { RStackCommentBell } from "../shared/components/rstack-comment-bell";
|
import { RStackCommentBell } from "../shared/components/rstack-comment-bell";
|
||||||
import { RStackCollabOverlay } from "../shared/components/rstack-collab-overlay";
|
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 { RStackUserDashboard } from "../shared/components/rstack-user-dashboard";
|
||||||
import { rspaceNavUrl } from "../shared/url-helpers";
|
import { rspaceNavUrl } from "../shared/url-helpers";
|
||||||
import { TabCache } from "../shared/tab-cache";
|
import { TabCache } from "../shared/tab-cache";
|
||||||
|
|
@ -42,6 +43,7 @@ RStackOfflineIndicator.define();
|
||||||
RStackSharePanel.define();
|
RStackSharePanel.define();
|
||||||
RStackCommentBell.define();
|
RStackCommentBell.define();
|
||||||
RStackCollabOverlay.define();
|
RStackCollabOverlay.define();
|
||||||
|
RStackModuleComments.define();
|
||||||
RStackUserDashboard.define();
|
RStackUserDashboard.define();
|
||||||
|
|
||||||
// ── Offline Runtime (lazy-loaded) ──
|
// ── Offline Runtime (lazy-loaded) ──
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue