PageSourceSearch

https://seattleflow.org/js/gf-comments.js?v=G-oohWRPtRb3OkNMXzepUV8jLFiAeieDHblLPi9ynOQ

js seattleflow.org collected 2026-10-04 21:12:56 UTC 16,496 bytes, 356 lines download raw bytes

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.