rspace-online/lib/folk-comment-pin.ts

938 lines
30 KiB
TypeScript

/**
* 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;
}
interface RNoteItem {
id: string;
title: string;
type: string;
updated_at: 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;
#notes: RNoteItem[] | 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();
}
linkNote(pinId: string, noteId: string, noteTitle: string) {
const newDoc = Automerge.change(this.#sync.doc, "Link note to pin", (doc) => {
if (doc.commentPins?.[pinId]) {
doc.commentPins[pinId].linkedNoteId = noteId;
doc.commentPins[pinId].linkedNoteTitle = noteTitle;
}
});
this.#sync._applyDocChange(newDoc);
if (this.#openPinId === pinId) this.#openPinPopover(pinId, false);
this.#renderAllPins();
}
unlinkNote(pinId: string) {
const newDoc = Automerge.change(this.#sync.doc, "Unlink note from pin", (doc) => {
if (doc.commentPins?.[pinId]) {
delete (doc.commentPins[pinId] as any).linkedNoteId;
delete (doc.commentPins[pinId] as any).linkedNoteTitle;
}
});
this.#sync._applyDocChange(newDoc);
if (this.#openPinId === pinId) this.#openPinPopover(pinId, false);
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<string>();
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 = `
<div style="
width: 28px; height: 28px;
background: ${bgColor}; border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex; align-items: center; justify-content: center;
border: ${border}; opacity: ${opacity};
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
">
<span style="
transform: rotate(45deg);
color: white; font-size: 11px; font-weight: 700;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
">${displayIndex}</span>
</div>
${this.#scale >= 0.4 && pin.messages.length > 0 ? `
<div style="
position: absolute; top: -6px; right: -6px;
background: #ef4444; color: white; font-size: 9px;
min-width: 16px; height: 16px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-weight: 700; transform: rotate(45deg);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
">${pin.messages.length}</div>
` : ""}
`;
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<HTMLElement>;
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 = `<style>
.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; }
.cp-link-btn { background: none; border: 1px solid #444; border-radius: 6px;
padding: 4px 8px; cursor: pointer; color: #a78bfa; font-size: 12px; width: 100%; text-align: left; }
.cp-link-btn:hover { background: #2a2a3a; }
.cp-note-item { padding: 6px 10px; cursor: pointer; font-size: 12px; border-bottom: 1px solid #333; }
.cp-note-item:hover { background: #3a3a4a; }
.cp-note-item:last-child { border-bottom: none; }
</style>`;
// Header
html += `
<div style="padding: 10px 12px; border-bottom: 1px solid #333; display: flex; align-items: center; justify-content: space-between;">
<span style="font-weight: 700; color: #a78bfa;">Pin #${pinIndex}</span>
<div style="display: flex; gap: 4px;">
${pin.resolved
? `<button class="cp-action" data-action="unresolve" title="Reopen">↩</button>`
: `<button class="cp-action" data-action="resolve" title="Resolve">✓</button>`}
<button class="cp-action" data-action="remind" title="Set reminder">📅</button>
<button class="cp-action" data-action="delete" title="Delete" style="color: #ef4444;">✕</button>
</div>
</div>
`;
if (isOrphaned) {
html += `<div style="padding: 6px 12px; background: #44200a; color: #fbbf24; font-size: 11px;">⚠ Attached shape was deleted</div>`;
}
// Linked rNote
if (pin.linkedNoteId) {
html += `
<div style="padding: 8px 12px; background: #1a1a2e; border-bottom: 1px solid #333; display: flex; align-items: center; justify-content: space-between;">
<a href="/${this.#spaceSlug}/rnotes#${pin.linkedNoteId}" target="_blank"
style="color: #a78bfa; text-decoration: none; font-size: 12px; font-weight: 600;">
📝 ${this.#escapeHtml(pin.linkedNoteTitle || "Linked note")}
</a>
<button class="cp-action" data-action="unlink-note" title="Unlink" style="font-size: 10px;">✕</button>
</div>
`;
}
// Messages
if (pin.messages.length > 0) {
html += `<div style="max-height: 200px; overflow-y: auto; padding: 8px 12px;">`;
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 style="margin-bottom: 10px;">
<div style="display: flex; justify-content: space-between; align-items: baseline;">
<span style="font-weight: 600; color: #c4b5fd; font-size: 12px;">${this.#escapeHtml(msg.authorName)}</span>
<span style="color: #666; font-size: 10px;">${time}</span>
</div>
<div style="margin-top: 3px; line-height: 1.4; word-break: break-word;">${this.#formatMessageText(msg.text)}</div>
</div>
`;
}
html += `</div>`;
}
// Input + link note row
html += `
<div style="padding: 8px 12px; border-top: 1px solid #333; position: relative;">
<div style="display: flex; gap: 6px;">
<input type="text" class="cp-input" placeholder="Add a note... (@ to mention)"
style="flex: 1; background: #2a2a3a; border: 1px solid #444; border-radius: 6px;
padding: 6px 10px; color: #e0e0e0; font-size: 13px; outline: none;"
/>
<button class="cp-send" style="
background: #8b5cf6; color: white; border: none; border-radius: 6px;
padding: 6px 12px; cursor: pointer; font-size: 13px; font-weight: 600;
">Send</button>
</div>
${!pin.linkedNoteId ? `
<div style="margin-top: 6px;">
<button class="cp-link-btn" data-action="link-note">📝 Link existing rNote...</button>
<div class="cp-note-picker" style="display: none; max-height: 150px; overflow-y: auto;
background: #2a2a3a; border: 1px solid #555; border-radius: 6px; margin-top: 4px;"></div>
</div>
` : ""}
</div>
`;
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);
else if (action === "unlink-note") this.unlinkNote(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));
// Link note button
const linkBtn = this.#popover.querySelector('[data-action="link-note"]') as HTMLButtonElement | null;
const notePicker = this.#popover.querySelector(".cp-note-picker") as HTMLElement | null;
if (linkBtn && notePicker) {
linkBtn.addEventListener("click", async () => {
if (notePicker.style.display !== "none") {
notePicker.style.display = "none";
return;
}
notePicker.innerHTML = `<div style="padding: 8px; color: #888; font-size: 12px;">Loading notes...</div>`;
notePicker.style.display = "block";
const notes = await this.#fetchNotes();
if (notes.length === 0) {
notePicker.innerHTML = `<div style="padding: 8px; color: #888; font-size: 12px;">No notes in this space</div>`;
return;
}
notePicker.innerHTML = "";
for (const note of notes) {
const item = document.createElement("div");
item.className = "cp-note-item";
item.textContent = note.title || "Untitled";
item.addEventListener("click", () => {
this.linkNote(pinId, note.id, note.title || "Untitled");
});
notePicker.appendChild(item);
}
});
}
if (focusInput) {
requestAnimationFrame(() => input.focus());
}
}
// ── @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 #fetchNotes(): Promise<RNoteItem[]> {
if (this.#notes) return this.#notes;
try {
const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}");
const res = await fetch(`/${this.#spaceSlug}/rnotes/api/notes?limit=50`, {
headers: sess?.accessToken ? { Authorization: `Bearer ${sess.accessToken}` } : {},
});
if (!res.ok) return [];
const data = await res.json();
this.#notes = (data.notes || []).map((n: any) => ({
id: n.id, title: n.title, type: n.type, updated_at: n.updated_at,
}));
return this.#notes!;
} 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 = `
<span style="font-weight: 600;">${this.#escapeHtml(m.displayName || m.username)}</span>
<span style="color: #888;">@${this.#escapeHtml(m.username)}</span>
`;
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<string, any> = {
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,
'<span style="color: #a78bfa; font-weight: 600;">@$1</span>',
);
return safe;
}
destroy() {
this.#pinLayer.remove();
this.#popover.remove();
}
}