rspace-online/modules/rnotes/components/comment-panel.ts

615 lines
24 KiB
TypeScript

/**
* <notes-comment-panel> — 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<string, string[]>;
reminderAt?: number;
reminderId?: string;
}
interface NotebookDoc {
items: Record<string, {
comments?: Record<string, CommentThread>;
[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<string, CommentThread> | 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<string, CommentThread> | 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<string, CommentThread>)
.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 } = this.getSessionInfo();
this.shadow.innerHTML = `
<style>
:host { display: block; }
.panel { border-left: 1px solid var(--rs-border, #e5e7eb); padding: 12px; font-family: system-ui, sans-serif; font-size: 13px; max-height: 80vh; overflow-y: auto; }
.panel-title { font-weight: 600; font-size: 14px; margin-bottom: 12px; color: var(--rs-text-primary, #111); display: flex; justify-content: space-between; align-items: center; }
.thread { margin-bottom: 16px; padding: 10px; border-radius: 8px; background: var(--rs-bg-surface, #fff); border: 1px solid var(--rs-border-subtle, #f0f0f0); cursor: pointer; transition: border-color 0.15s; }
.thread:hover { border-color: var(--rs-border, #e5e7eb); }
.thread.active { border-color: var(--rs-primary, #3b82f6); }
.thread.resolved { opacity: 0.6; }
.thread-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.thread-author { font-weight: 600; color: var(--rs-text-primary, #111); }
.thread-time { color: var(--rs-text-muted, #999); font-size: 11px; }
.thread-actions { display: flex; gap: 4px; }
.thread-action { padding: 2px 6px; border: none; background: none; color: var(--rs-text-secondary, #666); cursor: pointer; font-size: 11px; border-radius: 4px; }
.thread-action:hover { background: var(--rs-bg-hover, #f5f5f5); }
.message { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); }
.message-author { font-weight: 500; font-size: 12px; color: var(--rs-text-secondary, #666); }
.message-text { margin-top: 2px; color: var(--rs-text-primary, #111); line-height: 1.4; }
.reply-form { margin-top: 8px; display: flex; gap: 6px; }
.reply-input { flex: 1; padding: 6px 8px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 12px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); }
.reply-input:focus { border-color: var(--rs-primary, #3b82f6); outline: none; }
.reply-btn { padding: 6px 10px; border: none; background: var(--rs-primary, #3b82f6); color: #fff; border-radius: 6px; font-size: 12px; cursor: pointer; font-weight: 500; }
.reply-btn:hover { opacity: 0.9; }
/* Reactions */
.reactions-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; align-items: center; }
.reaction-pill { display: inline-flex; align-items: center; gap: 2px; padding: 2px 6px; border-radius: 12px; border: 1px solid var(--rs-border-subtle, #e0e0e0); background: var(--rs-bg-surface, #fff); font-size: 12px; cursor: pointer; transition: all 0.15s; user-select: none; }
.reaction-pill:hover { border-color: var(--rs-primary, #3b82f6); }
.reaction-pill.active { border-color: var(--rs-primary, #3b82f6); background: color-mix(in srgb, var(--rs-primary, #3b82f6) 10%, transparent); }
.reaction-pill .count { font-size: 11px; color: var(--rs-text-secondary, #666); }
.reaction-add { padding: 2px 6px; border-radius: 12px; border: 1px dashed var(--rs-border-subtle, #ddd); background: none; font-size: 12px; cursor: pointer; color: var(--rs-text-muted, #999); }
.reaction-add:hover { border-color: var(--rs-primary, #3b82f6); color: var(--rs-text-primary, #111); }
.emoji-picker { display: none; flex-wrap: wrap; gap: 2px; padding: 4px; background: var(--rs-bg-surface, #fff); border: 1px solid var(--rs-border, #e5e7eb); border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); margin-top: 4px; }
.emoji-picker.open { display: flex; }
.emoji-pick { padding: 4px 6px; border: none; background: none; font-size: 16px; cursor: pointer; border-radius: 4px; }
.emoji-pick:hover { background: var(--rs-bg-hover, #f5f5f5); }
/* Reminders */
.reminder-row { margin-top: 6px; display: flex; align-items: center; gap: 6px; font-size: 12px; }
.reminder-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 12px; background: color-mix(in srgb, var(--rs-warning, #f59e0b) 15%, transparent); color: var(--rs-text-primary, #111); font-size: 11px; }
.reminder-btn { padding: 2px 8px; border: 1px solid var(--rs-border-subtle, #ddd); border-radius: 12px; background: none; font-size: 11px; cursor: pointer; color: var(--rs-text-secondary, #666); }
.reminder-btn:hover { border-color: var(--rs-primary, #3b82f6); }
.reminder-clear { padding: 1px 4px; border: none; background: none; font-size: 10px; cursor: pointer; color: var(--rs-text-muted, #999); }
.reminder-date-input { padding: 2px 6px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 11px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); }
</style>
<div class="panel">
<div class="panel-title">
<span>Comments (${threads.filter(t => !t.resolved).length})</span>
</div>
${threads.map(thread => {
const reactions = thread.reactions || {};
const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0);
return `
<div class="thread ${thread.id === this._activeThreadId ? 'active' : ''} ${thread.resolved ? 'resolved' : ''}" data-thread="${thread.id}">
<div class="thread-header">
<span class="thread-author">${esc(thread.messages[0]?.authorName || 'Anonymous')}</span>
<span class="thread-time">${timeAgo(thread.createdAt)}</span>
</div>
${thread.messages.map(msg => `
<div class="message">
<div class="message-author">${esc(msg.authorName)}</div>
<div class="message-text">${esc(msg.text)}</div>
</div>
`).join('')}
${thread.messages.length === 0 ? '<div class="message"><div class="message-text" style="color: var(--rs-text-muted, #999)">Click to add a comment...</div></div>' : ''}
<div class="reactions-row">
${reactionEntries.map(([emoji, users]) => `
<button class="reaction-pill ${users.includes(currentUserId) ? 'active' : ''}" data-react-thread="${thread.id}" data-react-emoji="${emoji}">${emoji} <span class="count">${users.length}</span></button>
`).join('')}
<button class="reaction-add" data-react-add="${thread.id}">+</button>
</div>
<div class="emoji-picker" data-picker="${thread.id}">
${REACTION_EMOJIS.map(e => `<button class="emoji-pick" data-pick-thread="${thread.id}" data-pick-emoji="${e}">${e}</button>`).join('')}
</div>
<div class="reminder-row">
${thread.reminderAt
? `<span class="reminder-badge">&#9200; ${formatDate(thread.reminderAt)}</span><button class="reminder-clear" data-remind-clear="${thread.id}">&#10005;</button>`
: `<button class="reminder-btn" data-remind-set="${thread.id}">&#9200; Remind me</button>`
}
<input type="date" class="reminder-date-input" data-remind-input="${thread.id}" style="display:none">
</div>
<div class="reply-form">
<input class="reply-input" placeholder="Reply..." data-thread="${thread.id}">
<button class="reply-btn" data-reply="${thread.id}">Reply</button>
</div>
<div class="thread-actions">
<button class="thread-action" data-resolve="${thread.id}">${thread.resolved ? 'Re-open' : 'Resolve'}</button>
<button class="thread-action" data-delete="${thread.id}">Delete</button>
</div>
</div>`;
}).join('')}
</div>
`;
this.wireEvents();
}
private wireEvents() {
// Click thread to scroll editor to it
this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => {
el.addEventListener('click', (e) => {
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();
});
});
// 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<string, string> {
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);