Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

frontend: add threading and resolution to comments

+153
+21
static/css/editor.css
··· 445 445 .invite-modal-body { 446 446 padding: 1rem; 447 447 } 448 + 449 + /* Comment threading */ 450 + .comment-thread-header { 451 + display: flex; 452 + justify-content: space-between; 453 + align-items: center; 454 + padding-bottom: 8px; 455 + border-bottom: 1px solid var(--border-color); 456 + margin-bottom: 8px; 457 + } 458 + 459 + .comment-item-reply { 460 + margin-left: 24px; 461 + padding-left: 12px; 462 + border-left: 2px solid var(--border-color); 463 + } 464 + 465 + .btn-active { 466 + background-color: var(--success-color); 467 + color: white; 468 + }
+132
templates/document_edit.html
··· 824 824 editorEl.addEventListener('keyup', onSelectionChange); 825 825 } 826 826 827 + let pendingReplyTo = null; // set when replying to a comment 828 + 827 829 window.openCommentForm = function openCommentForm() { 828 830 if (!commentBtn || !commentForm || !milkdownEditor) return; 829 831 try { ··· 832 834 if (selection.empty) return; 833 835 pendingCommentRange = { from: selection.from, to: selection.to, 834 836 quotedText: doc.textBetween(selection.from, selection.to, ' ') }; 837 + pendingReplyTo = null; 838 + commentTextEl.placeholder = 'Add a comment...'; 835 839 const rect = commentBtn.getBoundingClientRect(); 836 840 commentForm.style.top = (rect.bottom + window.scrollY + 4) + 'px'; 837 841 commentForm.style.left = Math.max(8, rect.left + window.scrollX) + 'px'; ··· 845 849 if (commentForm) commentForm.style.display = 'none'; 846 850 if (commentBtn) commentBtn.style.display = 'none'; 847 851 pendingCommentRange = null; 852 + pendingReplyTo = null; 853 + if (commentTextEl) commentTextEl.placeholder = 'Add a comment...'; 848 854 } 849 855 850 856 window.submitComment = async function submitComment() { ··· 854 860 const { from, to, quotedText } = pendingCommentRange; 855 861 try { 856 862 const body = { quotedText, text }; 863 + if (pendingReplyTo) body.replyTo = pendingReplyTo; 857 864 if (ownerDID) body.ownerDID = ownerDID; 858 865 const resp = await fetch(`/api/docs/${rkey}/comments`, { 859 866 method: 'POST', ··· 871 878 } 872 879 } 873 880 closeCommentForm(); 881 + pendingReplyTo = null; 874 882 loadComments(); 875 883 } catch (e) { console.error('Comment post failed:', e); } 876 884 } ··· 1036 1044 function formatTime(ts) { 1037 1045 if (!ts) return ''; 1038 1046 try { return new Date(ts).toLocaleString(); } catch { return ts; } 1047 + } 1048 + 1049 + async function replyToComment(comment) { 1050 + pendingReplyTo = `at://${comment.docOwnerDid}/app.diffdown.comment/${comment.id}`; 1051 + if (commentTextEl) commentTextEl.placeholder = `Replying to ${comment.authorHandle || comment.author}...`; 1052 + openCommentForm(); 1053 + } 1054 + 1055 + async function toggleResolve(threadId, resolved) { 1056 + try { 1057 + const body = { resolved }; 1058 + if (ownerDID) body.ownerDID = ownerDID; 1059 + await fetch(`/api/docs/${rkey}/comments/${threadId}`, { 1060 + method: 'PATCH', 1061 + headers: { 'Content-Type': 'application/json' }, 1062 + body: JSON.stringify(body), 1063 + }); 1064 + loadComments(); 1065 + } catch (e) { 1066 + console.error('Toggle resolve failed:', e); 1067 + } 1068 + } 1069 + 1070 + function renderCommentThreads(comments) { 1071 + const container = document.getElementById('comment-threads'); 1072 + if (!container) return; 1073 + 1074 + if (!comments || comments.length === 0) { 1075 + container.textContent = ''; 1076 + const empty = document.createElement('p'); 1077 + empty.className = 'comment-empty'; 1078 + empty.textContent = 'No comments yet.'; 1079 + container.appendChild(empty); 1080 + return; 1081 + } 1082 + 1083 + const roots = []; 1084 + const replies = new Map(); 1085 + for (const c of comments) { 1086 + if (c.replyTo) { 1087 + if (!replies.has(c.replyTo)) replies.set(c.replyTo, []); 1088 + replies.get(c.replyTo).push(c); 1089 + } else { 1090 + roots.push(c); 1091 + } 1092 + } 1093 + 1094 + container.textContent = ''; 1095 + for (const root of roots) { 1096 + const threadId = root.threadId || root.id; 1097 + const threadReplies = replies.get(root.id) || []; 1098 + const threadEl = createCommentThreadElement(threadId, root, threadReplies); 1099 + container.appendChild(threadEl); 1100 + } 1101 + } 1102 + 1103 + function createCommentThreadElement(threadId, rootComment, replies) { 1104 + const isDetached = !findCommentMark(threadId); 1105 + const threadDiv = document.createElement('div'); 1106 + threadDiv.className = 'comment-thread' + (isDetached ? ' comment-thread-detached' : ''); 1107 + threadDiv.dataset.thread = threadId; 1108 + 1109 + const headerDiv = document.createElement('div'); 1110 + headerDiv.className = 'comment-thread-header'; 1111 + 1112 + const labelDiv = document.createElement('div'); 1113 + labelDiv.className = 'comment-thread-label'; 1114 + const labelText = rootComment.quotedText 1115 + ? '\u201c' + (rootComment.quotedText.length > 40 ? rootComment.quotedText.slice(0, 40) + '\u2026' : rootComment.quotedText) + '\u201d' 1116 + : '(no anchor)'; 1117 + labelDiv.textContent = labelText; 1118 + if (isDetached) { 1119 + const warn = document.createElement('span'); 1120 + warn.title = 'Text was deleted'; 1121 + warn.textContent = ' \u26a0'; 1122 + labelDiv.appendChild(warn); 1123 + } 1124 + headerDiv.appendChild(labelDiv); 1125 + 1126 + const resolveBtn = document.createElement('button'); 1127 + resolveBtn.className = 'btn btn-sm ' + (rootComment.resolved ? 'btn-active' : 'btn-outline'); 1128 + resolveBtn.textContent = rootComment.resolved ? 'Resolved' : 'Resolve'; 1129 + resolveBtn.onclick = () => toggleResolve(threadId, !rootComment.resolved); 1130 + headerDiv.appendChild(resolveBtn); 1131 + 1132 + threadDiv.appendChild(headerDiv); 1133 + 1134 + threadDiv.appendChild(createCommentItem(rootComment, true)); 1135 + 1136 + for (const reply of replies) { 1137 + threadDiv.appendChild(createCommentItem(reply, false)); 1138 + } 1139 + 1140 + return threadDiv; 1141 + } 1142 + 1143 + function createCommentItem(comment, isRoot) { 1144 + const item = document.createElement('div'); 1145 + item.className = 'comment-item' + (isRoot ? '' : ' comment-item-reply'); 1146 + 1147 + const author = document.createElement('div'); 1148 + author.className = 'comment-author'; 1149 + author.textContent = comment.authorHandle || comment.author; 1150 + 1151 + const body = document.createElement('div'); 1152 + body.className = 'comment-text'; 1153 + body.textContent = comment.text; 1154 + 1155 + const time = document.createElement('div'); 1156 + time.className = 'comment-time'; 1157 + time.textContent = formatTime(comment.createdAt); 1158 + 1159 + if (isRoot) { 1160 + const replyBtn = document.createElement('button'); 1161 + replyBtn.className = 'btn btn-sm btn-link'; 1162 + replyBtn.textContent = 'Reply'; 1163 + replyBtn.onclick = () => replyToComment(comment); 1164 + item.appendChild(replyBtn); 1165 + } 1166 + 1167 + item.appendChild(author); 1168 + item.appendChild(body); 1169 + item.appendChild(time); 1170 + return item; 1039 1171 } 1040 1172 1041 1173 async function loadComments() {