1/** 2 * gf-comments.js â first-party comment thread UI glue. 3 * The server owns all thread/comment markup (rendered by _CommentThread / _Comment); this script only 4 * submits writes via gf.api(), then swaps in the authoritative server re-render. No comment markup is built 5 * here. CSP-safe: served with a nonce, no inline JS. 6 */ 7(function () { 8 "use strict"; 9 10 var DRAFT_PREFIX = "gf-comment-draft-"; 11 12 // ---- helpers ------------------------------------------------------- 13 14 function threadOf(el) { return el.closest("[data-comment-thread]"); } 15 function targetOf(thread) { 16 return { kind: thread.getAttribute("data-target-kind"), id: thread.getAttribute("data-target-id") }; 17 } 18 19 // Visible length mirrors the server: bold/italic markers don't count toward the reader-visible total. 20 function visibleLength(text) { 21 return text.replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1").length; 22 } 23 24 function setStatus(composer, message) { 25 var status = composer.querySelector("[data-comment-status]"); 26 if (!status) return; 27 if (message) { status.textContent = message; status.hidden = false; } 28 else { status.textContent = ""; status.hidden = true; } 29 } 30 31 function updateCounter(composer) { 32 var input = composer.querySelector("[data-comment-input]"); 33 var counter = composer.querySelector("[data-comment-counter]"); 34 var submit = composer.querySelector("[data-comment-submit]"); 35 var thread = threadOf(composer); 36 var max = thread ? parseInt(thread.getAttribute("data-comment-max"), 10) || 1250 : 1250; 37 var used = visibleLength(input.value); 38 var remaining = max - used; 39 if (counter) counter.textContent = remaining <= 200 ? String(remaining) : ""; 40 if (submit) submit.disabled = remaining < 0 || visibleLength(input.value.trim()) === 0; 41 if (counter) counter.classList.toggle("is-over", remaining < 0); 42 } 43 44 function refreshThread(thread) { 45 if (!thread || !window.htmx) return Promise.resolve(); 46 var t = targetOf(thread); 47 var url = window.location.pathname + "?handler=Comments&kind=" + 48 encodeURIComponent(t.kind) + "&id=" + encodeURIComponent(t.id); 49 return window.htmx.ajax("GET", url, { target: thread, swap: "outerHTML" }); 50 } 51 52 function bumpCount(thread, delta) { 53 var t = targetOf(thread); 54 var row = document.querySelector('[data-item-actions][data-target-kind="' + window.CSS.escape(t.kind) + 55 '"][data-target-id="' + window.CSS.escape(t.id) + '"]'); 56 if (!row) return; 57 var el = row.querySelector("[data-comment-count]"); 58 if (!el) return; 59 var current = parseInt((el.textContent || "").replace(/[^\d]/g, ""), 10) || 0; 60 var next = Math.max(0, current + delta); 61 el.textContent = next > 0 ? String(next) : ""; 62 } 63 64 // ---- paste normalize ---------------------------------------------- 65 66 function serialize(node) { 67 if (node.nodeType === 3) return node.nodeValue; 68 if (node.nodeType !== 1) return ""; 69 var tag = node.tagName.toLowerCase(); 70 var inner = ""; 71 node.childNodes.forEach(function (c) { inner += serialize(c); }); 72 if (tag === "strong" || tag === "b") return inner ? "**" + inner + "**" : ""; 73 if (tag === "em" || tag === "i") return inner ? "*" + inner + "*" : ""; 74 if (tag === "a") { 75 var href = node.getAttribute("href") || ""; 76 return /^https?:/i.test(href) ? href : inner; 77 } 78 if (tag === "br") return "\n"; 79 if (tag === "p" || tag === "div" || tag === "li") return inner + "\n"; 80 return inner; 81 } 82 83 function htmlToMarkers(html) { 84 var doc = new DOMParser().parseFromString(html, "text/html"); 85 return serialize(doc.body).replace(/\n{3,}/g, "\n\n").trim(); 86 } 87 88 function insertAtCursor(input, text) { 89 var start = input.selectionStart, end = input.selectionEnd; 90 input.value = input.value.slice(0, start) + text + input.value.slice(end); 91 var pos = start + text.length; 92 input.setSelectionRange(pos, pos); 93 } 94 95 // ---- composer submit ---------------------------------------------- 96 97 function submitComposer(composer) { 98 var thread = threadOf(composer); 99 if (!thread) return; 100 var input = composer.querySelector("[data-comment-input]"); 101 var body = input.value.trim(); 102 if (!body) return; 103 var t = targetOf(thread); 104 105 if (!gf.isSignedIn()) { 106 var key = t.kind + ":" + t.id; 107 try { sessionStorage.setItem(DRAFT_PREFIX + key, input.value); } catch (e) { /* private mode */ } 108 var ret = window.location.pathname + window.location.search; 109 ret += (ret.indexOf("?") >= 0 ? "&" : "?") + "pending-comment=" + encodeURIComponent(key); 110 ret += "#conversation"; 111 window.location.href = "/signin?returnUrl=" + encodeURIComponent(ret); 112 return; 113 } 114 115 var parentId = composer.getAttribute("data-parent-id"); 116 var payload = { targetKind: t.kind, targetId: Number(t.id), body: body }; 117 if (parentId) payload.parentCommentId = Number(parentId); 118 119 setStatus(composer, ""); 120 gf.api("/v1/comments", { method: "POST", body: JSON.stringify(payload) }).then(function (res) {
121 if (!res.ok) { setStatus(composer, apiError(res)); return; } 122 input.value = ""; 123 clearReply(composer); 124 bumpCount(thread, 1); 125 refreshThread(thread); 126 }); 127 } 128 129 function apiError(res) { 130 return (res.error && res.error.message) || "Something went wrong. Please try again."; 131 } 132 133 // ---- reply --------------------------------------------------------- 134 135 function startReply(button) { 136 var thread = threadOf(button); 137 var composer = thread && thread.querySelector("[data-comment-composer]"); 138 if (!composer) return; 139 composer.setAttribute("data-parent-id", button.getAttribute("data-parent-id")); 140 var name = button.getAttribute("data-reply-name") || ""; 141 var ctx = composer.querySelector("[data-comment-reply-context]"); 142 var nameEl = composer.querySelector("[data-comment-reply-name]"); 143 if (nameEl) nameEl.textContent = name; 144 if (ctx) ctx.hidden = false; 145 var input = composer.querySelector("[data-comment-input]"); 146 if (input) { 147 var prefix = name ? "@" + name + " " : ""; 148 if (input.value.indexOf(prefix) !== 0) input.value = prefix + input.value; 149 input.focus(); 150 input.setSelectionRange(input.value.length, input.value.length); 151 } 152 updateCounter(composer); 153 } 154 155 function clearReply(composer) { 156 composer.removeAttribute("data-parent-id"); 157 var ctx = composer.querySelector("[data-comment-reply-context]"); 158 if (ctx) ctx.hidden = true; 159 } 160 161 // ---- edit / delete / report / moderate ----------------------------- 162 163 function startEdit(button) { 164 var row = button.closest("[data-comment-id]"); 165 if (!row || row.querySelector("[data-comment-edit-box]")) return; 166 var id = row.getAttribute("data-comment-id"); 167 var bodyEl = row.querySelector(".comment__body"); 168 var box = document.createElement("div"); 169 box.setAttribute("data-comment-edit-box", ""); 170 box.className = "comment-composer comment-composer--inline"; 171 var area = document.createElement("textarea"); 172 area.className = "comment-composer__input"; 173 area.value = button.getAttribute("data-body") || ""; 174 box.appendChild(area); 175 var save = document.createElement("button"); 176 save.type = "button"; save.className = "comment-composer__submit"; save.textContent = "Save"; 177 save.addEventListener("click", function () { 178 var value = area.value.trim(); 179 if (!value) return; 180 gf.api("/v1/comments/" + id, { method: "POST", body: JSON.stringify({ body: value }) }).then(function (res) { 181 if (!res.ok) { window.alert(apiError(res)); return; } 182 refreshThread(threadOf(row)); 183 }); 184 }); 185 var cancel = document.createElement("button"); 186 cancel.type = "button"; cancel.className = "comment__act"; cancel.textContent = "Cancel"; 187 cancel.addEventListener("click", function () { box.remove(); if (bodyEl) bodyEl.hidden = false; }); 188 box.appendChild(save); box.appendChild(cancel); 189 if (bodyEl) { bodyEl.hidden = true; bodyEl.parentNode.insertBefore(box, bodyEl.nextSibling); } 190 area.focus(); 191 } 192 193 function deleteComment(button) { 194 var row = button.closest("[data-comment-id]"); 195 if (!row || !window.confirm("Delete this comment?")) return; 196 var id = row.getAttribute("data-comment-id"); 197 gf.api("/v1/comments/" + id, { method: "DELETE" }).then(function (res) { 198 if (!res.ok) { window.alert(apiError(res)); return; } 199 var thread = threadOf(row); 200 bumpCount(thread, -1); 201 refreshThread(thread); 202 }); 203 } 204 205 var REASONS = [["spam", "Spam"], ["abusive", "Abusive"], ["offTopic", "Off-topic"], ["other", "Other"]]; 206 207 function startReport(button) { 208 var row = button.closest("[data-comment-id]"); 209 if (!row) return; 210 var id = row.getAttribute("data-comment-id"); 211 var menu = document.createElement("span"); 212 menu.className = "comment__report-menu"; 213 REASONS.forEach(function (r) { 214 var b = document.createElement("button"); 215 b.type = "button"; b.className = "comment__act"; b.textContent = r[1]; 216 b.addEventListener("click", function () { 217 gf.api("/v1/comments/" + id + "/reports", { method: "POST", body: JSON.stringify({ reason: r[0] }) }) 218 .then(function (res) { 219 menu.replaceWith(document.createTextNode(res.ok ? "Reported" : apiError(res)
219)); 220 }); 221 }); 222 menu.appendChild(b); 223 }); 224 button.replaceWith(menu); 225 } 226 227 // Comment on the item's own page: the composer is already on screen, so it takes focus rather than 228 // the reader fetching the page they are reading. 229 function focusComposer() { 230 var input = document.querySelector("[data-comment-thread] [data-comment-input]"); 231 if (input) input.focus(); 232 } 233 234 function moderate(button) { 235 var row = button.closest("[data-comment-id]"); 236 if (!row) return; 237 var id = row.getAttribute("data-comment-id"); 238 var status = button.getAttribute("data-status"); 239 gf.api("/v1/admin/comments/" + id, { method: "POST", body: JSON.stringify({ status: status }) }).then(function (res) { 240 if (!res.ok) { window.alert(apiError(res)); return; } 241 refreshThread(threadOf(row)); 242 }); 243 } 244 245 function setLinkTrust(button) { 246 var accountId = button.getAttribute("data-account"); 247 var linkTrust = button.getAttribute("data-linktrust"); 248 gf.api("/v1/admin/accounts/" + accountId, { method: "POST", body: JSON.stringify({ linkTrust: linkTrust }) }) 249 .then(function (res) { 250 if (!res.ok) { window.alert(apiError(res)); return; } 251 refreshThread(threadOf(button)); 252 }); 253 } 254 255 function showVersions(button) { 256 var id = button.getAttribute("data-comment-versions"); 257 var modal = document.getElementById("comment-versions-modal"); 258 if (!modal || !window.htmx) return; 259 var body = modal.querySelector("[data-comment-versions-body]");
260 var url = window.location.pathname + "?handler=CommentVersions&id=" + encodeURIComponent(id); 261 window.htmx.ajax("GET", url, { target: body, swap: "innerHTML" }).then(function () { 262 if (window.bootstrap && window.bootstrap.Modal) { 263 window.bootstrap.Modal.getOrCreateInstance(modal).show(); 264 } 265 }); 266 } 267 268 // ---- event wiring -------------------------------------------------- 269 270 document.addEventListener("click", function (event) { 271 var el = event.target.closest("button, a"); 272 if (!el) return; 273 if (el.hasAttribute("data-comment-submit")) { event.preventDefault(); submitComposer(el.closest("[data-comment-composer]")); } 274 else if (el.hasAttribute("data-comment-reply")) { event.preventDefault(); startReply(el); } 275 else if (el.hasAttribute("data-comment-reply-cancel")) { event.preventDefault(); clearReply(el.closest("[data-comment-composer]")); } 276 else if (el.hasAttribute("data-comment-edit")) { event.preventDefault(); startEdit(el); } 277 else if (el.hasAttribute("data-comment-delete")) { event.preventDefault(); deleteComment(el); } 278 else if (el.hasAttribute("data-comment-report")) { event.preventDefault(); startReport(el); } 279 else if (el.hasAttribute("data-comment-moderate")) { event.preventDefault(); moderate(el); } 280 else if (el.hasAttribute("data-comment-linktrust")) { event.preventDefault(); setLinkTrust(el); } 281 else if (el.hasAttribute("data-comment-versions")) { event.preventDefault(); showVersions(el); } 282 else if (el.hasAttribute("data-comment-focus")) { event.preventDefault(); focusComposer(); } 283 }); 284 285 document.addEventListener("input", function (event) { 286 if (event.target.hasAttribute("data-comment-input")) { 287 var composer = event.target.closest("[data-comment-composer]"); 288 if (composer && composer.querySelector("[data-comment-submit]")) updateCounter(composer); 289 } 290 }); 291 292 document.addEventListener("paste", function (event) { 293 var input = event.target; 294 if (!input.hasAttribute || !input.hasAttribute("data-comment-input")) return; 295 var data = event.clipboardData; 296 if (!data) return; 297 var html = data.getData("text/html"); 298 var text = html ? htmlToMarkers(html) : data.getData("text/plain"); 299 if (text == null) return; 300 event.preventDefault(); 301 insertAtCursor(input, text); 302 var composer = input.closest("[data-comment-composer]"); 303 if (composer) updateCounter(composer); 304 }); 305 306 document.body.addEventListener("htmx:afterSwap", function (event) { 307 var thread = event.target; 308 if (!thread.matches || !thread.matches("[data-comment-thread]")) return; 309 var composer = thread.querySelector("[data-comment-composer]"); 310 if (composer) updateCounter(composer); 311 restoreDraft(thread); 312 }); 313 314 // ---- draft / pending-comment round-trip --------------------------- 315 316 function restoreDraft(thread) { 317 var t = targetOf(thread); 318 var key = DRAFT_PREFIX + t.kind + ":" + t.id; 319 var draft; 320 try { draft = sessionStorage.getItem(key); } catch (e) { return; } 321 if (!draft) return; 322 var input = thread.querySelector("[data-comment-input]"); 323 if (input) { input.value = draft; var c = input.closest("[data-comment-composer]"); if (c) updateCounter(c); } 324 try { sessionStorage.removeItem(key); } catch (e) { /* ignore */ } 325 } 326 327 // The marker only survived the sign-in round trip; initThreads restores the draft it stood for. 328 function clearPendingCommentMarker() { 329 var params = new URLSearchParams(window.location.search); 330 if (!params.get("pending-comment")) return; 331 params.delete("pending-comment"); 332 var clean = window.location.pathname + (params.toString() ? "?" + params.toString() : "") + window.location.hash; 333 window.history.replaceState(null, "", clean); 334 } 335 336 function initThreads() { 337 document.querySelectorAll("[data-comment-thread]").forEach(function (thread) { 338 var composer = thread.querySelector("[data-comment-composer]"); 339 if (composer) updateCounter(composer); 340 restoreDraft(thread); 341 }); 342 } 343
344 function init() { 345 clearPendingCommentMarker(); 346 initThreads(); 347 // An inbound link straight to the conversation (rather than a same-page Comment click) still owes the reader a focused box. 348 if (window.location.hash === "#conversation") focusComposer(); 349 } 350 351 if (document.readyState === "loading") { 352 document.addEventListener("DOMContentLoaded", init); 353 } else { 354 init(); 355 } 356})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.