diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 9bc542c..9889202 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -11,6 +11,7 @@ import { computeMembranePermeability } from "./connection-types"; import { makeChangeMessage, parseChangeMessage } from "../shared/local-first/change-message"; import type { HistoryEntry } from "../shared/components/rstack-history-panel"; import type { EventEntry } from "./event-bus"; +import type { CommentPinData } from "../shared/comment-pin-types"; // Shape data stored in Automerge document export interface ShapeData { @@ -136,6 +137,8 @@ export interface CommunityDoc { layerViewMode?: "flat" | "stack"; /** Pub/sub event log — bounded ring buffer (last 100 entries) */ eventLog?: EventEntry[]; + /** Comment pins — Figma-style overlay markers */ + commentPins?: { [pinId: string]: CommentPinData }; } type SyncState = Automerge.SyncState; @@ -844,6 +847,11 @@ export class CommunitySync extends EventTarget { this.dispatchEvent(new CustomEvent("eventlog-changed")); } + // Notify comment pin manager of any pin data + if (this.#doc.commentPins && Object.keys(this.#doc.commentPins).length > 0) { + this.dispatchEvent(new CustomEvent("comment-pins-changed")); + } + // Debounce the synced event — during initial sync negotiation, #applyDocToDOM() // is called for every Automerge sync message (100+ round-trips). Debounce to // fire once after the burst settles. Only fires once per connection cycle. @@ -862,6 +870,7 @@ export class CommunitySync extends EventTarget { */ #applyPatchesToDOM(patches: Automerge.Patch[]): void { let eventLogChanged = false; + let commentPinsChanged = false; for (const patch of patches) { const path = patch.path; @@ -872,6 +881,12 @@ export class CommunitySync extends EventTarget { continue; } + // Detect commentPins changes + if (path[0] === "commentPins") { + commentPinsChanged = true; + continue; + } + // Handle shape updates: ["shapes", shapeId, ...] if (path[0] === "shapes" && typeof path[1] === "string") { const shapeId = path[1]; @@ -913,6 +928,11 @@ export class CommunitySync extends EventTarget { if (eventLogChanged) { this.dispatchEvent(new CustomEvent("eventlog-changed")); } + + // Notify comment pin manager of remote pin changes + if (commentPinsChanged) { + this.dispatchEvent(new CustomEvent("comment-pins-changed")); + } } /** diff --git a/lib/folk-comment-pin.ts b/lib/folk-comment-pin.ts new file mode 100644 index 0000000..a56bcff --- /dev/null +++ b/lib/folk-comment-pin.ts @@ -0,0 +1,833 @@ +/** + * CommentPinManager — Figma-style comment pins on the canvas. + * Follows the PresenceManager pattern: overlay DOM layer with camera tracking. + */ + +import * as Automerge from "@automerge/automerge"; +import type { CommunitySync } from "./community-sync"; +import type { + CommentPinAnchor, + CommentPinMessage, + CommentPinData, +} from "../shared/comment-pin-types"; + +interface SpaceMember { + did: string; + displayName: string | null; + username: string; +} + +export class CommentPinManager { + #container: HTMLElement; + #canvasContent: HTMLElement; + #sync: CommunitySync; + #spaceSlug: string; + #panX = 0; + #panY = 0; + #scale = 1; + #pinLayer: HTMLElement; + #popover: HTMLElement; + #placementMode = false; + #showResolved = false; + #openPinId: string | null = null; + #members: SpaceMember[] | null = null; + #mentionDropdown: HTMLElement | null = null; + + constructor( + container: HTMLElement, + canvasContent: HTMLElement, + sync: CommunitySync, + spaceSlug: string, + ) { + this.#container = container; + this.#canvasContent = canvasContent; + this.#sync = sync; + this.#spaceSlug = spaceSlug; + + // Create pin overlay layer + this.#pinLayer = document.createElement("div"); + this.#pinLayer.id = "comment-pins-layer"; + this.#pinLayer.style.cssText = ` + position: absolute; top: 0; left: 0; + width: 100%; height: 100%; + pointer-events: none; z-index: 9998; overflow: visible; + `; + this.#container.appendChild(this.#pinLayer); + + // Create shared popover + this.#popover = document.createElement("div"); + this.#popover.id = "comment-pin-popover"; + this.#popover.style.cssText = ` + display: none; position: absolute; z-index: 10001; + background: #1e1e2e; border: 1px solid #444; border-radius: 10px; + width: 300px; max-height: 400px; overflow: hidden; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); pointer-events: auto; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + color: #e0e0e0; font-size: 13px; + `; + this.#container.appendChild(this.#popover); + + // Listen for remote comment pin changes + this.#sync.addEventListener("comment-pins-changed", () => this.#renderAllPins()); + this.#sync.addEventListener("synced", () => this.#renderAllPins()); + + // Listen for shape transforms to track attached pins + this.#canvasContent.addEventListener("folk-transform", (e: Event) => { + const target = e.target as HTMLElement; + if (target?.id) this.#refreshPinsForShape(target.id); + }); + + // Close popover on outside click + document.addEventListener("pointerdown", (e) => { + if (this.#popover.style.display === "none") return; + if (this.#popover.contains(e.target as Node)) return; + // Don't close if clicking a pin marker + if ((e.target as HTMLElement)?.closest?.(".comment-pin-marker")) return; + this.closePopover(); + }); + } + + // ── Camera ── + + setCamera(panX: number, panY: number, scale: number) { + this.#panX = panX; + this.#panY = panY; + this.#scale = scale; + this.#repositionAllPins(); + this.#repositionPopover(); + + // Hide pins when zoomed out very far + this.#pinLayer.style.display = scale < 0.15 ? "none" : ""; + } + + // ── Placement Mode ── + + get placementMode() { + return this.#placementMode; + } + + enterPinPlacementMode() { + this.#placementMode = true; + this.#container.style.cursor = "crosshair"; + } + + exitPinPlacementMode() { + this.#placementMode = false; + this.#container.style.cursor = ""; + } + + // ── Pin CRUD ── + + createPin(anchor: CommentPinAnchor): string { + const pinId = crypto.randomUUID(); + const did = this.#getLocalDID(); + const name = this.#getLocalUsername(); + + const pinData: CommentPinData = { + id: pinId, + anchor, + resolved: false, + messages: [], + createdAt: Date.now(), + createdBy: did, + createdByName: name, + }; + + const newDoc = Automerge.change(this.#sync.doc, "Add comment pin", (doc) => { + if (!doc.commentPins) doc.commentPins = {}; + doc.commentPins[pinId] = JSON.parse(JSON.stringify(pinData)); + }); + this.#sync._applyDocChange(newDoc); + + this.#renderAllPins(); + this.#openPinPopover(pinId, true); + return pinId; + } + + createPinOnShape(shapeId: string) { + const shape = document.getElementById(shapeId) as HTMLElement | null; + if (!shape) return; + + const anchor: CommentPinAnchor = { + type: "shape", + shapeId, + offsetX: 0, + offsetY: 0, + }; + this.createPin(anchor); + } + + handleCanvasClick(worldX: number, worldY: number) { + if (!this.#placementMode) return false; + + // Check if clicking on a shape + const shapeEl = document.elementFromPoint( + worldX * this.#scale + this.#panX, + worldY * this.#scale + this.#panY, + )?.closest?.("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-prompt, folk-zine-gen, folk-image-gen, folk-video-gen, folk-embed, folk-chat, folk-rapp") as HTMLElement | null; + + let anchor: CommentPinAnchor; + if (shapeEl?.id) { + const shapeRect = shapeEl.getBoundingClientRect(); + const canvasRect = this.#container.getBoundingClientRect(); + // Offset relative to shape origin + const shapeWorldX = (shapeRect.left - canvasRect.left - this.#panX) / this.#scale; + const shapeWorldY = (shapeRect.top - canvasRect.top - this.#panY) / this.#scale; + anchor = { + type: "shape", + shapeId: shapeEl.id, + offsetX: worldX - shapeWorldX, + offsetY: worldY - shapeWorldY, + }; + } else { + anchor = { type: "canvas", offsetX: worldX, offsetY: worldY }; + } + + this.createPin(anchor); + this.exitPinPlacementMode(); + return true; + } + + addMessage(pinId: string, text: string) { + const did = this.#getLocalDID(); + const name = this.#getLocalUsername(); + + // Extract @mentions + const mentionedDids = this.#extractMentionDids(text); + + const msg: CommentPinMessage = { + id: crypto.randomUUID(), + authorId: did, + authorName: name, + text, + mentionedDids: mentionedDids.length > 0 ? mentionedDids : undefined, + createdAt: Date.now(), + }; + + const newDoc = Automerge.change(this.#sync.doc, "Add comment", (doc) => { + if (doc.commentPins?.[pinId]) { + doc.commentPins[pinId].messages.push(JSON.parse(JSON.stringify(msg))); + } + }); + this.#sync._applyDocChange(newDoc); + + // Fire-and-forget notification for mentions + if (mentionedDids.length > 0) { + this.#notifyMentions(pinId, did, name, mentionedDids); + } + + // Re-render popover if open + if (this.#openPinId === pinId) { + this.#openPinPopover(pinId, false); + } + this.#renderAllPins(); + } + + resolvePin(pinId: string) { + const newDoc = Automerge.change(this.#sync.doc, "Resolve comment pin", (doc) => { + if (doc.commentPins?.[pinId]) { + doc.commentPins[pinId].resolved = true; + } + }); + this.#sync._applyDocChange(newDoc); + this.closePopover(); + this.#renderAllPins(); + } + + unresolvePin(pinId: string) { + const newDoc = Automerge.change(this.#sync.doc, "Unresolve comment pin", (doc) => { + if (doc.commentPins?.[pinId]) { + doc.commentPins[pinId].resolved = false; + } + }); + this.#sync._applyDocChange(newDoc); + this.#renderAllPins(); + } + + deletePin(pinId: string) { + const newDoc = Automerge.change(this.#sync.doc, "Delete comment pin", (doc) => { + if (doc.commentPins?.[pinId]) { + delete doc.commentPins[pinId]; + } + }); + this.#sync._applyDocChange(newDoc); + this.closePopover(); + this.#renderAllPins(); + } + + toggleShowResolved() { + this.#showResolved = !this.#showResolved; + this.#renderAllPins(); + return this.#showResolved; + } + + closePopover() { + this.#popover.style.display = "none"; + this.#openPinId = null; + this.#closeMentionDropdown(); + } + + openPinById(pinId: string) { + const pins = this.#sync.doc.commentPins || {}; + if (!pins[pinId]) return; + this.#renderAllPins(); + this.#openPinPopover(pinId, false); + + // Pan to the pin + const pos = this.#getPinWorldPosition(pins[pinId]); + if (pos) { + // Dispatch a custom event so canvas.html can animate to position + this.#container.dispatchEvent( + new CustomEvent("comment-pin-navigate", { detail: { x: pos.x, y: pos.y } }), + ); + } + } + + // ── Rendering ── + + #renderAllPins() { + const pins = this.#sync.doc.commentPins || {}; + const sortedPins = Object.values(pins).sort((a, b) => a.createdAt - b.createdAt); + + // Detect orphaned pins (shape deleted) + const orphanedPins = new Set(); + for (const pin of sortedPins) { + if (pin.anchor.type === "shape" && pin.anchor.shapeId) { + const shapeExists = this.#sync.doc.shapes?.[pin.anchor.shapeId]; + const shapeDeleted = shapeExists && (shapeExists as any).deleted === true; + if (!shapeExists || shapeDeleted) { + orphanedPins.add(pin.id); + } + } + } + + // Clear existing markers + this.#pinLayer.innerHTML = ""; + + let displayIndex = 0; + for (const pin of sortedPins) { + displayIndex++; + + if (pin.resolved && !this.#showResolved) continue; + + const pos = this.#getPinWorldPosition(pin); + if (!pos) continue; + + const screenX = pos.x * this.#scale + this.#panX; + const screenY = pos.y * this.#scale + this.#panY; + const isOrphaned = orphanedPins.has(pin.id); + + const marker = document.createElement("div"); + marker.className = "comment-pin-marker"; + marker.dataset.pinId = pin.id; + marker.style.cssText = ` + position: absolute; + left: ${screenX}px; top: ${screenY}px; + width: 28px; height: 28px; + transform: translate(-50%, -100%); + pointer-events: auto; cursor: pointer; + transition: left 0.05s, top 0.05s; + z-index: ${pin.id === this.#openPinId ? 10000 : 9998}; + `; + + const bgColor = pin.resolved ? "#666" : isOrphaned ? "#ef4444" : "#8b5cf6"; + const border = isOrphaned ? "2px dashed #fbbf24" : "2px solid rgba(255,255,255,0.3)"; + const opacity = pin.resolved ? "0.5" : "1"; + + marker.innerHTML = ` +
+ ${displayIndex} +
+ ${this.#scale >= 0.4 && pin.messages.length > 0 ? ` +
${pin.messages.length}
+ ` : ""} + `; + + marker.addEventListener("click", (e) => { + e.stopPropagation(); + this.#openPinPopover(pin.id, false); + }); + + this.#pinLayer.appendChild(marker); + } + } + + #repositionAllPins() { + const pins = this.#sync.doc.commentPins || {}; + const markers = this.#pinLayer.querySelectorAll(".comment-pin-marker") as NodeListOf; + + for (const marker of markers) { + const pinId = marker.dataset.pinId; + if (!pinId || !pins[pinId]) continue; + + const pos = this.#getPinWorldPosition(pins[pinId]); + if (!pos) continue; + + const screenX = pos.x * this.#scale + this.#panX; + const screenY = pos.y * this.#scale + this.#panY; + marker.style.left = `${screenX}px`; + marker.style.top = `${screenY}px`; + + // Hide labels when zoomed out + const badge = marker.querySelector("div[style*='top: -6px']") as HTMLElement | null; + if (badge) badge.style.display = this.#scale >= 0.4 ? "" : "none"; + } + } + + #repositionPopover() { + if (!this.#openPinId || this.#popover.style.display === "none") return; + const pins = this.#sync.doc.commentPins || {}; + const pin = pins[this.#openPinId]; + if (!pin) return; + + const pos = this.#getPinWorldPosition(pin); + if (!pos) return; + + const screenX = pos.x * this.#scale + this.#panX; + const screenY = pos.y * this.#scale + this.#panY; + this.#popover.style.left = `${screenX + 16}px`; + this.#popover.style.top = `${screenY}px`; + } + + #refreshPinsForShape(shapeId: string) { + const pins = this.#sync.doc.commentPins || {}; + let needsReposition = false; + + for (const pin of Object.values(pins)) { + if (pin.anchor.type === "shape" && pin.anchor.shapeId === shapeId) { + needsReposition = true; + break; + } + } + + if (needsReposition) { + this.#repositionAllPins(); + this.#repositionPopover(); + } + } + + #getPinWorldPosition(pin: CommentPinData): { x: number; y: number } | null { + if (pin.anchor.type === "canvas") { + return { x: pin.anchor.offsetX, y: pin.anchor.offsetY }; + } + + if (pin.anchor.type === "shape" && pin.anchor.shapeId) { + const shapeData = this.#sync.doc.shapes?.[pin.anchor.shapeId]; + if (!shapeData || (shapeData as any).deleted) { + // Orphaned — fall back to anchor offset as world coords + return { x: pin.anchor.offsetX, y: pin.anchor.offsetY }; + } + return { + x: shapeData.x + pin.anchor.offsetX, + y: shapeData.y + pin.anchor.offsetY, + }; + } + + return null; + } + + // ── Popover ── + + #openPinPopover(pinId: string, focusInput: boolean) { + const pins = this.#sync.doc.commentPins || {}; + const pin = pins[pinId]; + if (!pin) return; + + this.#openPinId = pinId; + const pos = this.#getPinWorldPosition(pin); + if (!pos) return; + + const screenX = pos.x * this.#scale + this.#panX; + const screenY = pos.y * this.#scale + this.#panY; + + // Sequential pin number + const sortedPins = Object.values(pins).sort((a, b) => a.createdAt - b.createdAt); + const pinIndex = sortedPins.findIndex((p) => p.id === pinId) + 1; + + const isOrphaned = + pin.anchor.type === "shape" && + pin.anchor.shapeId && + (!this.#sync.doc.shapes?.[pin.anchor.shapeId] || + (this.#sync.doc.shapes[pin.anchor.shapeId] as any).deleted); + + let html = ` +
+ Pin #${pinIndex} +
+ ${pin.resolved + ? `` + : ``} + + +
+
+ ${isOrphaned ? `
⚠ Attached shape was deleted
` : ""} + `; + + // Messages + if (pin.messages.length > 0) { + html += `
`; + for (const msg of pin.messages) { + const time = new Date(msg.createdAt).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + html += ` +
+
+ ${this.#escapeHtml(msg.authorName)} + ${time} +
+
${this.#formatMessageText(msg.text)}
+
+ `; + } + html += `
`; + } + + // Input + html += ` +
+
+ + +
+
+ `; + + this.#popover.innerHTML = html; + this.#popover.style.display = "block"; + this.#popover.style.left = `${screenX + 16}px`; + this.#popover.style.top = `${screenY}px`; + + // Wire up actions + this.#popover.querySelectorAll(".cp-action").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); + else if (action === "remind") this.#createReminder(pinId); + }); + }); + + // Wire up input + const input = this.#popover.querySelector(".cp-input") as HTMLInputElement; + const sendBtn = this.#popover.querySelector(".cp-send") as HTMLButtonElement; + + const submitComment = () => { + const text = input.value.trim(); + if (!text) return; + this.addMessage(pinId, text); + input.value = ""; + }; + + 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)); + + if (focusInput) { + requestAnimationFrame(() => input.focus()); + } + + // Style action buttons + const style = document.createElement("style"); + style.textContent = ` + .cp-action { background: none; border: 1px solid #444; border-radius: 4px; + padding: 2px 6px; cursor: pointer; color: #ccc; font-size: 13px; } + .cp-action:hover { background: #333; } + `; + this.#popover.prepend(style); + } + + // ── @Mention Autocomplete ── + + async #fetchMembers() { + if (this.#members) return this.#members; + try { + const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}"); + const res = await fetch(`/${this.#spaceSlug}/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; + + // Find @ before cursor + 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.className = "cp-mention-dropdown"; + dropdown.style.cssText = ` + position: absolute; bottom: 100%; left: 12px; right: 12px; + background: #2a2a3a; border: 1px solid #555; border-radius: 6px; + max-height: 150px; overflow-y: auto; z-index: 10002; + `; + + for (const m of members.slice(0, 8)) { + const item = document.createElement("div"); + item.style.cssText = ` + padding: 6px 10px; cursor: pointer; font-size: 12px; + display: flex; justify-content: space-between; + `; + item.innerHTML = ` + ${this.#escapeHtml(m.displayName || m.username)} + @${this.#escapeHtml(m.username)} + `; + item.addEventListener("mousedown", (e) => { + e.preventDefault(); // prevent input blur + 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 inputContainer = input.closest("div[style*='position: relative']"); + if (inputContainer) { + inputContainer.appendChild(dropdown); + } + this.#mentionDropdown = dropdown; + } + + #closeMentionDropdown() { + if (this.#mentionDropdown) { + this.#mentionDropdown.remove(); + this.#mentionDropdown = null; + } + } + + #extractMentionDids(text: string): string[] { + const mentions = text.match(/@(\w+)/g); + if (!mentions || !this.#members) return []; + + const dids: string[] = []; + for (const mention of mentions) { + const username = mention.slice(1).toLowerCase(); + const member = this.#members.find( + (m) => m.username.toLowerCase() === username, + ); + if (member) dids.push(member.did); + } + return dids; + } + + // ── Notifications ── + + async #notifyMentions( + pinId: string, + authorDid: string, + authorName: string, + mentionedDids: string[], + ) { + const pins = this.#sync.doc.commentPins || {}; + const sortedPins = Object.values(pins).sort((a, b) => a.createdAt - b.createdAt); + const pinIndex = sortedPins.findIndex((p) => p.id === pinId) + 1; + + try { + const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}"); + await fetch(`/${this.#spaceSlug}/api/comment-pins/notify-mention`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(sess?.accessToken ? { Authorization: `Bearer ${sess.accessToken}` } : {}), + }, + body: JSON.stringify({ + pinId, + authorDid, + authorName, + mentionedDids, + pinIndex, + }), + }); + } catch { + // Silent fail — notification is best-effort + } + } + + // ── Reminder ── + + async #createReminder(pinId: string) { + const pins = this.#sync.doc.commentPins || {}; + const pin = pins[pinId]; + if (!pin) return; + + const sortedPins = Object.values(pins).sort((a, b) => a.createdAt - b.createdAt); + const pinIndex = sortedPins.findIndex((p) => p.id === pinId) + 1; + const firstMsg = pin.messages[0]?.text || "Comment pin"; + + // Tomorrow 9 AM + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(9, 0, 0, 0); + + // Fetch user email + let email: string | false = false; + try { + const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}"); + if (sess?.accessToken) { + const r = await fetch("/auth/api/account/security", { + headers: { Authorization: `Bearer ${sess.accessToken}` }, + }); + if (r.ok) { + const d = await r.json(); + email = d.emailAddress || false; + } + } + } catch {} + + try { + const body: Record = { + title: `Comment #${pinIndex}: ${firstMsg.slice(0, 50)}`, + remindAt: tomorrow.getTime(), + allDay: true, + syncToCalendar: true, + sourceModule: "canvas-comments", + sourceEntityId: pinId, + sourceLabel: "Canvas Comment", + sourceColor: "#8b5cf6", + }; + if (email) body.notifyEmail = email; + + const res = await fetch(`/${this.#spaceSlug}/rschedule/api/reminders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (res.ok) { + // Show brief feedback in popover + const fb = document.createElement("div"); + fb.style.cssText = + "padding: 6px 12px; background: #1a3a1a; color: #4ade80; font-size: 12px; text-align: center;"; + fb.textContent = "✓ Reminder set for tomorrow"; + this.#popover.prepend(fb); + setTimeout(() => fb.remove(), 2500); + } + } catch { + // Silent fail + } + } + + // ── Helpers ── + + #getLocalDID(): string { + try { + const sess = JSON.parse(localStorage.getItem("encryptid_session") || ""); + return sess?.claims?.sub || sess?.claims?.did || "anonymous"; + } catch { + return "anonymous"; + } + } + + #getLocalUsername(): string { + return localStorage.getItem("rspace-username") || "Anonymous"; + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + #formatMessageText(text: string): string { + // Escape HTML first + let safe = this.#escapeHtml(text); + // Highlight @mentions + safe = safe.replace( + /@(\w+)/g, + '@$1', + ); + return safe; + } + + destroy() { + this.#pinLayer.remove(); + this.#popover.remove(); + } +} diff --git a/lib/index.ts b/lib/index.ts index dd57e57..864dc93 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -110,6 +110,7 @@ export * from "./folk-group-frame"; export * from "./community-sync"; export * from "./presence"; export * from "./event-bus"; +export * from "./folk-comment-pin"; // Offline support export * from "./offline-store"; diff --git a/server/index.ts b/server/index.ts index 0f2addc..d794380 100644 --- a/server/index.ts +++ b/server/index.ts @@ -709,6 +709,56 @@ app.get("/api/modules/:moduleId/landing", (c) => { return c.json({ html, icon: mod.icon || "", name: mod.name || moduleId }); }); +// ── Comment Pin API ── +import { listAllUsersWithTrust } from "../src/encryptid/db"; + +// Space members for @mention autocomplete +app.get("/:space/api/space-members", async (c) => { + const space = c.req.param("space"); + try { + const users = await listAllUsersWithTrust(space); + return c.json({ + members: users.map((u) => ({ + did: u.did, + username: u.username, + displayName: u.displayName, + })), + }); + } catch { + return c.json({ members: [] }); + } +}); + +// Mention notification +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; + if (!pinId || !authorDid || !mentionedDids?.length) { + return c.json({ error: "Missing fields" }, 400); + } + for (const did of mentionedDids) { + await notify({ + userDid: did, + category: "module", + eventType: "canvas_mention", + title: `${authorName} mentioned you in a comment`, + body: `Comment pin #${pinIndex || "?"} in ${space}`, + spaceSlug: space, + moduleId: "rspace", + actionUrl: `/${space}/rspace#pin-${pinId}`, + actorDid: authorDid, + actorUsername: authorName, + }); + } + return c.json({ ok: true }); + } catch (err) { + console.error("[comment-pins] notify error:", err); + return c.json({ error: "Failed to send notification" }, 500); + } +}); + // ── x402 test endpoint (payment-gated, supports on-chain + CRDT) ── import { setupX402FromEnv } from "../shared/x402/hono-middleware"; import { setTokenVerifier } from "../shared/x402/crdt-scheme"; diff --git a/shared/comment-pin-types.ts b/shared/comment-pin-types.ts new file mode 100644 index 0000000..0d3158d --- /dev/null +++ b/shared/comment-pin-types.ts @@ -0,0 +1,30 @@ +/** + * Comment Pin Types — shared between client and server. + * Figma-style overlay comment markers on the canvas. + */ + +export interface CommentPinAnchor { + type: 'shape' | 'canvas'; + shapeId?: string; // when type === 'shape' + offsetX: number; // shape-relative or world coords + offsetY: number; +} + +export interface CommentPinMessage { + id: string; + authorId: string; // DID + authorName: string; + text: string; + mentionedDids?: string[]; + createdAt: number; +} + +export interface CommentPinData { + id: string; + anchor: CommentPinAnchor; + resolved: boolean; + messages: CommentPinMessage[]; + createdAt: number; + createdBy: string; // DID + createdByName: string; +} diff --git a/website/canvas.html b/website/canvas.html index 46d95bb..309e610 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -2208,6 +2208,9 @@ + `; html += ``; } else if (state === 'forgotten') { html += ``; @@ -5760,6 +5807,13 @@ } + if (action === 'add-comment') { + pinManager.createPinOnShape(contextTargetIds[0]); + shapeContextMenu.classList.remove("open"); + contextTargetIds = []; + return; + } + if (action === 'schedule-reminder') { const targetId = contextTargetIds[0]; const el = document.getElementById(targetId); @@ -6209,6 +6263,15 @@ if (memoryPanel.classList.contains("open")) renderMemoryPanel(); }); + // Navigate to comment pin position + canvas.addEventListener("comment-pin-navigate", (e) => { + const { x, y } = e.detail; + const rect = canvas.getBoundingClientRect(); + const targetPanX = rect.width / 2 - x * scale; + const targetPanY = rect.height / 2 - y * scale; + animateZoom(scale, targetPanX, targetPanY, 400); + }); + function updateCanvasTransform() { // Transform only the content layer — canvas viewport stays fixed canvasContent.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`; @@ -6229,6 +6292,7 @@ } // Update remote cursors to match new camera position presence.setCamera(panX, panY, scale); + pinManager.setCamera(panX, panY, scale); updateScheduleIcon(); updateLockIcon(); }