/** * — Right sidebar panel for viewing/managing inline comments. * * Shows threaded comments anchored to highlighted text in the editor. * Comment thread data is stored in Automerge, while the highlight mark * position is stored in Yjs (part of the document content). * * Supports: demo mode (in-memory), emoji reactions, date reminders. */ import type { Editor } from '@tiptap/core'; import type { DocumentId } from '../../../shared/local-first/document'; interface CommentMessage { id: string; authorId: string; authorName: string; text: string; createdAt: number; } interface CommentThread { id: string; anchor: string; resolved: boolean; messages: CommentMessage[]; createdAt: number; reactions?: Record; reminderAt?: number; reminderId?: string; } interface NotebookDoc { items: Record; [key: string]: any; }>; [key: string]: any; } const REACTION_EMOJIS = ['👍', '👎', '❤️', '🎉', '😂', '😮', '🔥']; class NotesCommentPanel extends HTMLElement { private shadow: ShadowRoot; private _noteId: string | null = null; private _doc: any = null; private _subscribedDocId: string | null = null; private _activeThreadId: string | null = null; private _editor: Editor | null = null; private _demoThreads: Record | null = null; private _space = ''; constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); } set noteId(v: string | null) { this._noteId = v; this.render(); } set doc(v: any) { this._doc = v; this.render(); } set subscribedDocId(v: string | null) { this._subscribedDocId = v; } set activeThreadId(v: string | null) { this._activeThreadId = v; this.render(); } set editor(v: Editor | null) { this._editor = v; } set space(v: string) { this._space = v; } set demoThreads(v: Record | null) { this._demoThreads = v; this.render(); } private get isDemo(): boolean { return this._space === 'demo'; } private getSessionInfo(): { authorName: string; authorId: string } { try { const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}'); return { authorName: sess?.username || sess?.displayName || 'Anonymous', authorId: sess?.userId || sess?.sub || 'anon', }; } catch { return { authorName: 'Anonymous', authorId: 'anon' }; } } private getThreads(): CommentThread[] { // Demo threads take priority if (this._demoThreads) { return Object.values(this._demoThreads).sort((a, b) => a.createdAt - b.createdAt); } if (!this._doc || !this._noteId) return []; const item = this._doc.items?.[this._noteId]; if (!item?.comments) return []; return Object.values(item.comments as Record) .sort((a, b) => a.createdAt - b.createdAt); } private dispatchDemoMutation() { if (!this._demoThreads || !this._noteId) return; this.dispatchEvent(new CustomEvent('comment-demo-mutation', { detail: { noteId: this._noteId, threads: { ...this._demoThreads } }, bubbles: true, composed: true, })); } private render() { const threads = this.getThreads(); if (threads.length === 0 && !this._activeThreadId) { this.shadow.innerHTML = ''; return; } const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }; const timeAgo = (ts: number) => { const diff = Date.now() - ts; if (diff < 60000) return 'just now'; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; return `${Math.floor(diff / 86400000)}d ago`; }; const formatDate = (ts: number) => new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); const { authorId: currentUserId, authorName: currentUserName } = this.getSessionInfo(); const initials = (name: string) => name.split(/\s+/).map(w => w[0] || '').join('').slice(0, 2).toUpperCase() || '?'; const avatarColor = (id: string) => { let h = 0; for (let i = 0; i < id.length; i++) h = id.charCodeAt(i) + ((h << 5) - h); return `hsl(${Math.abs(h) % 360}, 55%, 55%)`; }; this.shadow.innerHTML = `
Comments (${threads.filter(t => !t.resolved).length})
${threads.map(thread => { const reactions = thread.reactions || {}; const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0); const isActive = thread.id === this._activeThreadId; const hasMessages = thread.messages.length > 0; const firstMsg = thread.messages[0]; const authorName = firstMsg?.authorName || currentUserName; const authorId = firstMsg?.authorId || currentUserId; return `
${initials(authorName)}
${esc(authorName)} ${timeAgo(thread.createdAt)}
${hasMessages ? `
${esc(firstMsg.text)}
${thread.messages.slice(1).map(msg => `
${initials(msg.authorName)}
${esc(msg.authorName)} ${timeAgo(msg.createdAt)}
${esc(msg.text)}
`).join('')} ` : `
`} ${hasMessages && reactionEntries.length > 0 ? `
${reactionEntries.map(([emoji, users]) => ` `).join('')}
${REACTION_EMOJIS.map(e => ``).join('')}
` : ''} ${hasMessages && thread.reminderAt ? `
⏰ ${formatDate(thread.reminderAt)}
` : ''} ${hasMessages ? `
` : ''}
${hasMessages ? ` ` : ''}
`; }).join('')}
`; this.wireEvents(); // Auto-focus new comment textarea requestAnimationFrame(() => { const newInput = this.shadow.querySelector('.new-comment-input') as HTMLTextAreaElement; if (newInput) newInput.focus(); }); } private wireEvents() { // Click thread to scroll editor to it this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => { el.addEventListener('click', (e) => { // Don't handle clicks on inputs/buttons/textareas const target = e.target as HTMLElement; if (target.closest('input, textarea, button')) return; const threadId = (el as HTMLElement).dataset.thread; if (!threadId || !this._editor) return; this._activeThreadId = threadId; this._editor.state.doc.descendants((node, pos) => { if (!node.isText) return; const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId); if (mark) { this._editor!.commands.setTextSelection(pos); this._editor!.commands.scrollIntoView(); return false; } }); this.render(); }); }); // New comment submit (thread with no messages yet) this.shadow.querySelectorAll('[data-submit-new]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const threadId = (btn as HTMLElement).dataset.submitNew; if (!threadId) return; const textarea = this.shadow.querySelector(`textarea[data-new-thread="${threadId}"]`) as HTMLTextAreaElement; const text = textarea?.value?.trim(); if (!text) return; this.addReply(threadId, text); }); }); // New comment cancel — delete the empty thread this.shadow.querySelectorAll('[data-cancel-new]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const threadId = (btn as HTMLElement).dataset.cancelNew; if (threadId) this.deleteThread(threadId); }); }); // New comment textarea — Ctrl+Enter to submit, Escape to cancel this.shadow.querySelectorAll('.new-comment-input').forEach(textarea => { textarea.addEventListener('keydown', (e) => { const ke = e as KeyboardEvent; if (ke.key === 'Enter' && (ke.ctrlKey || ke.metaKey)) { e.stopPropagation(); const threadId = (textarea as HTMLTextAreaElement).dataset.newThread; const text = (textarea as HTMLTextAreaElement).value.trim(); if (threadId && text) this.addReply(threadId, text); } else if (ke.key === 'Escape') { e.stopPropagation(); const threadId = (textarea as HTMLTextAreaElement).dataset.newThread; if (threadId) this.deleteThread(threadId); } }); textarea.addEventListener('click', (e) => e.stopPropagation()); }); // Reply this.shadow.querySelectorAll('[data-reply]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const threadId = (btn as HTMLElement).dataset.reply; if (!threadId) return; const input = this.shadow.querySelector(`input[data-thread="${threadId}"]`) as HTMLInputElement; const text = input?.value?.trim(); if (!text) return; this.addReply(threadId, text); input.value = ''; }); }); // Reply on Enter this.shadow.querySelectorAll('.reply-input').forEach(input => { input.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') { e.stopPropagation(); const threadId = (input as HTMLInputElement).dataset.thread; const text = (input as HTMLInputElement).value.trim(); if (threadId && text) { this.addReply(threadId, text); (input as HTMLInputElement).value = ''; } } }); input.addEventListener('click', (e) => e.stopPropagation()); }); // Resolve / re-open this.shadow.querySelectorAll('[data-resolve]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const threadId = (btn as HTMLElement).dataset.resolve; if (threadId) this.toggleResolve(threadId); }); }); // Delete this.shadow.querySelectorAll('[data-delete]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const threadId = (btn as HTMLElement).dataset.delete; if (threadId) this.deleteThread(threadId); }); }); // Reaction pill toggle (existing reaction) this.shadow.querySelectorAll('[data-react-thread]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const el = btn as HTMLElement; this.toggleReaction(el.dataset.reactThread!, el.dataset.reactEmoji!); }); }); // Reaction add "+" button — toggle emoji picker this.shadow.querySelectorAll('[data-react-add]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const threadId = (btn as HTMLElement).dataset.reactAdd!; const picker = this.shadow.querySelector(`[data-picker="${threadId}"]`); if (picker) picker.classList.toggle('open'); }); }); // Emoji picker buttons this.shadow.querySelectorAll('[data-pick-thread]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const el = btn as HTMLElement; this.toggleReaction(el.dataset.pickThread!, el.dataset.pickEmoji!); // Close picker const picker = this.shadow.querySelector(`[data-picker="${el.dataset.pickThread}"]`); if (picker) picker.classList.remove('open'); }); }); // Reminder "set" button this.shadow.querySelectorAll('[data-remind-set]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const threadId = (btn as HTMLElement).dataset.remindSet!; const input = this.shadow.querySelector(`[data-remind-input="${threadId}"]`) as HTMLInputElement; if (input) { input.style.display = input.style.display === 'none' ? 'inline-block' : 'none'; if (input.style.display !== 'none') input.focus(); } }); }); // Reminder date change this.shadow.querySelectorAll('[data-remind-input]').forEach(input => { input.addEventListener('click', (e) => e.stopPropagation()); input.addEventListener('change', (e) => { e.stopPropagation(); const threadId = (input as HTMLInputElement).dataset.remindInput!; const val = (input as HTMLInputElement).value; if (val) this.setReminder(threadId, new Date(val + 'T09:00:00').getTime()); }); }); // Reminder clear this.shadow.querySelectorAll('[data-remind-clear]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const threadId = (btn as HTMLElement).dataset.remindClear!; this.clearReminder(threadId); }); }); } private addReply(threadId: string, text: string) { const { authorName, authorId } = this.getSessionInfo(); const msg: CommentMessage = { id: `m_${Date.now()}`, authorId, authorName, text, createdAt: Date.now(), }; if (this._demoThreads) { const thread = this._demoThreads[threadId]; if (!thread) return; if (!thread.messages) thread.messages = []; thread.messages.push(msg); this.dispatchDemoMutation(); this.render(); return; } if (!this._noteId || !this._subscribedDocId) return; const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; const noteId = this._noteId; runtime.change(this._subscribedDocId as DocumentId, 'Add comment reply', (d: NotebookDoc) => { const item = d.items[noteId]; if (!item?.comments?.[threadId]) return; const thread = item.comments[threadId] as any; if (!thread.messages) thread.messages = []; thread.messages.push(msg); }); this._doc = runtime.get(this._subscribedDocId as DocumentId); this.render(); } private toggleReaction(threadId: string, emoji: string) { const { authorId } = this.getSessionInfo(); if (this._demoThreads) { const thread = this._demoThreads[threadId]; if (!thread) return; if (!thread.reactions) thread.reactions = {}; if (!thread.reactions[emoji]) thread.reactions[emoji] = []; const idx = thread.reactions[emoji].indexOf(authorId); if (idx >= 0) thread.reactions[emoji].splice(idx, 1); else thread.reactions[emoji].push(authorId); this.dispatchDemoMutation(); this.render(); return; } if (!this._noteId || !this._subscribedDocId) return; const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; const noteId = this._noteId; runtime.change(this._subscribedDocId as DocumentId, 'Toggle reaction', (d: NotebookDoc) => { const item = d.items[noteId]; if (!item?.comments?.[threadId]) return; const thread = item.comments[threadId] as any; if (!thread.reactions) thread.reactions = {}; if (!thread.reactions[emoji]) thread.reactions[emoji] = []; const users: string[] = thread.reactions[emoji]; const idx = users.indexOf(authorId); if (idx >= 0) users.splice(idx, 1); else users.push(authorId); }); this._doc = runtime.get(this._subscribedDocId as DocumentId); this.render(); } private async setReminder(threadId: string, reminderAt: number) { // Set reminder on thread let reminderId: string | undefined; // Try creating a reminder via rSchedule API (non-demo only) if (!this.isDemo && this._space) { try { const res = await fetch(`/${this._space}/rschedule/api/reminders`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, body: JSON.stringify({ title: `Comment reminder`, remindAt: new Date(reminderAt).toISOString(), allDay: true, sourceModule: 'rnotes', sourceEntityId: threadId, }), }); if (res.ok) { const data = await res.json(); reminderId = data.id; } } catch {} } if (this._demoThreads) { const thread = this._demoThreads[threadId]; if (thread) { thread.reminderAt = reminderAt; if (reminderId) thread.reminderId = reminderId; } this.dispatchDemoMutation(); this.render(); return; } if (!this._noteId || !this._subscribedDocId) return; const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; const noteId = this._noteId; runtime.change(this._subscribedDocId as DocumentId, 'Set comment reminder', (d: NotebookDoc) => { const item = d.items[noteId]; if (!item?.comments?.[threadId]) return; const thread = item.comments[threadId] as any; thread.reminderAt = reminderAt; if (reminderId) thread.reminderId = reminderId; }); this._doc = runtime.get(this._subscribedDocId as DocumentId); this.render(); } private async clearReminder(threadId: string) { // Get existing reminderId before clearing const threads = this.getThreads(); const thread = threads.find(t => t.id === threadId); const reminderId = thread?.reminderId; // Delete from rSchedule if exists if (reminderId && !this.isDemo && this._space) { try { await fetch(`/${this._space}/rschedule/api/reminders/${reminderId}`, { method: 'DELETE', headers: this.authHeaders(), }); } catch {} } if (this._demoThreads) { const t = this._demoThreads[threadId]; if (t) { delete t.reminderAt; delete t.reminderId; } this.dispatchDemoMutation(); this.render(); return; } if (!this._noteId || !this._subscribedDocId) return; const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; const noteId = this._noteId; runtime.change(this._subscribedDocId as DocumentId, 'Clear comment reminder', (d: NotebookDoc) => { const item = d.items[noteId]; if (!item?.comments?.[threadId]) return; const t = item.comments[threadId] as any; delete t.reminderAt; delete t.reminderId; }); this._doc = runtime.get(this._subscribedDocId as DocumentId); this.render(); } private authHeaders(): Record { try { const s = JSON.parse(localStorage.getItem('encryptid_session') || '{}'); if (s?.accessToken) return { 'Authorization': 'Bearer ' + s.accessToken }; } catch {} return {}; } private toggleResolve(threadId: string) { if (this._demoThreads) { const thread = this._demoThreads[threadId]; if (thread) thread.resolved = !thread.resolved; this.dispatchDemoMutation(); // Update editor mark this.updateEditorResolveMark(threadId, this._demoThreads[threadId]?.resolved ?? false); this.render(); return; } if (!this._noteId || !this._subscribedDocId) return; const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; const noteId = this._noteId; runtime.change(this._subscribedDocId as DocumentId, 'Toggle comment resolve', (d: NotebookDoc) => { const item = d.items[noteId]; if (!item?.comments?.[threadId]) return; (item.comments[threadId] as any).resolved = !(item.comments[threadId] as any).resolved; }); this._doc = runtime.get(this._subscribedDocId as DocumentId); const thread = this._doc?.items?.[this._noteId]?.comments?.[threadId]; if (thread) this.updateEditorResolveMark(threadId, thread.resolved); this.render(); } private updateEditorResolveMark(threadId: string, resolved: boolean) { if (!this._editor) return; this._editor.state.doc.descendants((node, pos) => { if (!node.isText) return; const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId); if (mark) { const { tr } = this._editor!.state; tr.removeMark(pos, pos + node.nodeSize, mark); tr.addMark(pos, pos + node.nodeSize, this._editor!.schema.marks.comment.create({ threadId, resolved }) ); this._editor!.view.dispatch(tr); return false; } }); } private deleteThread(threadId: string) { if (this._demoThreads) { delete this._demoThreads[threadId]; this.dispatchDemoMutation(); this.removeEditorCommentMark(threadId); if (this._activeThreadId === threadId) this._activeThreadId = null; this.render(); return; } if (!this._noteId || !this._subscribedDocId) return; const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; const noteId = this._noteId; runtime.change(this._subscribedDocId as DocumentId, 'Delete comment thread', (d: NotebookDoc) => { const item = d.items[noteId]; if (item?.comments?.[threadId]) { delete (item.comments as any)[threadId]; } }); this._doc = runtime.get(this._subscribedDocId as DocumentId); this.removeEditorCommentMark(threadId); if (this._activeThreadId === threadId) this._activeThreadId = null; this.render(); } private removeEditorCommentMark(threadId: string) { if (!this._editor) return; const { state } = this._editor; const { tr } = state; state.doc.descendants((node, pos) => { if (!node.isText) return; const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId); if (mark) { tr.removeMark(pos, pos + node.nodeSize, mark); } }); if (tr.docChanged) { this._editor.view.dispatch(tr); } } } customElements.define('notes-comment-panel', NotesCommentPanel);