/* Client-side behaviour. Everything here is progressive: the application is server-rendered and works without this file, apart from the streaming reply, which is htmx's SSE extension rather than anything hand-written below. */ (function () { "use strict"; var THEME_KEY = "lembas-theme"; var THEMES = ["moria", "shire"]; /* --- Theme ------------------------------------------------------------- Stored locally so the choice applies instantly and survives being signed out, and mirrored to the server so it follows the user to another device. The server call is best-effort: a failure must not undo the local switch. */ function currentTheme() { return document.documentElement.dataset.theme || THEMES[0]; } function applyTheme(name) { if (THEMES.indexOf(name) === -1) return; document.documentElement.dataset.theme = name; try { localStorage.setItem(THEME_KEY, name); } catch (e) { /* private mode */ } document.querySelectorAll("[data-theme-toggle]").forEach(function (el) { el.setAttribute("aria-label", name === "moria" ? "Switch to Shire (light)" : "Switch to Moria (dark)"); }); if (document.body.dataset.authenticated === "true") { fetch("/api/preferences/theme", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ theme: name }) }).catch(function () { /* preference is already applied locally */ }); } } function toggleTheme() { applyTheme(currentTheme() === "moria" ? "shire" : "moria"); } /* --- Textarea autosize ------------------------------------------------- Grows the composer with its content up to a cap, after which it scrolls. */ function autosize(el) { if (!el) return; var max = parseInt(el.dataset.maxHeight || "320", 10); el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, max) + "px"; el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden"; } /* --- Copy -------------------------------------------------------------- Falls back to a hidden textarea because navigator.clipboard is unavailable on pages served over plain http, which self-hosted installs often are. */ function copyText(text, trigger) { function done() { if (!trigger) return; var original = trigger.getAttribute("aria-label"); trigger.classList.add("is-copied"); trigger.setAttribute("aria-label", "Copied"); setTimeout(function () { trigger.classList.remove("is-copied"); if (original) trigger.setAttribute("aria-label", original); }, 1400); } if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(done).catch(function () {}); return; } var scratch = document.createElement("textarea"); scratch.value = text; scratch.setAttribute("readonly", ""); scratch.style.position = "fixed"; scratch.style.opacity = "0"; document.body.appendChild(scratch); scratch.select(); try { document.execCommand("copy"); done(); } catch (e) { /* nothing to do */ } document.body.removeChild(scratch); } /* --- Thread scrolling -------------------------------------------------- Only auto-scrolls when the reader is already near the bottom, so scrolling up to re-read something is not yanked away by an incoming token. */ var STICK_THRESHOLD = 120; function isNearBottom(el) { return el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD; } function scrollThread(force) { var thread = document.getElementById("thread-scroll"); if (!thread) return; if (force || isNearBottom(thread)) { thread.scrollTop = thread.scrollHeight; } } /* --- Attachments ------------------------------------------------------- Files are uploaded one at a time as soon as they are chosen, dropped or pasted, rather than all at once when the message is sent. The chip (or the rejection) then appears immediately, and a large file cannot make the send button appear to hang. */ function uploadFiles(fileList) { var input = document.getElementById("file-input"); var target = document.getElementById("attachments"); if (!input || !target || !fileList || !fileList.length) return; var url = input.dataset.uploadUrl; Array.prototype.forEach.call(fileList, function (file) { var body = new FormData(); body.append("file", file, file.name); fetch(url, { method: "POST", body: body, credentials: "same-origin" }) .then(function (response) { return response.text(); }) .then(function (html) { target.insertAdjacentHTML("beforeend", html); // The chip's remove button is htmx-driven, so the new markup has to // be announced or its attributes are inert. if (window.htmx) window.htmx.process(target.lastElementChild); }) .catch(function () { target.insertAdjacentHTML( "beforeend", '