tangled
alpha
login
or
join now
diffdown.com
/
diffdown-app
0
fork
atom
Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
0
fork
atom
overview
issues
10
pulls
pipelines
frontend: add threading and resolution to comments
diffdown.com
2 weeks ago
fb736649
21336635
+153
2 changed files
expand all
collapse all
unified
split
static
css
editor.css
templates
document_edit.html
+21
static/css/editor.css
reviewed
···
445
445
.invite-modal-body {
446
446
padding: 1rem;
447
447
}
448
448
+
449
449
+
/* Comment threading */
450
450
+
.comment-thread-header {
451
451
+
display: flex;
452
452
+
justify-content: space-between;
453
453
+
align-items: center;
454
454
+
padding-bottom: 8px;
455
455
+
border-bottom: 1px solid var(--border-color);
456
456
+
margin-bottom: 8px;
457
457
+
}
458
458
+
459
459
+
.comment-item-reply {
460
460
+
margin-left: 24px;
461
461
+
padding-left: 12px;
462
462
+
border-left: 2px solid var(--border-color);
463
463
+
}
464
464
+
465
465
+
.btn-active {
466
466
+
background-color: var(--success-color);
467
467
+
color: white;
468
468
+
}
+132
templates/document_edit.html
reviewed
···
824
824
editorEl.addEventListener('keyup', onSelectionChange);
825
825
}
826
826
827
827
+
let pendingReplyTo = null; // set when replying to a comment
828
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
837
+
pendingReplyTo = null;
838
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
852
+
pendingReplyTo = null;
853
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
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
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
1047
+
}
1048
1048
+
1049
1049
+
async function replyToComment(comment) {
1050
1050
+
pendingReplyTo = `at://${comment.docOwnerDid}/app.diffdown.comment/${comment.id}`;
1051
1051
+
if (commentTextEl) commentTextEl.placeholder = `Replying to ${comment.authorHandle || comment.author}...`;
1052
1052
+
openCommentForm();
1053
1053
+
}
1054
1054
+
1055
1055
+
async function toggleResolve(threadId, resolved) {
1056
1056
+
try {
1057
1057
+
const body = { resolved };
1058
1058
+
if (ownerDID) body.ownerDID = ownerDID;
1059
1059
+
await fetch(`/api/docs/${rkey}/comments/${threadId}`, {
1060
1060
+
method: 'PATCH',
1061
1061
+
headers: { 'Content-Type': 'application/json' },
1062
1062
+
body: JSON.stringify(body),
1063
1063
+
});
1064
1064
+
loadComments();
1065
1065
+
} catch (e) {
1066
1066
+
console.error('Toggle resolve failed:', e);
1067
1067
+
}
1068
1068
+
}
1069
1069
+
1070
1070
+
function renderCommentThreads(comments) {
1071
1071
+
const container = document.getElementById('comment-threads');
1072
1072
+
if (!container) return;
1073
1073
+
1074
1074
+
if (!comments || comments.length === 0) {
1075
1075
+
container.textContent = '';
1076
1076
+
const empty = document.createElement('p');
1077
1077
+
empty.className = 'comment-empty';
1078
1078
+
empty.textContent = 'No comments yet.';
1079
1079
+
container.appendChild(empty);
1080
1080
+
return;
1081
1081
+
}
1082
1082
+
1083
1083
+
const roots = [];
1084
1084
+
const replies = new Map();
1085
1085
+
for (const c of comments) {
1086
1086
+
if (c.replyTo) {
1087
1087
+
if (!replies.has(c.replyTo)) replies.set(c.replyTo, []);
1088
1088
+
replies.get(c.replyTo).push(c);
1089
1089
+
} else {
1090
1090
+
roots.push(c);
1091
1091
+
}
1092
1092
+
}
1093
1093
+
1094
1094
+
container.textContent = '';
1095
1095
+
for (const root of roots) {
1096
1096
+
const threadId = root.threadId || root.id;
1097
1097
+
const threadReplies = replies.get(root.id) || [];
1098
1098
+
const threadEl = createCommentThreadElement(threadId, root, threadReplies);
1099
1099
+
container.appendChild(threadEl);
1100
1100
+
}
1101
1101
+
}
1102
1102
+
1103
1103
+
function createCommentThreadElement(threadId, rootComment, replies) {
1104
1104
+
const isDetached = !findCommentMark(threadId);
1105
1105
+
const threadDiv = document.createElement('div');
1106
1106
+
threadDiv.className = 'comment-thread' + (isDetached ? ' comment-thread-detached' : '');
1107
1107
+
threadDiv.dataset.thread = threadId;
1108
1108
+
1109
1109
+
const headerDiv = document.createElement('div');
1110
1110
+
headerDiv.className = 'comment-thread-header';
1111
1111
+
1112
1112
+
const labelDiv = document.createElement('div');
1113
1113
+
labelDiv.className = 'comment-thread-label';
1114
1114
+
const labelText = rootComment.quotedText
1115
1115
+
? '\u201c' + (rootComment.quotedText.length > 40 ? rootComment.quotedText.slice(0, 40) + '\u2026' : rootComment.quotedText) + '\u201d'
1116
1116
+
: '(no anchor)';
1117
1117
+
labelDiv.textContent = labelText;
1118
1118
+
if (isDetached) {
1119
1119
+
const warn = document.createElement('span');
1120
1120
+
warn.title = 'Text was deleted';
1121
1121
+
warn.textContent = ' \u26a0';
1122
1122
+
labelDiv.appendChild(warn);
1123
1123
+
}
1124
1124
+
headerDiv.appendChild(labelDiv);
1125
1125
+
1126
1126
+
const resolveBtn = document.createElement('button');
1127
1127
+
resolveBtn.className = 'btn btn-sm ' + (rootComment.resolved ? 'btn-active' : 'btn-outline');
1128
1128
+
resolveBtn.textContent = rootComment.resolved ? 'Resolved' : 'Resolve';
1129
1129
+
resolveBtn.onclick = () => toggleResolve(threadId, !rootComment.resolved);
1130
1130
+
headerDiv.appendChild(resolveBtn);
1131
1131
+
1132
1132
+
threadDiv.appendChild(headerDiv);
1133
1133
+
1134
1134
+
threadDiv.appendChild(createCommentItem(rootComment, true));
1135
1135
+
1136
1136
+
for (const reply of replies) {
1137
1137
+
threadDiv.appendChild(createCommentItem(reply, false));
1138
1138
+
}
1139
1139
+
1140
1140
+
return threadDiv;
1141
1141
+
}
1142
1142
+
1143
1143
+
function createCommentItem(comment, isRoot) {
1144
1144
+
const item = document.createElement('div');
1145
1145
+
item.className = 'comment-item' + (isRoot ? '' : ' comment-item-reply');
1146
1146
+
1147
1147
+
const author = document.createElement('div');
1148
1148
+
author.className = 'comment-author';
1149
1149
+
author.textContent = comment.authorHandle || comment.author;
1150
1150
+
1151
1151
+
const body = document.createElement('div');
1152
1152
+
body.className = 'comment-text';
1153
1153
+
body.textContent = comment.text;
1154
1154
+
1155
1155
+
const time = document.createElement('div');
1156
1156
+
time.className = 'comment-time';
1157
1157
+
time.textContent = formatTime(comment.createdAt);
1158
1158
+
1159
1159
+
if (isRoot) {
1160
1160
+
const replyBtn = document.createElement('button');
1161
1161
+
replyBtn.className = 'btn btn-sm btn-link';
1162
1162
+
replyBtn.textContent = 'Reply';
1163
1163
+
replyBtn.onclick = () => replyToComment(comment);
1164
1164
+
item.appendChild(replyBtn);
1165
1165
+
}
1166
1166
+
1167
1167
+
item.appendChild(author);
1168
1168
+
item.appendChild(body);
1169
1169
+
item.appendChild(time);
1170
1170
+
return item;
1039
1171
}
1040
1172
1041
1173
async function loadComments() {