segment.
var LETM_CAT_COLORS = {
announcements: "#e06c75",
meta: "#8b93a1",
general: "#8b93a1",
offers: "#60a96d",
"shared-hosting-offers": "#61afef",
"industry-news": "#56b6c2",
outages: "#e5c07b",
domains: "#c678dd",
"giveaways-freebies": "#60a96d",
help: "#e5c07b",
news: "#56b6c2",
reviews: "#f0883e",
requests: "#a78bfa",
"service-transfers": "#c678dd",
tutorial: "#61afef",
offtopic: "#8b93a1",
};
// --- Userscript Settings (persisted via GM storage) ---
var LET_DEFAULTS = {
titleFontSize: 13,
metaFontSize: 11,
userMonitorInterval: 30,
userMonitorAutoOn: false,
// Empty by default — add users in Userscript Settings › Monitored Users.
monitoredUsers: [],
hideSidebar: false,
pushEnabled: false,
pushoverUser: "",
pushoverToken: "",
autoRefreshOn: false,
autoRefreshSec: 120,
readingPane: false,
ignoredUsers: [],
ignoredText: "bla bla bla",
catNotifOffers: false,
catNotifTransfers: false,
catNotifIntervalSec: 300,
catNotifLastSeen: {},
postCounter: true,
theme: "dark",
accent: LETM_ACCENTS[0],
sidebarMode: "drawer", // column | drawer | hidden
compact: false,
};
var LET_STORAGE_KEY = "let-modern-settings";
var LET_LAST_SEEN_KEY = "let-user-monitor-last-seen";
var LET_LEADER_KEY = "let-poll-leader";
// GM storage keeps Pushover credentials out of the site's localStorage
// (where any on-site XSS could read them) and lets tabs observe each
// other's writes via GM_addValueChangeListener. localStorage remains as a
// fallback so the script still runs under managers without GM_* APIs.
var HAS_GM =
typeof GM_getValue === "function" && typeof GM_setValue === "function";
function storeGet(key) {
try {
return HAS_GM ? GM_getValue(key, null) : localStorage.getItem(key);
} catch (e) {
return null;
}
}
function storeSet(key, value) {
try {
if (HAS_GM) GM_setValue(key, value);
else localStorage.setItem(key, value);
} catch (e) {}
}
function storeRemove(key) {
try {
if (HAS_GM) GM_deleteValue(key);
else localStorage.removeItem(key);
} catch (e) {}
}
function storeWatch(key, handler) {
if (HAS_GM && typeof GM_addValueChangeListener === "function") {
GM_addValueChangeListener(key, function (name, oldV, newV, remote) {
if (remote) handler(newV);
});
} else if (!HAS_GM) {
window.addEventListener("storage", function (e) {
if (e.key === key && e.storageArea === localStorage) handler(e.newValue);
});
}
}
// One-time move of a key out of the site's localStorage into GM storage.
function storeMigrate(key) {
if (!HAS_GM) return;
try {
var v = localStorage.getItem(key);
if (v != null) {
if (storeGet(key) == null) storeSet(key, v);
localStorage.removeItem(key);
}
} catch (e) {}
}
var LET_CATEGORIES = [
{ key: "offers", slug: "offers", label: "Offers" },
{
key: "transfers",
slug: "service-transfers",
label: "Service Transfers",
},
];
function normalizeSettings(s) {
return {
titleFontSize: Number(s.titleFontSize) || LET_DEFAULTS.titleFontSize,
metaFontSize: Number(s.metaFontSize) || LET_DEFAULTS.metaFontSize,
userMonitorInterval: Math.max(
30,
Number(s.userMonitorInterval) || LET_DEFAULTS.userMonitorInterval,
),
userMonitorAutoOn: s.userMonitorAutoOn === true,
monitoredUsers:
Array.isArray(s.monitoredUsers) && s.monitoredUsers.length
? s.monitoredUsers
: LET_DEFAULTS.monitoredUsers.slice(),
hideSidebar: s.hideSidebar === true,
pushEnabled: s.pushEnabled === true,
pushoverUser: s.pushoverUser || "",
pushoverToken: s.pushoverToken || "",
autoRefreshOn: s.autoRefreshOn === true,
autoRefreshSec: Number(s.autoRefreshSec) || LET_DEFAULTS.autoRefreshSec,
readingPane: s.readingPane === true,
ignoredUsers: Array.isArray(s.ignoredUsers) ? s.ignoredUsers : [],
ignoredText:
typeof s.ignoredText === "string"
? s.ignoredText
: LET_DEFAULTS.ignoredText,
catNotifOffers: s.catNotifOffers === true,
catNotifTransfers: s.catNotifTransfers === true,
catNotifIntervalSec:
Number(s.catNotifIntervalSec) || LET_DEFAULTS.catNotifIntervalSec,
catNotifLastSeen:
s.catNotifLastSeen && typeof s.catNotifLastSeen === "object"
? s.catNotifLastSeen
: {},
postCounter: s.postCounter !== false,
theme: s.theme === "light" ? "light" : "dark",
accent:
typeof s.accent === "string" && /^#[0-9a-f]{6}$/i.test(s.accent)
? s.accent
: LET_DEFAULTS.accent,
sidebarMode:
s.sidebarMode === "column" ||
s.sidebarMode === "drawer" ||
s.sidebarMode === "hidden"
? s.sidebarMode
: s.hideSidebar === true
? "hidden"
: LET_DEFAULTS.sidebarMode,
compact: s.compact === true,
};
}
function loadSettings() {
storeMigrate(LET_STORAGE_KEY);
try {
var s = JSON.parse(storeGet(LET_STORAGE_KEY));
if (s && typeof s === "object") return normalizeSettings(s);
} catch (e) {}
return Object.assign({}, LET_DEFAULTS);
}
function saveSettings(obj) {
storeSet(LET_STORAGE_KEY, JSON.stringify(obj));
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
var letSettings = loadSettings();
var renderIgnoredList = function () {}; // updated once settings flyout is built
// Another tab saved settings — refresh our copy so a later save from this
// tab doesn't clobber it with stale data.
storeWatch(LET_STORAGE_KEY, function (raw) {
try {
var s = JSON.parse(raw);
if (s && typeof s === "object") letSettings = normalizeSettings(s);
} catch (e) {}
});
// Only one tab may send notifications (and run the category poller);
// otherwise every open tab re-pushes the same posts. The leader holds a
// heartbeat record; any tab may take over once it goes stale.
var LET_TAB_ID = Math.random().toString(36).slice(2);
var LET_LEADER_TTL_MS = 25000;
function amLeader() {
var rec = null;
try {
rec = JSON.parse(storeGet(LET_LEADER_KEY));
} catch (e) {}
var now = Date.now();
if (rec && rec.id !== LET_TAB_ID && now - rec.ts < LET_LEADER_TTL_MS) {
return false;
}
storeSet(LET_LEADER_KEY, JSON.stringify({ id: LET_TAB_ID, ts: now }));
return true;
}
if (!IS_IFRAME) {
amLeader();
setInterval(amLeader, 10000);
}
// Route Pushover through GM_xmlhttpRequest when available: it is exempt
// from the site's CSP (see @connect api.pushover.net).
function sendPushover(fields) {
if (!letSettings.pushoverUser || !letSettings.pushoverToken) return;
fields.token = letSettings.pushoverToken;
fields.user = letSettings.pushoverUser;
if (typeof GM_xmlhttpRequest === "function") {
GM_xmlhttpRequest({
method: "POST",
url: "https://api.pushover.net/1/messages.json",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
data: new URLSearchParams(fields).toString(),
});
} else {
var fd = new FormData();
Object.keys(fields).forEach(function (k) {
fd.append(k, fields[k]);
});
fetch("https://api.pushover.net/1/messages.json", {
method: "POST",
body: fd,
}).catch(function () {});
}
}
// Dynamic style element for user-configurable font sizes
var dynStyle = document.createElement("style");
dynStyle.id = "let-dynamic-styles";
document.documentElement.appendChild(dynStyle);
// --- Graphite theme plumbing -------------------------------------------
// The palette lives in CSS custom properties on ; switching theme or
// accent is a single attribute/variable write, no stylesheet rebuild.
function applyTheme() {
var root = document.documentElement;
root.setAttribute("data-letm", letSettings.theme);
root.style.setProperty("--ac", letSettings.accent);
}
applyTheme();
function sidebarMode() {
var isProfilePage = /\/profile\//i.test(window.location.pathname);
// The profile sidebar carries the user card, so never fold it away there.
if (isProfilePage) return "column";
return letSettings.sidebarMode;
}
function applyDynamicStyles() {
var titleSize = letSettings.titleFontSize;
var metaSize = letSettings.metaFontSize;
var css =
".let-title a { font-size: " +
titleSize +
"px !important; }" +
".let-meta, .let-meta span, .let-meta a, .let-meta time { font-size: " +
metaSize +
"px !important; }" +
".let-meta .let-num { font-size: " +
(metaSize - 0.5) +
"px !important; }";
var isProfilePage = /\/profile\//i.test(window.location.pathname);
var isDiscussionPage = /\/discussion\/\d+/i.test(window.location.pathname);
var isListingPage = !isProfilePage && !isDiscussionPage;
var mode = sidebarMode();
var readingPaneOn = !IS_IFRAME && letSettings.readingPane && isListingPage;
if (readingPaneOn) {
// Reading pane splits the frame 50/50, so the panel always leaves flow.
css += ".Column.PanelColumn { display: none !important; }";
css +=
".Column.ContentColumn { flex: 0 0 50% !important; max-width: 50% !important; overflow-y: auto !important; overflow-x: hidden !important; height: calc(100vh - var(--letm-chrome)) !important; }";
css +=
"#let-reading-pane { display: block !important; flex: 1 1 0 !important; min-width: 0 !important; }";
// Narrow columns can't hold the fixed meta grid — let rows breathe.
css +=
"ul.DataList.Discussions > li.ItemDiscussion { flex-wrap: wrap !important; }";
css +=
".let-meta { grid-template-columns: none !important; display: flex !important; flex-wrap: wrap !important; gap: 4px 10px !important; }";
css += ".let-meta > * { grid-column: auto !important; }";
// Rows reflow to wrapped flex in the narrow pane — nothing to label.
css += ".letm-colhead { display: none !important; }";
} else if (mode === "hidden") {
css += ".Column.PanelColumn { display: none !important; }";
} else if (mode === "drawer") {
css += ".letm-ctl, .letm-drawerbtn { display: inline-grid !important; }";
}
if (mode !== "drawer" || readingPaneOn) {
// The drawer toggle is the cluster's only button — hide the lot.
css += ".letm-ctl, .letm-drawerbtn { display: none !important; }";
}
if (!readingPaneOn) {
css += "#let-reading-pane { display: none !important; }";
}
dynStyle.textContent = css;
document.documentElement.classList.toggle("letm-compact", !!letSettings.compact);
document.documentElement.classList.toggle(
"letm-drawer",
mode === "drawer" && !readingPaneOn,
);
if (mode !== "drawer") {
document.documentElement.classList.remove("letm-drawer-open");
}
}
applyDynamicStyles();
// Reading pane functions
function createReadingPane() {
if (document.getElementById("let-reading-pane")) return;
var row = document.querySelector("#Body .Row");
if (!row) return;
var pane = document.createElement("div");
pane.id = "let-reading-pane";
pane.innerHTML =
'Click a post to read it here
';
row.appendChild(pane);
}
function loadInReadingPane(url) {
var pane = document.getElementById("let-reading-pane");
if (!pane) return;
pane.innerHTML = 'Loading...
';
fetch(url, { credentials: "same-origin" })
.then(function (r) {
return r.text();
})
.then(function (html) {
var doc = new DOMParser().parseFromString(html, "text/html");
var content =
doc.querySelector(".Column.ContentColumn") ||
doc.querySelector("main.Content.MainContent") ||
doc.querySelector("#Body");
if (!content) {
pane.innerHTML =
'Failed to load content
';
return;
}
content
.querySelectorAll(".Column.PanelColumn, aside.Panel, .Panel-main")
.forEach(function (el) {
el.remove();
});
pane.innerHTML = "";
var wrapper = document.createElement("div");
wrapper.className = "let-reading-content";
wrapper.innerHTML = content.innerHTML;
pane.appendChild(wrapper);
restructureCommentHeaders(pane);
interceptPaneLinks(pane);
})
.catch(function () {
pane.innerHTML =
'Failed to load
';
});
}
function interceptPaneLinks(pane) {
pane.addEventListener("click", function (e) {
var a = e.target.closest("a");
if (!a) return;
var href = a.getAttribute("href");
if (!href || href.startsWith("#") || href.startsWith("javascript:"))
return;
e.preventDefault();
e.stopPropagation();
try {
var linkUrl = new URL(href, location.origin);
if (linkUrl.origin !== location.origin) {
window.open(href, "_blank", "noopener,noreferrer");
} else {
loadInReadingPane(linkUrl.href);
}
} catch (ex) {
window.open(href, "_blank", "noopener,noreferrer");
}
});
pane.addEventListener("submit", function (e) {
var form = e.target.closest("form");
if (!form) return;
e.preventDefault();
e.stopPropagation();
var action = form.action || location.href;
var method = (form.method || "GET").toUpperCase();
var data = new FormData(form);
var opts = { credentials: "same-origin", method: method };
if (method === "POST") {
opts.body = data;
} else {
action +=
(action.indexOf("?") > -1 ? "&" : "?") +
new URLSearchParams(data).toString();
}
fetch(action, opts)
.then(function (r) {
if (r.redirected) {
loadInReadingPane(r.url);
} else {
loadInReadingPane(action);
}
})
.catch(function () {
loadInReadingPane(action);
});
});
}
function toggleReadingPane(enabled) {
if (enabled) {
createReadingPane();
} else {
var pane = document.getElementById("let-reading-pane");
if (pane) {
pane.innerHTML =
'Click a post to read it here
';
}
}
}
// ---- Post counter bar (integrated from lowendtalk-post-counter.user.js) ----
var POSTS_PER_PAGE = 30;
function postCounterCurrentPage() {
var urlMatch = window.location.pathname.match(/\/p(\d+)\/?$/);
if (urlMatch) return parseInt(urlMatch[1], 10);
var highlight = document.querySelector(".Pager .Highlight");
if (highlight) {
var num = parseInt(highlight.textContent.trim(), 10);
if (!isNaN(num)) return num;
}
return 1;
}
function postCounterLastPage() {
var maxPage = 1;
document.querySelectorAll('.Pager a[href*="/p"]').forEach(function (link) {
var m = link.href.match(/\/p(\d+)/);
if (m) maxPage = Math.max(maxPage, parseInt(m[1], 10));
});
document.querySelectorAll(".Pager .Highlight").forEach(function (el) {
var m = el.textContent.trim().match(/^(\d+)$/);
if (m) maxPage = Math.max(maxPage, parseInt(m[1], 10));
});
return maxPage;
}
function postCounterCount() {
var currentPage = postCounterCurrentPage();
var comments = document.querySelectorAll(
"ul.DataList.Comments > li.ItemComment",
);
var opCount = 0;
if (currentPage === 1) {
var op = document.querySelector(
".MessageList.Discussion .ItemDiscussion",
);
if (op) opCount = 1;
}
return comments.length + opCount;
}
function updatePostCounter() {
var existing = document.getElementById("let-post-counter");
if (existing) existing.remove();
if (document.body) document.body.style.paddingBottom = "";
if (!letSettings.postCounter) return;
if (!/\/discussion\/\d+/i.test(window.location.pathname)) return;
if (!document.body) return;
var currentPage = postCounterCurrentPage();
var lastPage = postCounterLastPage();
var postsOnPage = postCounterCount();
if (postsOnPage === 0) return;
var remaining = POSTS_PER_PAGE - postsOnPage;
var nextPage = currentPage + 1;
var bar = document.createElement("div");
bar.id = "let-post-counter";
var span = document.createElement("span");
if (remaining > 0) {
span.innerHTML =
"" + postsOnPage + " posts on this page — " +
'' + remaining +
" more until page " + nextPage;
} else if (remaining === 0) {
if (currentPage === lastPage) {
span.innerHTML =
'Page full (' +
POSTS_PER_PAGE + "/" + POSTS_PER_PAGE +
") — next post starts page " + nextPage;
} else {
span.innerHTML =
'Page full (' +
POSTS_PER_PAGE + "/" + POSTS_PER_PAGE + ")";
}
} else {
span.innerHTML =
"" + postsOnPage + " posts on this page";
}
if (lastPage > 1) {
span.innerHTML +=
" • Page " +
currentPage + " of " + lastPage;
}
bar.appendChild(span);
var close = document.createElement("span");
close.className = "let-pc-close";
close.textContent = "✕";
close.title = "Hide post counter (re-enable in Userscript Settings)";
close.addEventListener("click", function (e) {
e.stopPropagation();
letSettings.postCounter = false;
saveSettings(letSettings);
var chk = document.querySelector(".let-chk-postcounter");
if (chk) chk.checked = false;
bar.remove();
document.body.style.paddingBottom = "";
});
bar.appendChild(close);
document.body.style.paddingBottom = "40px";
document.body.appendChild(bar);
}
// Intercept post title clicks for reading pane
if (!IS_IFRAME) {
document.addEventListener("click", function (e) {
if (!letSettings.readingPane) return;
var link = e.target.closest(".let-title a");
if (!link) return;
e.preventDefault();
e.stopPropagation();
document.querySelectorAll("li.let-reading-active").forEach(function (li) {
li.classList.remove("let-reading-active");
});
var li = link.closest("li");
if (li) li.classList.add("let-reading-active");
loadInReadingPane(link.href);
});
}
const style = document.createElement("style");
style.textContent = `
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;450;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap');
/* ============================ palette ============================ */
html[data-letm="dark"] {
--bg0:#08090b; --bg:#0e0f12; --bg2:#15171b; --bg3:#1c1f25;
--line:#24272e; --line2:#2f333c;
--tx:#e6e8ec; --tx2:#9aa1ab; --tx3:#646b76;
--shadow:rgba(0,0,0,.45);
--letm-chrome:82px;
}
html[data-letm="light"] {
--bg0:#eceef0; --bg:#f6f7f8; --bg2:#ffffff; --bg3:#edeff2;
--line:#e2e5e9; --line2:#d2d6dc;
--tx:#1a1d21; --tx2:#5a616b; --tx3:#8e959f;
--shadow:rgba(20,25,35,.18);
--letm-chrome:82px;
}
/* ============================ base ============================ */
html, body, body.Vanilla {
background: var(--bg0) !important;
color: var(--tx) !important;
}
html { overflow-x: hidden !important; }
body, input, button, select, textarea, .Frame {
font-family: Geist, system-ui, -apple-system, sans-serif !important;
}
a { color: var(--ac) !important; text-decoration: none !important; }
a:hover { color: var(--ac) !important; text-decoration: underline !important; }
/* The whole frame is one column: header, card, footer */
#Frame {
background: var(--bg0) !important;
display: flex !important;
flex-direction: column !important;
min-height: 100vh !important;
margin: 0 !important;
border: none !important;
box-shadow: none !important;
}
#Body { flex: 1 !important; background: var(--bg0) !important; }
#Frame, #Body, #Head, #Foot,
#Head .Row, #Body .Row, #Foot .Row, .Row,
.Column.ContentColumn, #Content {
max-width: none !important;
width: 100% !important;
box-sizing: border-box !important;
}
#Content, .Section, .Section-Content, .DataList, ul.DataList,
.CategoryList, .DataTable, .PageBox, .Content, .MessageList, .Item {
background: transparent !important;
color: var(--tx) !important;
}
/* ============================ header ============================ */
#Head {
background: var(--bg2) !important;
border: none !important;
border-bottom: 1px solid var(--line) !important;
padding: 0 !important;
margin: 0 !important;
position: sticky !important;
top: 0 !important;
z-index: 1000 !important;
box-shadow: none !important;
}
#Head .Row,
#Head .Row > *,
#Head .SiteMenu,
#Head .SiteMenu li,
#Head .SiteMenu ul,
#Head .MeBox,
#Head #let-head-mebox {
border: none !important;
background: transparent !important;
}
#Head, #Head .Row, #Head .Row > * { color: var(--tx2) !important; }
#Head .Row {
display: flex !important;
align-items: center !important;
gap: 4px !important;
min-height: 40px !important;
padding: 0 14px !important;
background: var(--bg2) !important;
}
/* Centre every header child on its own, so no item can fall back to
baseline alignment and drift against the others. */
#Head .Row > * { align-self: center !important; }
/* wordmark */
#Head .SiteTitle {
/* Flex only on the wrapper: it centres the link without contributing a
line-box strut (the theme leaves this element at 24px). */
display: flex !important;
align-items: center !important;
align-self: center !important;
margin: 0 10px 0 0 !important;
padding: 0 !important;
flex: none !important;
font-size: 13px !important;
line-height: 1.4 !important;
}
/* The theme pins the wordmark to its own "helvetica neue" stack. Left alone it
keeps those metrics while the nav links render in Geist, and two typefaces in
one line box put their baselines in different places — the wordmark rode high
in Firefox, whose Helvetica substitute diverges from Geist more than
Chrome's. Same family here means same metrics means same baseline. */
#Head .SiteTitle,
#Head .SiteTitle a {
font-family: Geist, system-ui, -apple-system, sans-serif !important;
}
/* Same trap, other headings the theme pins to its own stack. h4 is left out on
purpose — the panel/section h4s are deliberately Geist Mono. */
.BoxFilter-Heading, .BoxFilter h2,
.Column.ContentColumn h1, .Column.ContentColumn h2, .Column.ContentColumn h3 {
font-family: Geist, system-ui, -apple-system, sans-serif !important;
}
#Head .SiteTitle a {
/* Deliberately NOT a flex container. As inline-flex, the wordmark text
becomes an anonymous flex item, and Firefox positions those higher than
Chrome does — the accent dot (a real flex item) stayed put while the
text rode up. A plain block keeps the text in an ordinary line box,
laid out exactly like .SiteMenu a, so both engines agree. */
display: block !important;
font-size: 13px !important;
line-height: 1.4 !important;
/* Matches .SiteMenu a's vertical padding. */
padding: 4px 0 !important;
margin: 0 !important;
font-weight: 600 !important;
letter-spacing: .01em !important;
text-transform: none !important;
color: var(--tx) !important;
white-space: nowrap !important;
}
#Head .SiteTitle a:hover { text-decoration: none !important; }
.letm-logomark {
width: 10px; height: 10px; border-radius: 3px;
background: var(--ac);
display: inline-block;
vertical-align: middle;
margin-right: 8px;
/* nudge off the x-height centre onto the cap-height centre */
position: relative;
top: -1px;
}
/* nav pills */
#Head .SiteMenu {
display: flex !important;
align-items: center !important;
gap: 2px !important;
margin: 0 !important;
padding: 0 !important;
list-style: none !important;
}
#Head .SiteMenu li { margin: 0 !important; padding: 0 !important; }
#Head .SiteMenu a {
display: inline-block !important;
color: var(--tx2) !important;
font-size: 12.5px !important;
font-weight: 400 !important;
padding: 4px 8px !important;
border-radius: 6px !important;
line-height: 1.4 !important;
}
#Head .SiteMenu a:hover {
color: var(--tx) !important;
background: var(--bg3) !important;
text-decoration: none !important;
}
#Head .SiteMenu li.Active a,
#Head .SiteMenu a.Selected {
color: var(--tx) !important;
background: var(--bg3) !important;
font-weight: 500 !important;
}
/* header search */
#Head .SiteSearch {
display: block !important;
margin-left: auto !important;
margin-right: 10px !important;
padding: 0 !important;
background: transparent !important;
}
#Head .SiteSearch div { display: flex !important; gap: 4px !important; }
#Head .SiteSearch input.InputBox {
width: 190px !important;
font-size: 12px !important;
padding: 4px 9px !important;
box-sizing: border-box !important;
border: 1px solid var(--line) !important;
background: var(--bg3) !important;
color: var(--tx) !important;
border-radius: 6px !important;
}
#Head .SiteSearch input.InputBox:focus {
border-color: var(--ac) !important;
outline: none !important;
}
#Head .SiteSearch .Button {
font-size: 11px !important;
padding: 4px 9px !important;
filter: none !important;
}
/* MeBox icons in the header bar */
#let-head-mebox {
display: inline-flex !important;
align-items: center !important;
gap: 6px !important;
padding: 0 !important;
margin: 0 !important;
position: relative !important;
}
#let-head-mebox a {
color: var(--tx2) !important;
padding: 3px 4px !important;
border-radius: 6px !important;
display: inline-flex !important;
align-items: center !important;
}
#let-head-mebox a:hover {
color: var(--tx) !important;
background: var(--bg3) !important;
text-decoration: none !important;
}
#let-head-mebox .PhotoWrap { width: 22px !important; height: 22px !important; padding: 0 !important; margin: 0 !important; }
#let-head-mebox .PhotoWrap img { width: 22px !important; height: 22px !important; border-radius: 50% !important; }
#let-head-mebox .Alert {
font-family: 'Geist Mono', monospace !important;
font-size: 9px !important;
padding: 1px 5px !important;
border-radius: 99px !important;
background: var(--ac) !important;
color: #0b0d0f !important;
}
#let-head-mebox a[title="Account Options"],
#let-head-mebox .MeButton-user,
#let-head-mebox > a.MeButton:not([title]) { display: none !important; }
.letm-userwrap { gap: 7px !important; margin-left: 2px !important; }
.letm-userwrap > a.PhotoWrap { cursor: pointer !important; }
#let-head-mebox a.Username {
font-size: 12.5px !important;
font-weight: 500 !important;
color: var(--tx2) !important;
max-width: 130px !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
display: inline-block !important;
}
.letm-usermenu {
width: 190px !important;
left: auto !important;
right: 0 !important;
padding: 5px !important;
}
.letm-usermenu a {
display: block !important;
padding: 7px 10px !important;
border-radius: 6px !important;
font-size: 12px !important;
color: var(--tx2) !important;
}
.letm-usermenu a:hover { background: var(--bg3) !important; color: var(--tx) !important; text-decoration: none !important; }
html[data-letm="dark"] #let-head-mebox .Sprite { filter: invert(.72); }
/* ============================ layout card ============================ */
#Body > .Row {
display: flex !important;
align-items: stretch !important;
gap: 12px !important;
padding: 12px 14px 16px !important;
border-top: none !important;
margin: 0 !important;
}
.Column.ContentColumn {
flex: 1 1 auto !important;
min-width: 0 !important;
order: 1 !important;
background: var(--bg) !important;
border: 1px solid var(--line2) !important;
border-radius: 10px !important;
overflow: hidden !important;
padding: 0 !important;
margin: 0 !important;
}
.Column.PanelColumn {
width: 236px !important;
min-width: 236px !important;
max-width: 236px !important;
flex-shrink: 0 !important;
order: 2 !important;
background: var(--bg2) !important;
border: 1px solid var(--line2) !important;
border-radius: 10px !important;
padding: 10px !important;
box-sizing: border-box !important;
color: var(--tx2) !important;
align-self: flex-start !important;
max-height: calc(100vh - var(--letm-chrome) - 28px) !important;
overflow-y: auto !important;
}
/* sidebar as a slide-over drawer */
html.letm-drawer .Column.PanelColumn {
position: fixed !important;
top: 0 !important; right: 0 !important; bottom: 0 !important;
width: 300px !important; min-width: 300px !important; max-width: 300px !important;
height: 100vh !important; max-height: none !important;
z-index: 1200 !important;
margin: 0 !important;
padding: 16px !important;
border: none !important;
border-left: 1px solid var(--line2) !important;
border-radius: 0 !important;
box-shadow: -16px 0 40px var(--shadow) !important;
transform: translateX(105%);
transition: transform .22s ease;
}
html.letm-drawer-open .Column.PanelColumn { transform: none; }
.letm-scrim {
position: fixed; inset: 0; z-index: 1100;
background: rgba(0,0,0,.4);
opacity: 0; pointer-events: none; transition: opacity .22s;
}
html.letm-drawer-open .letm-scrim { opacity: 1; pointer-events: auto; }
/* sidebar contents */
.Column.PanelColumn .Box,
.Column.PanelColumn .PanelInfo,
.Column.PanelColumn .BoxButtons {
background: none !important;
border: none !important;
padding: 0 !important;
margin: 0 0 14px !important;
box-shadow: none !important;
}
.Column.PanelColumn h4,
.PanelInfo h4 {
font-family: 'Geist Mono', monospace !important;
font-size: 9.5px !important;
text-transform: uppercase !important;
letter-spacing: .1em !important;
color: var(--tx3) !important;
background: none !important;
border: none !important;
margin: 0 0 6px !important;
padding: 0 !important;
}
.Column.PanelColumn a { color: var(--tx2) !important; font-size: 12px !important; }
.Column.PanelColumn a:hover { color: var(--tx) !important; }
.Column.PanelColumn .PanelInfo li {
padding: 3px 0 !important;
font-size: 12px !important;
border: none !important;
background: none !important;
display: flex !important;
align-items: center !important;
gap: 7px !important;
}
.Column.PanelColumn .PanelInfo li a { font-size: 12px !important; }
.Column.PanelColumn .BigButton,
.Column.PanelColumn .Button.Action.Primary {
display: block !important;
text-align: center !important;
padding: 7px 12px !important;
font-size: 12px !important;
margin: 0 0 10px !important;
}
/* hide sidebar ads and the duplicated MeBox */
.Column.PanelColumn [id^="bsap_"],
.Column.PanelColumn [class*="bsap"],
.Column.PanelColumn [class*="bsa_"],
.Column.PanelColumn .bsarocks,
.Column.PanelColumn a[rel="nofollow noopener"][target="_blank"],
.Column.PanelColumn .MeBox.MeBox-SignedIn,
.Column.PanelColumn .MeBox,
.Column.PanelColumn .Box.MeBox,
.Panel .MeBox {
display: none !important;
}
.Column.PanelColumn .SiteSearch { display: none !important; }
/* ============================ tabs / controls ============================ */
div.Headings, div.Tabs, .DiscussionsTabs, .DiscussionTabs,
.Column.ContentColumn > div.Tabs,
.Column.ContentColumn > div.Headings {
background: var(--bg2) !important;
border: none !important;
border-bottom: 1px solid var(--line) !important;
margin: 0 !important;
padding: 5px 12px !important;
display: flex !important;
align-items: center !important;
gap: 4px !important;
min-height: 0 !important;
}
.Tabs ul, .Tabs .TabList {
display: flex !important; align-items: center !important;
gap: 2px !important; margin: 0 !important; padding: 0 !important;
list-style: none !important;
}
.Tabs li { margin: 0 !important; padding: 0 !important; border: none !important; background: none !important; }
.Tabs a {
color: var(--tx2) !important;
font-size: 12px !important;
padding: 4px 9px !important;
border-radius: 6px !important;
border: none !important;
background: none !important;
line-height: 1.4 !important;
}
.Tabs a:hover { color: var(--tx) !important; background: var(--bg3) !important; text-decoration: none !important; }
.Tabs .Active a, .Tabs li.Active a, .Tabs a.Active {
color: var(--tx) !important;
background: var(--bg3) !important;
font-weight: 500 !important;
border: none !important;
}
.BoxFilter, .FilterMenu {
padding: 0 !important; margin: 0 !important;
font-size: 11.5px !important; background: transparent !important;
}
.PageControls {
display: flex !important;
align-items: center !important;
justify-content: flex-end !important;
gap: 10px !important;
padding: 6px 12px !important;
margin: 0 !important;
background: var(--bg2) !important;
border-bottom: 1px solid var(--line) !important;
min-height: 0 !important;
max-width: none !important;
width: 100% !important;
box-sizing: border-box !important;
}
.PageControls.Bottom {
border-bottom: none !important;
border-top: 1px solid var(--line) !important;
background: var(--bg) !important;
}
.Pager { padding: 0 !important; margin: 0 !important; }
.PagerNub { display: none !important; }
.Pager a, .Pager span {
font-family: 'Geist Mono', monospace !important;
font-size: 11px !important;
color: var(--tx3) !important;
background: none !important;
border: none !important;
padding: 3px 8px !important;
border-radius: 5px !important;
margin: 0 !important;
min-width: 0 !important;
}
.Pager a:hover { color: var(--tx) !important; background: var(--bg3) !important; text-decoration: none !important; }
.Pager .Active, .Pager .Highlight {
background: var(--bg3) !important;
color: var(--tx) !important;
}
/* sort control */
.let-sort-wrap {
margin-right: auto !important;
display: flex !important;
align-items: center !important;
gap: 4px !important;
}
.let-sort-select, .let-sort-dir {
display: inline-block !important;
visibility: visible !important;
opacity: 1 !important;
width: auto !important; height: auto !important;
max-height: none !important; overflow: visible !important;
background: var(--bg3) !important;
color: var(--tx2) !important;
border: 1px solid var(--line) !important;
font-size: 11px !important;
font-family: Geist, sans-serif !important;
padding: 3px 7px !important;
border-radius: 6px !important;
cursor: pointer !important;
line-height: 1.3 !important;
}
.let-sort-select { -webkit-appearance: menulist !important; appearance: menulist !important; }
.let-sort-select:hover, .let-sort-dir:hover { border-color: var(--ac) !important; color: var(--tx) !important; }
/* ============================ discussion rows ============================ */
.Breadcrumbs, .BreadcrumbsWrapper, #Body .HomeCrumb,
.Column.ContentColumn > .Breadcrumbs { display: none !important; }
ul.DataList.Discussions { padding: 0 !important; margin: 0 !important; border: none !important; }
.DataList li, .DataList .Item, ul.DataList > li {
border: none !important; box-shadow: none !important; outline: none !important;
}
ul.DataList.Discussions > li.ItemDiscussion {
display: flex !important;
flex-wrap: nowrap !important;
align-items: center !important;
gap: 10px !important;
min-height: 34px !important;
padding: 4px 14px !important;
margin: 0 !important;
background: transparent !important;
border: none !important;
border-bottom: 1px solid var(--line) !important;
box-shadow: none !important;
animation: none !important;
overflow: hidden !important;
}
ul.DataList.Discussions > li.ItemDiscussion:last-child { border-bottom: none !important; }
ul.DataList.Discussions > li.ItemDiscussion:hover { background: var(--bg2) !important; }
ul.DataList.Discussions > li.Announcement,
ul.DataList.Discussions > li.Alt,
ul.DataList.Discussions > li.Read,
.Discussions .DataList .Item, .Item, .Item.Read { background: transparent !important; }
/* unread dot */
ul.DataList.Discussions > li.ItemDiscussion::before {
content: '';
flex: none;
width: 6px; height: 6px;
border-radius: 50%;
background: transparent;
}
ul.DataList.Discussions > li.ItemDiscussion:not(.Read)::before { background: var(--ac); }
/* column header strip above the list */
.letm-colhead {
display: flex !important;
align-items: center !important;
gap: 10px !important;
padding: 5px 14px !important;
background: var(--bg2) !important;
border-bottom: 1px solid var(--line) !important;
white-space: nowrap !important;
user-select: none;
}
/* stand-in for the unread dot so the columns start at the same offset */
.letm-colhead::before {
content: '';
flex: none;
width: 6px;
height: 6px;
}
/* Two classes deep so these beat the dynamic font-size sheet, which targets
.let-meta / .let-title with a single class and is appended after this one. */
.letm-colhead .let-title,
.letm-colhead .let-meta,
.letm-colhead .let-meta > * {
font-family: 'Geist Mono', monospace !important;
font-size: 9px !important;
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: .08em !important;
color: var(--tx3) !important;
line-height: 1.5 !important;
}
/* strip the pill off the "new" label — it is a heading here, not a badge */
.letm-colhead .let-new {
background: none !important;
padding: 0 !important;
border-radius: 0 !important;
}
.letm-colhead .let-avatar,
.letm-colhead .let-bookmark { flex: none !important; }
.letm-colhead .let-bookmark { width: 16px !important; }
.let-avatar { flex: none !important; width: 18px !important; height: 18px !important; order: 1 !important; }
.let-avatar img {
width: 18px !important; height: 18px !important;
border-radius: 50% !important; vertical-align: middle !important;
}
.let-title {
order: 2 !important;
flex: 1 1 0 !important;
min-width: 90px !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
.let-title a {
color: var(--tx2) !important;
font-weight: 450 !important;
line-height: 1.5 !important;
}
.let-title a:hover { color: var(--tx) !important; text-decoration: none !important; }
li.ItemDiscussion:not(.Read) .let-title a { color: var(--tx) !important; font-weight: 600 !important; }
/* fixed meta grid so columns line up across every row */
.let-meta {
order: 3 !important;
display: grid !important;
grid-template-columns: max-content 128px 50px 58px 70px 118px 74px;
/* tags cat cmts views new by date */
align-items: center !important;
column-gap: 10px !important;
flex: none !important;
white-space: nowrap !important;
color: var(--tx3) !important;
line-height: 1.5 !important;
}
.let-meta > * { grid-row: 1; margin: 0 !important; min-width: 0 !important; }
.let-meta .let-tags { grid-column: 1; justify-self: end; display: flex; gap: 5px; align-items: center; }
.let-meta .let-cat { grid-column: 2; justify-self: start; overflow: hidden; text-overflow: ellipsis; }
.let-meta .let-comments { grid-column: 3; justify-self: end; }
.let-meta .let-views { grid-column: 4; justify-self: end; }
.let-meta .let-new { grid-column: 5; justify-self: end; }
.let-meta .let-recent { grid-column: 6; justify-self: end; overflow: hidden; text-overflow: ellipsis; }
.let-meta .let-date { grid-column: 7; justify-self: end; }
.let-meta .let-num {
font-family: 'Geist Mono', monospace !important;
color: var(--tx2) !important;
}
.let-meta .let-views .let-num { color: var(--tx3) !important; }
.let-meta .let-comments .let-num::after { content: ' \\21A9'; color: var(--tx3); font-size: 10px; }
.let-meta .let-date, .let-meta .let-date time { color: var(--tx3) !important; }
.let-meta .let-recent a { color: var(--tx2) !important; text-decoration: none !important; }
.let-meta .let-recent a:hover { color: var(--tx) !important; }
/* category chip: plain text + colour dot */
.let-meta .let-cat,
.let-meta .let-cat a {
background: none !important;
border: none !important;
border-radius: 0 !important;
padding: 0 !important;
box-shadow: none !important;
color: var(--tx2) !important;
font-weight: 400 !important;
}
.let-meta .let-cat a {
display: inline-flex !important;
align-items: center !important;
gap: 5px !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
text-decoration: none !important;
}
.let-meta .let-cat a:hover { color: var(--tx) !important; }
.letm-dot { flex: none; width: 7px; height: 7px; border-radius: 2px; display: inline-block; }
/* tags + "N new" badge */
.let-meta .let-tag {
font-family: 'Geist Mono', monospace !important;
font-size: 9px !important;
text-transform: uppercase !important;
letter-spacing: .06em !important;
font-weight: 600 !important;
color: var(--ac) !important;
background: color-mix(in srgb, var(--ac) 14%, transparent) !important;
border: none !important;
border-radius: 99px !important;
padding: 2px 7px !important;
}
.let-meta .let-tag.Tag-Closed {
color: var(--tx3) !important;
background: var(--bg3) !important;
}
.let-meta .let-new {
font-family: 'Geist Mono', monospace !important;
font-size: 9px !important;
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: .05em !important;
color: var(--ac) !important;
background: color-mix(in srgb, var(--ac) 15%, transparent) !important;
border-radius: 99px !important;
padding: 2px 7px !important;
}
.let-bookmark {
order: 4 !important;
flex: none !important;
display: flex !important;
align-items: center !important;
gap: 3px !important;
}
.let-bookmark a {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: 16px !important; height: 16px !important;
font-size: 0 !important;
flex-shrink: 0 !important;
}
.let-bookmark .OptionsMenu { display: none !important; }
html[data-letm="dark"] .let-bookmark .Sprite.SpBookmarks,
html[data-letm="dark"] .let-bookmark .Bookmark { filter: invert(.72) !important; }
html[data-letm="light"] .let-bookmark .Sprite.SpBookmarks,
html[data-letm="light"] .let-bookmark .Bookmark { filter: none !important; }
/* hide original children after restructure */
ul.DataList.Discussions > li.ItemDiscussion.let-done > a.IndexPhoto,
ul.DataList.Discussions > li.ItemDiscussion.let-done > .ItemContent,
ul.DataList.Discussions > li.ItemDiscussion.let-done > .Options {
display: none !important;
}
/* category header band (category pages) and the category index */
ul.DataList.CategoryList { padding: 0 !important; margin: 0 !important; }
ul.DataList.CategoryList > li.Item {
background: var(--bg2) !important;
border: none !important;
border-bottom: 1px solid var(--line) !important;
padding: 9px 14px !important;
margin: 0 !important;
}
.ItemContent.Category .Title,
.ItemContent.Category .Title a,
.ItemContent.Category h3, .ItemContent.Category h3 a {
font-size: 14px !important;
font-weight: 600 !important;
color: var(--tx) !important;
text-decoration: none !important;
}
.ItemContent.Category .Description {
font-size: 12px !important;
color: var(--tx2) !important;
margin: 3px 0 0 !important;
}
.ItemContent.Category .Meta {
font-size: 11px !important;
color: var(--tx3) !important;
margin: 5px 0 0 !important;
display: flex !important;
flex-wrap: wrap !important;
align-items: center !important;
gap: 3px 10px !important;
}
.ItemContent.Category .Meta a { color: var(--tx2) !important; }
.ItemContent.Category .Meta .Number { font-family: 'Geist Mono', monospace !important; color: var(--tx2) !important; }
.ItemContent.Category .Meta .RSS, .ItemContent.Category .Meta img { display: none !important; }
/* ============================ thread view ============================ */
.MessageList.Discussion .ItemDiscussion,
ul.DataList.Comments > li.ItemComment {
background: var(--bg2) !important;
border: 1px solid var(--line) !important;
border-radius: 9px !important;
margin: 0 12px 8px !important;
padding: 10px 14px !important;
box-shadow: none !important;
color: var(--tx) !important;
}
ul.DataList.Comments { padding: 12px 0 4px !important; margin: 0 !important; }
.MessageList.Discussion { padding: 12px 0 0 !important; margin: 0 !important; }
ul.DataList.Comments > li.ItemComment:hover { border-color: var(--line2) !important; }
li.ItemComment > .Comment { background: transparent !important; }
.Item-Header.CommentHeader, .CommentHeader, .Comment .Header, .ItemComment .Header {
background: transparent !important;
border: none !important;
padding: 0 0 6px !important;
margin: 0 !important;
display: flex !important;
align-items: center !important;
flex-wrap: wrap !important;
gap: 8px !important;
}
.AuthorWrap {
display: inline-flex !important;
align-items: center !important;
gap: 8px !important;
flex-wrap: wrap !important;
}
/* two-row header: avatar beside (username + role) over (date · flag · …) */
.Item-Header.letm-hdr {
display: flex !important;
align-items: flex-start !important;
gap: 9px !important;
padding: 0 0 7px !important;
margin: 0 !important;
flex-wrap: nowrap !important;
}
.letm-hdr > a.PhotoWrap {
flex: none !important;
width: 30px !important;
height: 30px !important;
margin: 1px 0 0 !important;
padding: 0 !important;
}
.letm-hdr > a.PhotoWrap img {
width: 30px !important;
height: 30px !important;
border-radius: 50% !important;
}
.letm-hdrcol {
display: flex !important;
flex-direction: column !important;
align-items: flex-start !important;
gap: 2px !important;
min-width: 0 !important;
flex: 1 1 auto !important;
}
.letm-hdr .letm-hdrcol .AuthorWrap {
display: flex !important;
align-items: baseline !important;
gap: 7px !important;
flex-wrap: wrap !important;
margin: 0 !important;
padding: 0 !important;
line-height: 1.35 !important;
}
.letm-hdr .letm-hdrcol .Meta {
margin: 0 !important;
padding: 0 !important;
display: flex !important;
align-items: center !important;
flex-wrap: wrap !important;
gap: 2px 10px !important;
line-height: 1.35 !important;
}
.AuthorWrap .Author { display: inline-flex !important; align-items: center !important; gap: 7px !important; }
.CommentHeader .PhotoWrap, .AuthorWrap .PhotoWrap { width: 24px !important; height: 24px !important; }
.CommentHeader .PhotoWrap img, .AuthorWrap .PhotoWrap img {
width: 24px !important; height: 24px !important; border-radius: 50% !important;
}
.AuthorWrap a.Username, .Username a, .AuthorWrap .Author a {
font-size: 12.5px !important;
font-weight: 600 !important;
color: var(--tx) !important;
text-decoration: none !important;
}
.AuthorWrap a.Username:hover { color: var(--ac) !important; }
.AuthorInfo .MItem, .RoleTitle, .Author .Rank, .RankLabel {
font-family: 'Geist Mono', monospace !important;
font-size: 9px !important;
text-transform: uppercase !important;
letter-spacing: .06em !important;
color: var(--tx3) !important;
background: none !important;
border: none !important;
padding: 0 !important;
}
.Meta.CommentMeta, .CommentMeta, .MessageList .Meta {
margin-left: auto !important;
display: inline-flex !important;
align-items: center !important;
gap: 10px !important;
background: none !important;
}
.CommentMeta .MItem, .CommentMeta .MItem a,
.DateCreated, .DateCreated a, .DateCreated time {
font-size: 11px !important;
color: var(--tx3) !important;
padding: 0 !important;
margin: 0 !important;
background: none !important;
}
.CommentMeta .MItem a:hover { color: var(--tx) !important; }
.Item-BodyWrap, .Item-Body { background: transparent !important; padding: 0 !important; margin: 0 !important; }
.Message, .Comment .Message, .Discussion .Message {
background: transparent !important;
color: var(--tx) !important;
font-size: 13px !important;
line-height: 1.6 !important;
padding: 0 !important;
}
.Message p, .Message li, .Message td { color: var(--tx) !important; }
.Message img { max-width: 100% !important; height: auto !important; }
blockquote, .Message blockquote, .UserQuote, .Quote {
background: var(--bg3) !important;
border: none !important;
border-left: 2px solid var(--ac) !important;
border-radius: 0 6px 6px 0 !important;
padding: 7px 11px !important;
margin: 0 0 8px !important;
font-size: 12.5px !important;
color: var(--tx2) !important;
}
code, pre {
background: var(--bg3) !important;
color: var(--tx) !important;
border: 1px solid var(--line) !important;
border-radius: 6px !important;
font-family: 'Geist Mono', monospace !important;
font-size: 12px !important;
}
.Reactions, .ReactButton {
font-family: 'Geist Mono', monospace !important;
font-size: 10.5px !important;
}
.Reactions a { color: var(--tx3) !important; background: none !important; border: none !important; }
.Reactions a:hover { color: var(--tx) !important; text-decoration: none !important; }
h2.CommentHeading, .CommentHeading {
margin: 0 !important;
padding: 4px 14px 8px !important;
font-size: 13px !important;
font-weight: 600 !important;
color: var(--tx2) !important;
border: none !important;
background: none !important;
}
.DataBox, .DataBox-Comments, .PageBox { background: transparent !important; border: none !important; }
.CategoryHeading, h1.H, h2.H, .PageTitle h1 {
color: var(--tx) !important;
font-size: 17px !important;
font-weight: 600 !important;
letter-spacing: -.01em !important;
background: none !important;
border: none !important;
margin: 0 !important;
padding: 14px 14px 8px !important;
}
/* composer */
.CommentForm, .MessageForm, .DiscussionForm, .FormWrapper, .CommentFormWrap {
background: var(--bg2) !important;
border: 1px solid var(--line) !important;
border-radius: 9px !important;
margin: 8px 12px 14px !important;
padding: 12px 14px !important;
box-shadow: none !important;
}
.CommentForm .FormWrapper, .CommentForm .CommentFormWrap, .MessageForm .FormWrapper {
background: none !important; border: none !important;
margin: 0 !important; padding: 0 !important;
}
.CommentForm h2, .MessageForm h2, .DiscussionForm h2, .CommentForm h1, .MessageForm h1 {
margin: 0 0 10px !important;
padding: 0 !important;
font-size: 14px !important;
font-weight: 600 !important;
color: var(--tx) !important;
border: none !important;
background: none !important;
}
.CommentForm textarea, .MessageForm textarea, .DiscussionForm textarea,
.CommentForm .BodyBox, .MessageForm .BodyBox {
width: 100% !important;
min-height: 110px !important;
box-sizing: border-box !important;
resize: vertical !important;
}
.CommentForm .Buttons, .MessageForm .Buttons {
text-align: right !important;
margin: 10px 0 0 !important;
}
.DiscussionForm input[type=text], .DiscussionForm select { width: 100% !important; box-sizing: border-box !important; }
.richEditor, .richEditor-menu { background: transparent !important; border: none !important; }
.richEditor-frame, .richEditor-text, .BodyBox, .TextBoxWrapper {
background: var(--bg3) !important;
border: 1px solid var(--line) !important;
border-radius: 7px !important;
color: var(--tx) !important;
}
/* ============================ forms & buttons ============================ */
input, textarea, select, .InputBox, .TextBox {
background: var(--bg3) !important;
color: var(--tx) !important;
border: 1px solid var(--line) !important;
border-radius: 6px !important;
font-size: 12.5px !important;
box-shadow: none !important;
}
input:focus, textarea:focus, select:focus { border-color: var(--ac) !important; outline: none !important; }
.Button, .btn, .Btn, input[type=submit] {
background: var(--bg3) !important;
color: var(--tx2) !important;
border: 1px solid var(--line2) !important;
border-radius: 6px !important;
font: 500 12px Geist, sans-serif !important;
padding: 5px 11px !important;
text-shadow: none !important;
box-shadow: none !important;
text-decoration: none !important;
}
.Button:hover { color: var(--tx) !important; }
.Button.Primary, .btn-primary, .BigButton,
.Button.Primary a, .BigButton a, a.Button.Primary, a.BigButton, .btn-primary a {
background: var(--ac) !important;
color: #0b0d0f !important;
border-color: transparent !important;
}
.Button.Primary:hover, .BigButton:hover { filter: brightness(1.08); }
/* flyouts / dropdown menus */
.Flyout, .MenuItems {
background: var(--bg2) !important;
border: 1px solid var(--line2) !important;
border-radius: 8px !important;
box-shadow: 0 8px 24px var(--shadow) !important;
z-index: 1300 !important;
}
.Flyout a, .MenuItems a { color: var(--tx2) !important; }
.Flyout a:hover, .MenuItems a:hover { background: var(--bg3) !important; color: var(--tx) !important; }
/* ============================ monitor banner ============================ */
#let-user-monitor-banner {
display: flex !important;
align-items: center !important;
background: var(--bg2) !important;
padding: 4px 14px !important;
font-size: 11px !important;
color: var(--tx3) !important;
border-top: 1px solid var(--line) !important;
gap: 8px !important;
white-space: nowrap !important;
overflow: visible !important;
}
#let-user-monitor-banner.let-um-banner-multi { align-items: center !important; flex-wrap: nowrap !important; }
#let-user-monitor-banner .let-um-controls {
display: inline-flex !important;
align-items: center !important;
gap: 6px !important;
flex-shrink: 0 !important;
}
#let-user-monitor-banner .let-um-posts {
display: flex !important;
flex: 1 1 auto !important;
min-width: 0 !important;
flex-direction: column !important;
gap: 1px !important;
}
#let-user-monitor-banner .let-um-post-row {
display: flex !important;
align-items: center !important;
gap: 6px !important;
min-width: 0 !important;
line-height: 17px !important;
}
#let-user-monitor-banner .let-um-label {
font-family: 'Geist Mono', monospace !important;
font-size: 9.5px !important;
text-transform: uppercase !important;
letter-spacing: .08em !important;
color: var(--tx3) !important;
flex-shrink: 0 !important;
}
#let-user-monitor-banner .let-um-label a { color: var(--ac) !important; font-weight: 600 !important; }
#let-user-monitor-banner .let-um-title {
color: var(--tx2) !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
flex: 1 1 auto !important;
min-width: 0 !important;
}
#let-user-monitor-banner .let-um-title a { color: var(--tx2) !important; }
#let-user-monitor-banner .let-um-title a:hover { color: var(--tx) !important; }
#let-user-monitor-banner .let-um-toggle {
display: flex !important;
align-items: center !important;
gap: 5px !important;
flex-shrink: 0 !important;
cursor: pointer !important;
color: var(--tx3) !important;
font-size: 10px !important;
}
#let-user-monitor-banner .let-um-switch,
.let-um-switch {
position: relative !important;
width: 24px !important;
height: 13px !important;
background: var(--line2) !important;
border-radius: 99px !important;
transition: background .2s !important;
cursor: pointer !important;
flex: none !important;
}
#let-user-monitor-banner .let-um-switch.on, .let-um-switch.on { background: var(--ac) !important; }
#let-user-monitor-banner .let-um-switch::after, .let-um-switch::after {
content: '' !important;
position: absolute !important;
top: 2px !important; left: 2px !important;
width: 9px !important; height: 9px !important;
background: var(--bg2) !important;
border-radius: 50% !important;
transition: left .2s !important;
}
#let-user-monitor-banner .let-um-switch.on::after, .let-um-switch.on::after { left: 13px !important; }
#let-user-monitor-banner .let-um-interval,
.let-um-interval {
width: 32px !important;
background: var(--bg3) !important;
border: 1px solid var(--line) !important;
color: var(--tx2) !important;
font-family: 'Geist Mono', monospace !important;
font-size: 10px !important;
padding: 0 3px !important;
text-align: center !important;
border-radius: 5px !important;
height: 16px !important;
line-height: 16px !important;
}
#let-user-monitor-banner .let-um-interval:focus { border-color: var(--ac) !important; outline: none !important; }
#let-user-monitor-banner .let-um-unit, .let-um-unit {
color: var(--tx3) !important;
font-size: 10px !important;
}
#let-user-monitor-banner .let-um-divider, .let-um-divider {
width: 1px !important;
height: 11px !important;
background: var(--line2) !important;
margin: 0 3px !important;
flex-shrink: 0 !important;
}
#let-user-monitor-banner .let-um-updated {
font-family: 'Geist Mono', monospace !important;
color: var(--tx3) !important;
font-size: 10px !important;
flex-shrink: 0 !important;
}
#let-user-monitor-banner .let-um-refresh {
color: var(--ac) !important;
font-size: 10px !important;
flex-shrink: 0 !important;
}
/* toast */
.let-um-toast {
position: fixed !important;
top: 66px !important; right: 18px !important;
background: var(--bg2) !important;
border: 1px solid var(--line2) !important;
border-left: 3px solid var(--ac) !important;
border-radius: 9px !important;
padding: 12px 34px 12px 14px !important;
max-width: 340px !important;
z-index: 100001 !important;
box-shadow: 0 10px 30px var(--shadow) !important;
font-size: 12px !important;
color: var(--tx2) !important;
animation: let-toast-in .25s ease-out !important;
cursor: pointer !important;
}
.let-um-toast:hover { border-color: var(--ac) !important; }
.let-um-toast-label {
font-family: 'Geist Mono', monospace !important;
color: var(--ac) !important;
font-weight: 600 !important;
font-size: 9.5px !important;
text-transform: uppercase !important;
letter-spacing: .08em !important;
margin-bottom: 5px !important;
display: block !important;
}
.let-um-toast-title {
color: var(--tx) !important;
font-weight: 600 !important;
display: block !important;
margin-bottom: 3px !important;
line-height: 1.4 !important;
}
.let-um-toast-title a { color: var(--tx) !important; text-decoration: none !important; }
.let-um-toast-title a:hover { color: var(--ac) !important; }
.let-um-toast-time {
font-family: 'Geist Mono', monospace !important;
color: var(--tx3) !important;
font-size: 10px !important;
}
.let-um-toast-close {
position: absolute !important;
top: 6px !important; right: 8px !important;
color: var(--tx3) !important;
cursor: pointer !important;
font-size: 14px !important;
background: none !important;
border: none !important;
padding: 2px !important;
}
.let-um-toast-close:hover { color: var(--tx) !important; }
@keyframes let-toast-in {
from { opacity: 0; transform: translateX(40px); }
to { opacity: 1; transform: translateX(0); }
}
/* ============================ custom flyouts ============================ */
.let-flyout-wrap {
position: relative !important;
display: inline-flex !important;
align-items: center !important;
}
.let-flyout {
display: none;
position: absolute;
top: calc(100% + 6px);
left: 0;
width: 340px;
max-height: 420px;
overflow-y: auto;
background: var(--bg2) !important;
border: 1px solid var(--line2) !important;
border-radius: 9px !important;
box-shadow: 0 10px 30px var(--shadow) !important;
z-index: 10001 !important;
font-size: 12px !important;
color: var(--tx2) !important;
padding: 0 !important;
/* The banner sets nowrap and it inherits down into the flyouts. */
white-space: normal !important;
}
.let-flyout.let-flyout-open { display: block !important; }
/* The MeBox sits hard against the right edge, so a left-anchored panel hangs
off screen. Anchor these to the right of their icon and cap the width so
they stay inside the viewport at any window size. */
#let-head-mebox .let-flyout {
left: auto !important;
right: 0 !important;
max-width: calc(100vw - 24px) !important;
}
.let-flyout a { color: var(--tx2) !important; font-size: 12px !important; }
.let-flyout a:hover { color: var(--tx) !important; }
.let-flyout .Item, .let-flyout li {
padding: 8px 12px !important;
border: none !important;
border-bottom: 1px solid var(--line) !important;
list-style: none !important;
background: transparent !important;
}
.let-flyout .Item:hover, .let-flyout li:hover { background: var(--bg3) !important; }
.let-flyout .PhotoWrap img { width: 28px !important; height: 28px !important; border-radius: 50% !important; }
.let-flyout-footer {
padding: 9px 12px !important;
text-align: center !important;
border-top: 1px solid var(--line) !important;
}
.let-flyout-footer a { color: var(--ac) !important; }
.let-flyout-loading {
padding: 18px !important;
text-align: center !important;
color: var(--tx3) !important;
}
/* ============================ settings flyout ============================ */
#let-user-monitor-banner .let-flyout-wrap { margin-left: auto !important; }
.let-settings-link {
font-family: 'Geist Mono', monospace !important;
color: var(--tx3) !important;
font-size: 9.5px !important;
text-transform: uppercase !important;
letter-spacing: .08em !important;
cursor: pointer !important;
text-decoration: none !important;
flex-shrink: 0 !important;
}
.let-settings-link:hover { color: var(--ac) !important; }
.let-settings-flyout {
width: 280px !important;
padding: 14px !important;
max-height: 75vh !important;
overflow-x: hidden !important;
z-index: 100000 !important;
left: auto !important;
right: 0 !important;
}
.let-settings-flyout * { box-sizing: border-box !important; max-width: 100% !important; }
.let-settings-flyout label {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
gap: 8px !important;
color: var(--tx2) !important;
font-size: 11.5px !important;
margin-bottom: 9px !important;
}
.let-settings-flyout .let-sec {
display: block !important;
font-family: 'Geist Mono', monospace !important;
font-size: 9px !important;
text-transform: uppercase !important;
letter-spacing: .1em !important;
color: var(--tx3) !important;
margin: 0 0 7px !important;
}
.let-settings-flyout hr {
border: none !important;
border-top: 1px solid var(--line) !important;
margin: 12px 0 !important;
}
.let-settings-flyout input[type="range"] {
width: 96px !important;
margin: 0 !important;
accent-color: var(--ac) !important;
background: none !important;
border: none !important;
padding: 0 !important;
}
.let-settings-flyout input[type="checkbox"] {
accent-color: var(--ac) !important;
width: 13px !important; height: 13px !important;
margin: 0 !important; padding: 0 !important;
background: none !important; border: none !important;
flex: none !important;
}
.let-settings-flyout .let-size-val {
font-family: 'Geist Mono', monospace !important;
color: var(--tx2) !important;
font-size: 10.5px !important;
min-width: 32px !important;
text-align: right !important;
}
.let-settings-flyout .let-inp {
width: 100% !important;
padding: 5px 8px !important;
background: var(--bg3) !important;
color: var(--tx) !important;
border: 1px solid var(--line) !important;
border-radius: 6px !important;
font-size: 11.5px !important;
margin-bottom: 8px !important;
box-sizing: border-box !important;
}
.let-settings-flyout select.let-inp { cursor: pointer !important; }
.let-settings-flyout .let-addrow {
display: flex !important;
gap: 5px !important;
margin-bottom: 8px !important;
}
.let-settings-flyout .let-addrow .let-inp { margin-bottom: 0 !important; }
.let-settings-flyout .let-addbtn {
margin: 0 !important;
flex-shrink: 0 !important;
padding: 5px 11px !important;
background: var(--bg3) !important;
color: var(--tx2) !important;
border: 1px solid var(--line2) !important;
border-radius: 6px !important;
font-size: 11px !important;
cursor: pointer !important;
}
.let-settings-flyout .let-addbtn:hover { color: var(--tx) !important; border-color: var(--ac) !important; }
.let-settings-flyout .let-settings-reset {
display: block !important;
margin-top: 8px !important;
padding: 6px 10px !important;
background: var(--bg3) !important;
border: 1px solid var(--line2) !important;
color: var(--tx2) !important;
font-size: 11px !important;
border-radius: 6px !important;
cursor: pointer !important;
text-align: center !important;
width: 100% !important;
}
.let-settings-flyout .let-settings-reset:hover { color: var(--tx) !important; border-color: var(--ac) !important; }
.let-settings-flyout .let-swatches { display: flex !important; gap: 7px !important; }
.let-settings-flyout .let-swatches .letm-sw { width: 18px; height: 18px; }
/* user lists in settings */
.let-ignored-list, .let-monitored-list {
max-height: 118px;
overflow-y: auto;
margin: 0 0 8px 0;
padding: 0;
list-style: none;
border: 1px solid var(--line);
border-radius: 6px;
}
.let-ignored-list li, .let-monitored-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
font-size: 11.5px;
color: var(--tx2);
background: transparent;
border-bottom: 1px solid var(--line);
}
.let-ignored-list li:last-child, .let-monitored-list li:last-child { border-bottom: none; }
.let-ignored-list .let-unignore-btn, .let-monitored-list .let-unmonitor-btn {
cursor: pointer;
color: var(--tx3);
font-size: 11px;
background: none;
border: none;
padding: 0 3px;
}
.let-ignored-list .let-unignore-btn:hover, .let-monitored-list .let-unmonitor-btn:hover { color: #e06c75; }
/* ignore link in the comment meta bar */
.let-ignore-wrap { display: inline !important; }
.let-ignore-link { cursor: pointer !important; color: var(--tx3) !important; font-size: 11px !important; }
.let-ignore-link:hover { color: #e06c75 !important; }
.let-ignored-post { opacity: .75 !important; }
.let-ignored-post .Message { color: var(--tx3) !important; font-style: italic !important; }
/* ============================ reading pane ============================ */
#let-reading-pane {
display: none;
order: 3 !important;
height: calc(100vh - var(--letm-chrome)) !important;
position: sticky !important;
top: calc(var(--letm-chrome) + 12px) !important;
background: var(--bg) !important;
border: 1px solid var(--line2) !important;
border-radius: 10px !important;
box-sizing: border-box !important;
overflow-y: auto !important;
overflow-x: hidden !important;
}
.let-reading-content { padding: 12px !important; color: var(--tx) !important; }
.let-reading-content img { max-width: 100% !important; height: auto !important; }
#let-reading-pane-placeholder {
display: flex !important;
align-items: center !important;
justify-content: center !important;
height: 100% !important;
color: var(--tx3) !important;
font-size: 13px !important;
}
li.let-reading-active { background: var(--bg2) !important; box-shadow: inset 2px 0 0 var(--ac) !important; }
/* ============================ post counter ============================ */
#let-post-counter {
position: fixed !important;
bottom: 0 !important; left: 0 !important; right: 0 !important;
z-index: 99999 !important;
background: var(--bg2) !important;
color: var(--tx2) !important;
font-family: Geist, sans-serif !important;
font-size: 12px !important;
padding: 7px 16px !important;
text-align: center !important;
border-top: 1px solid var(--line2) !important;
box-shadow: 0 -2px 12px var(--shadow) !important;
}
#let-post-counter strong { color: var(--tx) !important; font-weight: 600 !important; }
#let-post-counter .let-pc-accent { color: var(--ac) !important; }
#let-post-counter .let-pc-full { color: var(--ac) !important; }
#let-post-counter .let-pc-close {
position: absolute !important;
right: 12px !important; top: 50% !important;
transform: translateY(-50%) !important;
cursor: pointer !important;
padding: 4px 8px !important;
color: var(--tx3) !important;
font-size: 13px !important;
}
#let-post-counter .let-pc-close:hover { color: var(--tx) !important; }
/* ==================== Vanilla's own bottom-left toasts ==================== */
/* "X mentioned you in ..." lands in .InformMessages at z-index 200, which puts
it under the post-counter bar (99999) and the drawer scrim — only a sliver
showed above the bar. Lift it over our fixed chrome and, when the counter is
up, step it above rather than on top of it. */
.InformMessages {
z-index: 100002 !important;
left: 14px !important;
bottom: 14px !important;
}
body:has(#let-post-counter) .InformMessages { bottom: 52px !important; }
.InformMessages .InformMessage {
background: var(--bg2) !important;
color: var(--tx2) !important;
border: 1px solid var(--line2) !important;
border-left: 3px solid var(--ac) !important;
border-radius: 9px !important;
box-shadow: 0 10px 30px var(--shadow) !important;
font: 400 12px Geist, system-ui, sans-serif !important;
padding: 10px 14px !important;
margin: 6px 0 0 !important;
}
.InformMessages .InformMessage a { color: var(--ac) !important; }
.InformMessages .InformMessage strong, .InformMessages .InformMessage b { color: var(--tx) !important; }
/* ============================ floating controls ============================ */
/* Bottom-right: LowEndTalk drops its own notification popups bottom-left. */
.letm-ctl {
position: fixed;
bottom: 14px; right: 14px;
z-index: 1350;
display: flex; gap: 6px;
transition: transform .22s ease;
}
/* Slide out of the way when the sidebar drawer takes the right edge. */
html.letm-drawer-open .letm-ctl { transform: translateX(-306px); }
/* Lift clear of the post-counter bar when it is showing. */
body:has(#let-post-counter) .letm-ctl { bottom: 48px; }
.letm-btn {
width: 30px; height: 30px;
display: inline-grid; place-items: center;
background: var(--bg3) !important;
border: 1px solid var(--line2) !important;
border-radius: 8px;
color: var(--tx2) !important;
font-size: 13px; cursor: pointer; line-height: 1;
box-shadow: 0 2px 10px var(--shadow);
padding: 0 !important;
}
.letm-btn:hover { color: var(--tx) !important; border-color: var(--ac) !important; }
/* accent swatches (Userscript Settings › Appearance) */
.letm-sw {
width: 22px; height: 22px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
padding: 0;
flex: none;
}
.letm-sw.on { border-color: var(--tx) !important; }
/* ============================ footer ============================ */
#Foot {
background: var(--bg2) !important;
border-top: 1px solid var(--line) !important;
color: var(--tx3) !important;
padding: 0 !important;
margin: 0 !important;
min-height: 0 !important;
line-height: 1 !important;
}
#Foot div {
background: transparent !important;
color: var(--tx3) !important;
border: none !important;
margin: 0 !important;
padding: 6px 14px !important;
display: flex !important;
align-items: center !important;
min-height: 0 !important;
width: 100% !important;
}
#Foot a { color: var(--tx3) !important; }
#Foot a:hover { color: var(--tx) !important; }
.let-foot-left, .let-foot-center, .let-foot-right { flex: 1 !important; font-size: 11px !important; }
.let-foot-left { text-align: left !important; }
.let-foot-center { text-align: center !important; }
.let-foot-center a { margin: 0 6px !important; }
.let-foot-right { text-align: right !important; }
/* ============================ compact density ============================ */
/* Opt-in via Userscript Settings › Appearance. Squeezes vertical rhythm only —
column positions, font sizes and alignment are left alone. */
html.letm-compact #Body > .Row { padding: 8px 10px 10px !important; gap: 8px !important; }
/* discussion rows */
html.letm-compact ul.DataList.Discussions > li.ItemDiscussion {
min-height: 0 !important;
padding: 1px 12px !important;
gap: 8px !important;
}
html.letm-compact .let-title a,
html.letm-compact .let-meta { line-height: 1.35 !important; }
html.letm-compact .let-avatar,
html.letm-compact .let-avatar img { width: 15px !important; height: 15px !important; }
html.letm-compact .letm-colhead { padding: 2px 12px !important; gap: 8px !important; }
/* chrome around the list */
html.letm-compact .PageControls { padding: 3px 12px !important; }
html.letm-compact div.Tabs,
html.letm-compact div.Headings,
html.letm-compact .DiscussionTabs { padding: 3px 12px !important; }
html.letm-compact #let-user-monitor-banner { padding: 2px 12px !important; }
html.letm-compact ul.DataList.CategoryList > li.Item { padding: 6px 12px !important; }
html.letm-compact .CategoryHeading,
html.letm-compact h1.H,
html.letm-compact h2.H,
html.letm-compact .PageTitle h1 { padding: 9px 12px 6px !important; }
html.letm-compact h2.CommentHeading { padding: 2px 12px 5px !important; }
/* thread view */
html.letm-compact ul.DataList.Comments > li.ItemComment,
html.letm-compact .MessageList.Discussion .ItemDiscussion {
margin: 0 10px 5px !important;
padding: 7px 11px !important;
}
html.letm-compact ul.DataList.Comments { padding: 8px 0 2px !important; }
html.letm-compact .MessageList.Discussion { padding: 8px 0 0 !important; }
html.letm-compact .Item-Header.letm-hdr { padding: 0 0 4px !important; gap: 8px !important; }
html.letm-compact .letm-hdr > a.PhotoWrap,
html.letm-compact .letm-hdr > a.PhotoWrap img { width: 26px !important; height: 26px !important; }
html.letm-compact .Message { line-height: 1.45 !important; }
html.letm-compact .Message p { margin: 0 0 6px !important; }
html.letm-compact .Message p:last-child { margin-bottom: 0 !important; }
html.letm-compact blockquote,
html.letm-compact .Message blockquote,
html.letm-compact .UserQuote { padding: 5px 9px !important; margin: 0 0 5px !important; }
html.letm-compact .CommentForm,
html.letm-compact .MessageForm,
html.letm-compact .DiscussionForm { margin: 6px 10px 10px !important; padding: 9px 11px !important; }
/* sidebar */
html.letm-compact .Column.PanelColumn .PanelInfo li { padding: 1px 0 !important; }
html.letm-compact .Column.PanelColumn .Box,
html.letm-compact .Column.PanelColumn .PanelInfo,
html.letm-compact .Column.PanelColumn .BoxButtons { margin-bottom: 10px !important; }
/* ============================ responsive ============================ */
@media (max-width: 1360px) {
.let-meta { grid-template-columns: max-content 106px 46px 52px 68px 96px 66px; }
}
@media (max-width: 1150px) {
.let-meta { grid-template-columns: max-content 46px 52px 68px 96px 66px; }
.let-meta .let-cat { display: none !important; }
.let-meta .let-comments { grid-column: 2; }
.let-meta .let-views { grid-column: 3; }
.let-meta .let-new { grid-column: 4; }
.let-meta .let-recent { grid-column: 5; }
.let-meta .let-date { grid-column: 6; }
}
@media (max-width: 1000px) {
#Body > .Row { flex-direction: column !important; }
.Column.PanelColumn {
width: 100% !important; min-width: 0 !important; max-width: none !important;
}
html.letm-drawer .Column.PanelColumn { width: 300px !important; max-width: 300px !important; }
}
@media (max-width: 820px) {
#Head .SiteSearch input.InputBox { width: 120px !important; }
/* rows stop being a grid down here, so the labels would line up with nothing */
.letm-colhead { display: none !important; }
ul.DataList.Discussions > li.ItemDiscussion { flex-wrap: wrap !important; }
.let-title { flex: 1 1 100% !important; min-width: 0 !important; }
.let-meta {
display: flex !important;
flex-wrap: wrap !important;
gap: 4px 10px !important;
grid-template-columns: none !important;
}
.let-meta > * { grid-column: auto !important; }
.let-meta .let-cat { display: inline-flex !important; }
}
`;
document.documentElement.appendChild(style);
// Re-append dynamic style AFTER main style so it wins the cascade
document.documentElement.appendChild(dynStyle);
applyDynamicStyles();
// --- Graphite chrome: wordmark, drawer, floating theme controls ---------
var letmSyncSettingsUI = function () {}; // replaced once the flyout exists
function setTheme(next) {
letSettings.theme = next === "light" ? "light" : "dark";
applyTheme();
saveSettings(letSettings);
letmSyncSettingsUI();
}
function setAccent(color) {
letSettings.accent = color;
applyTheme();
saveSettings(letSettings);
letmSyncSettingsUI();
}
function toggleDrawer(force) {
var root = document.documentElement;
if (!root.classList.contains("letm-drawer")) return;
root.classList.toggle(
"letm-drawer-open",
typeof force === "boolean" ? force : undefined,
);
}
// Sticky chrome height (header + monitor banner) drives the reading pane
// and sidebar heights; remeasure whenever the banner reflows.
function measureChrome() {
var head = document.getElementById("Head");
if (!head) return;
document.documentElement.style.setProperty(
"--letm-chrome",
head.offsetHeight + "px",
);
}
function buildGraphiteChrome() {
// Wordmark: a small accent square ahead of the site title.
var titleLink = document.querySelector("#Head .SiteTitle a");
if (titleLink && !titleLink.querySelector(".letm-logomark")) {
var mark = document.createElement("span");
mark.className = "letm-logomark";
titleLink.insertBefore(mark, titleLink.firstChild);
}
// Scrim behind the slide-over sidebar.
var scrim = document.createElement("div");
scrim.className = "letm-scrim";
scrim.addEventListener("click", function () {
toggleDrawer(false);
});
document.body.appendChild(scrim);
// Floating control: sidebar drawer only. Theme and accent live in the
// Userscript Settings flyout — no need to duplicate them out here.
var ctl = document.createElement("div");
ctl.className = "letm-ctl";
document.body.appendChild(ctl);
var drawerBtn = document.createElement("button");
drawerBtn.className = "letm-btn letm-drawerbtn";
drawerBtn.type = "button";
drawerBtn.textContent = "⧉";
drawerBtn.title = "Sidebar";
drawerBtn.addEventListener("click", function () {
toggleDrawer();
});
ctl.appendChild(drawerBtn);
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") toggleDrawer(false);
// '/' jumps to search, the way every modern forum does it.
if (
e.key === "/" &&
!/^(INPUT|TEXTAREA|SELECT)$/.test(
(document.activeElement || {}).tagName || "",
) &&
!(document.activeElement || {}).isContentEditable
) {
var box = document.querySelector(
"#Head .SiteSearch input.InputBox, .SiteSearch input[type=text], input#Form_Search",
);
if (box) {
e.preventDefault();
box.focus();
box.select();
}
}
});
measureChrome();
window.addEventListener("resize", measureChrome);
var head = document.getElementById("Head");
if (head && window.ResizeObserver) {
new ResizeObserver(measureChrome).observe(head);
}
}
// Post headers ship as one row: [avatar + name + role] ......... [meta].
// Rebuild them as avatar | (name + role) over (date, flag, thanks, ignore)
// so the meta reads as a second line under the username instead of drifting
// off to the right edge. Same markup on the OP and on every comment.
function restructureCommentHeaders(root) {
(root || document)
.querySelectorAll(".Item-Header")
.forEach(function (hdr) {
if (hdr.classList.contains("letm-hdr")) return;
var authorWrap = hdr.querySelector(":scope > .AuthorWrap");
if (!authorWrap) return;
var meta = hdr.querySelector(":scope > .Meta");
var photo = authorWrap.querySelector("a.PhotoWrap");
var col = document.createElement("div");
col.className = "letm-hdrcol";
hdr.insertBefore(col, authorWrap);
col.appendChild(authorWrap);
if (meta) col.appendChild(meta);
// Avatar leaves the name row so it can sit beside both lines.
if (photo) hdr.insertBefore(photo, col);
hdr.classList.add("letm-hdr");
});
}
// Colour dot for a /categories/ link.
function letmCategoryDot(href) {
var slug = String(href || "").split("/categories/")[1];
slug = slug ? slug.split(/[/?#]/)[0] : "";
var dot = document.createElement("span");
dot.className = "letm-dot";
dot.style.background = LETM_CAT_COLORS[slug] || "#8b93a1";
return dot;
}
document.addEventListener("DOMContentLoaded", function () {
if (!IS_IFRAME) {
buildGraphiteChrome();
// Move MeBox icons from sidebar into header bar
const headRow = document.querySelector("#Head .Row");
if (headRow && !document.getElementById("let-head-mebox")) {
// Try multiple selectors to find the sidebar MeBox
const sidebarMeBox =
document.querySelector(".Column.PanelColumn .MeBox") ||
document.querySelector(".Panel .MeBox") ||
document.querySelector(".PanelColumn .MeBox");
if (sidebarMeBox) {
const container = document.createElement("span");
container.id = "let-head-mebox";
// Grab all links and photo wraps from the MeBox
sidebarMeBox.querySelectorAll("a, .PhotoWrap").forEach(function (el) {
container.appendChild(el.cloneNode(true));
});
// Insert into the header
headRow.appendChild(container);
// Hide original
sidebarMeBox.style.display = "none";
// Attach custom flyout panels to notification/inbox links
var flyoutMap = [
{
match: /notifications/i,
popin: "/profile/notificationspopin",
label: "All Notifications",
full: "/profile/notifications",
},
{
match: /messages|inbox/i,
popin: "/messages/popin",
label: "All Messages",
full: "/messages/inbox",
},
{
match: /bookmarks/i,
popin: "/discussions/bookmarkedpopin",
label: "All Bookmarks",
full: "/discussions/bookmarked",
},
];
container.querySelectorAll("a").forEach(function (link) {
var href = link.getAttribute("href") || "";
var title = link.getAttribute("title") || "";
var text = link.textContent || "";
var cls = link.className || "";
var inner = link.innerHTML || "";
var matchStr =
href + " " + title + " " + text + " " + cls + " " + inner;
var config = null;
for (var i = 0; i < flyoutMap.length; i++) {
if (flyoutMap[i].match.test(matchStr)) {
config = flyoutMap[i];
break;
}
}
if (!config) return;
// Wrap link in a positioned container
var wrap = document.createElement("span");
wrap.className = "let-flyout-wrap";
link.parentNode.insertBefore(wrap, link);
wrap.appendChild(link);
// Create flyout panel inside the wrapper
var flyout = document.createElement("div");
flyout.className = "let-flyout";
flyout.innerHTML =
'Loading…
';
wrap.appendChild(flyout);
var loaded = false;
link.addEventListener("click", function (e) {
// Allow ctrl/cmd/middle click to navigate normally
if (e.ctrlKey || e.metaKey || e.button === 1) return;
e.preventDefault();
e.stopPropagation();
// Close other flyouts
container.querySelectorAll(".let-flyout").forEach(function (f) {
if (f !== flyout) f.classList.remove("let-flyout-open");
});
// Toggle this flyout
var isOpen = flyout.classList.toggle("let-flyout-open");
// Fetch content on first open (and refresh each time)
if (isOpen) {
flyout.innerHTML =
'Loading…
';
// gdn is a page global; with @grant active the script runs
// sandboxed, so reach it through unsafeWindow.
var page =
typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
var tk =
page.gdn && page.gdn.definition
? page.gdn.definition("TransientKey")
: "";
var popinUrl =
config.popin +
(config.popin.indexOf("?") > -1 ? "&" : "?") +
"DeliveryType=VIEW" +
(tk ? "&TransientKey=" + encodeURIComponent(tk) : "");
fetch(popinUrl, {
credentials: "same-origin",
})
.then(function (r) {
return r.text();
})
.then(function (html) {
flyout.innerHTML =
html.trim() +
'";
// Restyle the title row inline to beat theme CSS
var titleLi = flyout.querySelector("li.Item.Title");
if (titleLi) {
var prefLink = titleLi.querySelector("a");
var heading = titleLi.querySelector("strong");
titleLi.innerHTML = "";
titleLi.style.cssText =
"display:flex!important;align-items:baseline!important;justify-content:space-between!important;padding:6px 12px!important;border-bottom:1px solid #252530!important;";
if (heading) {
heading.style.cssText =
"font-size:12px!important;color:#ccc!important;font-weight:700!important;order:1!important;";
titleLi.appendChild(heading);
}
if (prefLink) {
prefLink.style.cssText =
"font-size:10px!important;color:#666!important;font-style:italic!important;order:2!important;text-decoration:none!important;";
titleLi.appendChild(prefLink);
}
}
// Remove duplicate "All Notifications" from popin (we add our own footer)
var centerItem = flyout.querySelector("li.Item.Center");
if (centerItem) centerItem.style.display = "none";
})
.catch(function () {
flyout.innerHTML =
'Failed to load
' +
'";
});
}
});
});
// The MeBox clone drags the whole account dropdown along as loose
// links. Fold them back behind the avatar so the header stays a
// single row of icons instead of a strip of text links.
var accountLinks = container.querySelectorAll("a.dropdown-menu-link");
var photo = container.querySelector("a.PhotoWrap");
if (accountLinks.length && photo) {
var uwrap = document.createElement("span");
uwrap.className = "let-flyout-wrap letm-userwrap";
container.appendChild(uwrap);
uwrap.appendChild(photo);
var uname = container.querySelector("a.Username");
if (uname) uwrap.appendChild(uname);
var umenu = document.createElement("div");
umenu.className = "let-flyout letm-usermenu";
accountLinks.forEach(function (a) {
umenu.appendChild(a);
});
uwrap.appendChild(umenu);
photo.addEventListener("click", function (e) {
if (e.ctrlKey || e.metaKey || e.button === 1) return;
e.preventDefault();
e.stopPropagation();
container.querySelectorAll(".let-flyout").forEach(function (f) {
if (f !== umenu) f.classList.remove("let-flyout-open");
});
umenu.classList.toggle("let-flyout-open");
});
}
// Close flyouts on outside click
document.addEventListener("click", function (e) {
if (!container.contains(e.target)) {
container.querySelectorAll(".let-flyout").forEach(function (f) {
f.classList.remove("let-flyout-open");
});
}
});
}
}
// Search lives in the header (the sidebar may be a drawer now), pushed
// right so it sits next to the MeBox icons.
const headRowForSearch = document.querySelector("#Head .Row");
const search =
document.querySelector("#Head .SiteSearch") ||
document.querySelector(".Column.PanelColumn .SiteSearch");
if (headRowForSearch && search) {
var meboxEl = document.getElementById("let-head-mebox");
headRowForSearch.insertBefore(search, meboxEl || null);
}
// Fetch monitored users' latest posts and display in banner
(function () {
var banner = document.createElement("div");
banner.id = "let-user-monitor-banner";
var head = document.getElementById("Head");
if (head) {
head.appendChild(banner);
}
var intervalId = null;
var tickerId = null;
var autoOn = letSettings.userMonitorAutoOn;
var refreshSec = letSettings.userMonitorInterval;
var lastFetchTime = null;
var latestPosts = [];
var lastSeenByUser = (function () {
storeMigrate(LET_LAST_SEEN_KEY);
try {
var v = storeGet(LET_LAST_SEEN_KEY);
if (v) {
var parsed = JSON.parse(v);
if (parsed && typeof parsed === "object") return parsed;
}
return {};
} catch (e) {
return {};
}
})();
storeWatch(LET_LAST_SEEN_KEY, function (raw) {
try {
var parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") lastSeenByUser = parsed;
} catch (e) {}
});
function getMonitoredUsers() {
var seen = {};
return (letSettings.monitoredUsers || [])
.map(function (name) {
return String(name || "").trim();
})
.filter(function (name) {
var key = name.toLowerCase();
if (!key || seen[key]) return false;
seen[key] = true;
return true;
});
}
function saveLastSeen() {
storeSet(LET_LAST_SEEN_KEY, JSON.stringify(lastSeenByUser));
}
function profilePath(kind, username) {
return "/profile/" + kind + "/" + encodeURIComponent(username);
}
function showNewPostToast(post) {
var existing = document.querySelector(".let-um-toast");
if (existing) existing.remove();
var toast = document.createElement("div");
toast.className = "let-um-toast";
toast.innerHTML =
'× ' +
'New post from ' +
escapeHtml(post.username) +
" " +
'' +
escapeHtml(post.prefix + post.title) +
" " +
'' +
escapeHtml(post.time) +
" ";
document.body.appendChild(toast);
toast
.querySelector(".let-um-toast-close")
.addEventListener("click", function (e) {
e.stopPropagation();
toast.remove();
});
toast.addEventListener("click", function () {
window.location.href = post.href;
});
setTimeout(function () {
if (toast.parentNode) toast.remove();
}, 15000);
}
function sendUserPush(post) {
if (!letSettings.pushEnabled) return;
var fullUrl = post.href.startsWith("http")
? post.href
: window.location.origin + post.href;
sendPushover({
title: post.username + " — " + post.prefix + post.title,
message: post.prefix + post.title + "\n" + post.time,
url: fullUrl,
url_title: "View Post",
});
}
function getInterval() {
return refreshSec * 1000;
}
function startInterval() {
clearInterval(intervalId);
intervalId = setInterval(fetchLatest, getInterval());
}
function startTicker() {
clearInterval(tickerId);
tickerId = setInterval(updateTicker, 1000);
}
function updateTicker() {
var el = banner.querySelector(".let-um-updated");
if (!el || !lastFetchTime) return;
var ago = Math.round((Date.now() - lastFetchTime) / 1000);
el.textContent = "updated " + ago + "s ago";
}
function formatMonitorTime(post) {
if (!post || !post.datetime) return post && post.time ? post.time : "";
var d = new Date(post.datetime);
if (isNaN(d.getTime())) return post.time || "";
var months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
var hour = d.getHours();
var ampm = hour >= 12 ? "PM" : "AM";
hour = hour % 12;
if (hour === 0) hour = 12;
var minute = String(d.getMinutes()).padStart(2, "0");
return (
months[d.getMonth()] +
" " +
d.getDate() +
", " +
String(hour).padStart(2, "0") +
":" +
minute +
" " +
ampm
);
}
function buildPostRow(post) {
if (!post) {
return (
'' +
'Monitored users: ' +
'no posts found ' +
"
"
);
}
return (
'"
);
}
function buildBanner(posts) {
var users = getMonitoredUsers();
var visiblePosts = (posts || []).slice(
0,
users.length > 1 ? 2 : 1,
);
var postHtml = visiblePosts.length
? visiblePosts.map(buildPostRow).join("")
: buildPostRow(null);
banner.classList.toggle(
"let-um-banner-multi",
users.length > 1 && visiblePosts.length > 1,
);
banner.innerHTML =
'' +
postHtml +
"
" +
'' +
' ' +
'' +
'Monitor (s): ' +
' ' +
' ' +
'Enabled: ' +
' ' +
" " +
' ' +
'' +
(lastFetchTime ? "updated 0s ago" : "") +
" " +
'update now ' +
" ";
banner
.querySelector(".let-um-switch")
.addEventListener("click", toggleAuto);
banner
.querySelector(".let-um-refresh")
.addEventListener("click", function (e) {
e.preventDefault();
fetchLatest();
});
var input = banner.querySelector(".let-um-interval");
input.addEventListener("click", function (e) {
e.stopPropagation();
});
input.addEventListener("change", function () {
var val = parseInt(this.value, 10);
if (val && val >= 30) {
refreshSec = val;
letSettings.userMonitorInterval = val;
saveSettings(letSettings);
} else {
this.value = refreshSec;
}
if (autoOn) startInterval();
});
}
function getResultAuthor(item) {
// Look ONLY in .Meta for the byline. Profile links inside the
// comment body — quote attributions ("@user said:") and @mentions —
// would otherwise win and make us treat someone else's reply as if
// it were authored by the monitored user.
var meta = item.querySelector(".Meta");
if (!meta) return "";
var links = meta.querySelectorAll("a[href*='/profile/']");
for (var i = 0; i < links.length; i++) {
var link = links[i];
// Skip the discussion title link (wrapped in ) and any link
// inside a quote (defensive — meta shouldn't contain quotes).
if (link.closest("b") || link.closest("blockquote")) continue;
var href = link.getAttribute("href") || "";
var match = href.match(/\/profile\/([^?#]+)/i);
if (!match) continue;
var parts = match[1].split("/").filter(Boolean);
if (!parts.length) continue;
return decodeURIComponent(parts[parts.length - 1]).trim();
}
return "";
}
function parseComment(doc, username) {
var items = Array.prototype.slice.call(
doc.querySelectorAll("ul.DataList.SearchResults li.Item"),
);
if (!items.length) return null;
var item = null;
for (var i = 0; i < items.length; i++) {
var author = getResultAuthor(items[i]);
if (author && author.toLowerCase() === username.toLowerCase()) {
item = items[i];
break;
}
}
if (!item) return null;
var titleLink = item.querySelector(".Meta .MItem b a");
var timeEl = item.querySelector(".Meta .MItem:last-child a");
// Comments page: time may be a tag or just text in an
var timeTag = item.querySelector(".Meta time");
var title = titleLink ? titleLink.textContent.trim() : "";
var href = titleLink ? titleLink.getAttribute("href") : "#";
var time = timeEl ? timeEl.textContent.trim() : "";
var datetime = timeTag ? timeTag.getAttribute("datetime") : null;
// If no tag, the comment ID in the li id tells us order;
// but more reliably, extract from the comment permalink's title attr
if (!datetime) {
// Try the inside the last MItem link
var innerTime = timeEl ? timeEl.querySelector("time") : null;
if (innerTime) {
datetime = innerTime.getAttribute("datetime");
} else {
// Comments without tag — parse displayed time text
var timeText = time; // e.g. "12:56PM", "March 5", "March 6, 2025"
var todayMatch = timeText.match(/^(\d{1,2}):(\d{2})(AM|PM)$/i);
if (todayMatch) {
var h = parseInt(todayMatch[1], 10);
var m = parseInt(todayMatch[2], 10);
var ampm = todayMatch[3].toUpperCase();
if (ampm === "PM" && h !== 12) h += 12;
if (ampm === "AM" && h === 12) h = 0;
var now = new Date();
var candidate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
h,
m,
0,
);
// A bare clock time means "today" — unless that lands in the
// future (we're just past midnight), then it was yesterday.
if (candidate.getTime() > now.getTime() + 60000) {
candidate.setDate(candidate.getDate() - 1);
}
datetime = candidate.toISOString();
} else {
// Parse "Month Day" or "Month Day, Year" format
var months = {
January: 0,
February: 1,
March: 2,
April: 3,
May: 4,
June: 5,
July: 6,
August: 7,
September: 8,
October: 9,
November: 10,
December: 11,
};
var dateMatch = timeText.match(
/^(\w+)\s+(\d{1,2})(?:,?\s*(\d{4}))?$/,
);
if (dateMatch && months[dateMatch[1]] !== undefined) {
var yr = dateMatch[3]
? parseInt(dateMatch[3], 10)
: new Date().getFullYear();
datetime = new Date(
yr,
months[dateMatch[1]],
parseInt(dateMatch[2], 10),
).toISOString();
}
}
}
}
return { title: title, href: href, time: time, datetime: datetime };
}
function parseDiscussion(doc, username) {
// /profile/discussions/ lists discussions started by X, sorted by
// last activity. The displayed timestamp comes from .LastCommentDate
// which advances whenever ANY user replies (often with a quote of
// X). To avoid alerting when someone else replied to X's old thread,
// only accept items where LastCommentBy === X — meaning either X
// just started the thread (no replies yet) or X posted the most
// recent reply.
var items = doc.querySelectorAll(
"ul.DataList.Discussions li.ItemDiscussion",
);
for (var i = 0; i < items.length; i++) {
var item = items[i];
var lastByLink = item.querySelector(
".LastCommentBy a[href*='/profile/']",
);
var lastByName = "";
if (lastByLink) {
var lbHref = lastByLink.getAttribute("href") || "";
var lbMatch = lbHref.match(/\/profile\/([^?#]+)/i);
if (lbMatch) {
var lbParts = lbMatch[1].split("/").filter(Boolean);
if (lbParts.length) {
lastByName = decodeURIComponent(
lbParts[lbParts.length - 1],
).trim();
}
}
if (!lastByName) lastByName = lastByLink.textContent.trim();
}
if (
username &&
lastByName &&
lastByName.toLowerCase() !== username.toLowerCase()
) {
continue;
}
var titleLink = item.querySelector(".Title a");
var timeTag = item.querySelector(".LastCommentDate time");
var title = titleLink ? titleLink.textContent.trim() : "";
var href = titleLink ? titleLink.getAttribute("href") : "#";
var time = timeTag ? timeTag.textContent.trim() : "";
var datetime = timeTag ? timeTag.getAttribute("datetime") : null;
return { title: title, href: href, time: time, datetime: datetime };
}
return null;
}
function fetchLatest() {
var users = getMonitoredUsers();
var results = [];
var total = users.length * 2;
var done = 0;
if (!users.length) {
lastFetchTime = Date.now();
latestPosts = [];
buildBanner(latestPosts);
return;
}
function finish() {
done++;
if (done < total) return;
lastFetchTime = Date.now();
latestPosts = users
.map(function (username) {
var matches = results.filter(function (post) {
return post && post.username === username;
});
matches.sort(function (a, b) {
return (b.ts || 0) - (a.ts || 0);
});
return matches[0] || null;
})
.filter(Boolean)
.sort(function (a, b) {
return (b.ts || 0) - (a.ts || 0);
});
// Only the leader tab advances last-seen and notifies. If a
// follower did it, the shared last-seen would move forward and
// the leader would never send the notification at all.
if (amLeader()) {
var newPosts = [];
latestPosts.forEach(function (post) {
if (!post.ts) return;
var key = post.username.toLowerCase();
var prev = Number(lastSeenByUser[key]) || null;
if (prev !== null && post.ts > prev) newPosts.push(post);
if (prev === null || post.ts > prev)
lastSeenByUser[key] = post.ts;
});
if (newPosts.length) {
showNewPostToast(newPosts[0]);
newPosts.forEach(sendUserPush);
}
saveLastSeen();
}
buildBanner(latestPosts);
}
function fetchPage(username, url, parser, prefix) {
var xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.onload = function () {
if (xhr.status === 200) {
var doc = new DOMParser().parseFromString(
xhr.responseText,
"text/html",
);
var post = parser(doc, username);
if (post) {
post.username = username;
post.prefix = prefix;
post.ts = post.datetime ? new Date(post.datetime).getTime() : 0;
results.push(post);
}
}
finish();
};
xhr.onerror = function () {
finish();
};
xhr.send();
}
users.forEach(function (username) {
fetchPage(
username,
profilePath("comments", username),
parseComment,
"RE: ",
);
fetchPage(
username,
profilePath("discussions", username),
parseDiscussion,
"",
);
});
}
function toggleAuto() {
autoOn = !autoOn;
letSettings.userMonitorAutoOn = autoOn;
saveSettings(letSettings);
if (autoOn) {
fetchLatest();
startInterval();
} else {
clearInterval(intervalId);
intervalId = null;
}
var sw = banner.querySelector(".let-um-switch");
if (sw) sw.classList.toggle("on", autoOn);
}
// After building banner, re-append the settings wrap if it exists
var settingsWrap = null;
var origBuildBanner = buildBanner;
buildBanner = function (posts) {
origBuildBanner(posts);
if (settingsWrap) banner.appendChild(settingsWrap);
};
// Create settings link + flyout, appended to the banner
(function () {
settingsWrap = document.createElement("span");
settingsWrap.className = "let-flyout-wrap";
settingsWrap.style.cssText =
"flex-shrink:0!important;display:inline-flex!important;align-items:center!important;gap:6px!important;";
var divider = document.createElement("span");
divider.className = "let-um-divider";
settingsWrap.insertBefore(divider, settingsWrap.firstChild);
// Auto-refresh page toggle
var arLabel = document.createElement("span");
arLabel.className = "let-um-unit";
arLabel.textContent = "Refresh (s):";
settingsWrap.appendChild(arLabel);
var arInput = document.createElement("input");
arInput.type = "text";
arInput.className = "let-um-interval";
arInput.value = letSettings.autoRefreshSec;
arInput.title = "Page refresh interval in seconds";
settingsWrap.appendChild(arInput);
var arSwitch = document.createElement("span");
arSwitch.className =
"let-um-switch" + (letSettings.autoRefreshOn ? " on" : "");
var arTimerId = null;
function startAutoRefresh() {
if (arTimerId) clearInterval(arTimerId);
arTimerId = setInterval(
reloadContent,
letSettings.autoRefreshSec * 1000,
);
}
arSwitch.addEventListener("click", function () {
letSettings.autoRefreshOn = !letSettings.autoRefreshOn;
arSwitch.classList.toggle("on", letSettings.autoRefreshOn);
saveSettings(letSettings);
if (letSettings.autoRefreshOn) {
startAutoRefresh();
} else {
clearInterval(arTimerId);
arTimerId = null;
}
});
settingsWrap.appendChild(arSwitch);
arInput.addEventListener("change", function () {
var val = parseInt(this.value, 10);
if (!val || val < 5) val = 5;
letSettings.autoRefreshSec = val;
this.value = val;
saveSettings(letSettings);
if (letSettings.autoRefreshOn) startAutoRefresh();
});
if (letSettings.autoRefreshOn) startAutoRefresh();
var dividerAR = document.createElement("span");
dividerAR.className = "let-um-divider";
settingsWrap.appendChild(dividerAR);
// Push notifications toggle
var pushLabel = document.createElement("span");
pushLabel.className = "let-um-unit";
pushLabel.textContent = "Push:";
settingsWrap.appendChild(pushLabel);
var pushSwitch = document.createElement("span");
pushSwitch.className =
"let-um-switch let-push-switch" +
(letSettings.pushEnabled ? " on" : "");
pushSwitch.addEventListener("click", function () {
letSettings.pushEnabled = !letSettings.pushEnabled;
pushSwitch.classList.toggle("on", letSettings.pushEnabled);
saveSettings(letSettings);
});
settingsWrap.appendChild(pushSwitch);
var divider2 = document.createElement("span");
divider2.className = "let-um-divider";
settingsWrap.appendChild(divider2);
var link = document.createElement("a");
link.className = "let-settings-link";
link.href = "#";
link.textContent = "Userscript Settings";
settingsWrap.appendChild(link);
var flyout = document.createElement("div");
flyout.className = "let-flyout let-settings-flyout";
flyout.innerHTML =
'Appearance ' +
"Theme " +
'' +
'Dark " +
'Light " +
" " +
" " +
"Accent " +
' ' +
" " +
"Sidebar " +
'" +
" " +
"Compact Rows " +
' " +
" " +
"Post Title Size " +
' ' +
'' +
letSettings.titleFontSize +
"px " +
" " +
"Meta Info Size " +
' ' +
'' +
letSettings.metaFontSize +
"px " +
" " +
"Reading Pane " +
' " +
" " +
"Post Counter Bar " +
' " +
" " +
" " +
'Pushover ' +
' ' +
' ' +
" " +
'Category Alerts ' +
"Offers " +
' " +
" " +
"Service Transfers " +
' " +
" " +
'Check interval (seconds, min 30) ' +
' ' +
" " +
'Monitored Users ' +
'' +
'' +
' ' +
'Add ' +
"
" +
" " +
'Ignored Message Text ' +
' ' +
'Ignored Users ' +
'' +
'' +
' ' +
'Add ' +
"
" +
'Reset to Defaults ' +
'Test Notification ' +
'";
settingsWrap.appendChild(flyout);
// --- appearance controls ---
var selTheme = flyout.querySelector(".let-sel-theme");
var selSidebar = flyout.querySelector(".let-sel-sidebar");
var swatchBox = flyout.querySelector(".let-swatches");
LETM_ACCENTS.forEach(function (c) {
var sw = document.createElement("button");
sw.type = "button";
sw.className = "letm-sw";
sw.dataset.accent = c;
sw.style.background = c;
sw.title = c;
sw.addEventListener("click", function (e) {
e.preventDefault();
setAccent(c);
});
swatchBox.appendChild(sw);
});
selTheme.addEventListener("change", function () {
setTheme(this.value);
});
var chkCompact = flyout.querySelector(".let-chk-compact");
chkCompact.addEventListener("change", function () {
letSettings.compact = this.checked;
saveSettings(letSettings);
applyDynamicStyles();
measureChrome();
});
selSidebar.addEventListener("change", function () {
letSettings.sidebarMode = this.value;
// Keep the legacy flag in step for anything still reading it.
letSettings.hideSidebar = this.value === "hidden";
saveSettings(letSettings);
applyDynamicStyles();
measureChrome();
});
// Reflect theme/accent changes made from the floating controls.
letmSyncSettingsUI = function () {
selTheme.value = letSettings.theme;
selSidebar.value = letSettings.sidebarMode;
swatchBox.querySelectorAll(".letm-sw").forEach(function (sw) {
sw.classList.toggle("on", sw.dataset.accent === letSettings.accent);
});
};
letmSyncSettingsUI();
flyout
.querySelector(".let-test-toast")
.addEventListener("click", function () {
var testPost = {
username: getMonitoredUsers()[0] || "example_user",
prefix: "RE: ",
title: "Test Post - This is a sample user monitor notification!",
href: "#",
time: "just now",
};
showNewPostToast(testPost);
sendUserPush(testPost);
});
var rangeTitle = flyout.querySelector(".let-range-title");
var rangeMeta = flyout.querySelector(".let-range-meta");
var chkReadPane = flyout.querySelector(".let-chk-readpane");
var valTitle = flyout.querySelector(".let-val-title");
var valMeta = flyout.querySelector(".let-val-meta");
var pushUserInput = flyout.querySelector(".let-push-user");
var pushTokenInput = flyout.querySelector(".let-push-token");
pushUserInput.addEventListener("change", function () {
letSettings.pushoverUser = this.value.trim();
saveSettings(letSettings);
});
pushTokenInput.addEventListener("change", function () {
letSettings.pushoverToken = this.value.trim();
saveSettings(letSettings);
});
// Category notification subscriptions
var chkCatOffers = flyout.querySelector(".let-chk-cat-offers");
var chkCatTransfers = flyout.querySelector(".let-chk-cat-transfers");
var catIntervalInput = flyout.querySelector(".let-cat-interval");
function restartCatPoller() {
if (window.letCategoryPoller) window.letCategoryPoller.restart();
}
chkCatOffers.addEventListener("change", function () {
letSettings.catNotifOffers = this.checked;
saveSettings(letSettings);
restartCatPoller();
});
chkCatTransfers.addEventListener("change", function () {
letSettings.catNotifTransfers = this.checked;
saveSettings(letSettings);
restartCatPoller();
});
catIntervalInput.addEventListener("change", function () {
var v = parseInt(this.value, 10);
if (!v || v < 30) v = 30;
letSettings.catNotifIntervalSec = v;
this.value = v;
saveSettings(letSettings);
restartCatPoller();
});
// Monitored users management in settings
var monitoredList = flyout.querySelector(".let-monitored-list");
var monitorAddInput = flyout.querySelector(".let-monitor-add-input");
var monitorAddBtn = flyout.querySelector(".let-monitor-add-btn");
function renderMonitoredList() {
monitoredList.innerHTML = "";
var users = getMonitoredUsers();
letSettings.monitoredUsers = users;
if (users.length === 0) {
monitoredList.innerHTML =
'No monitored users ';
return;
}
users.forEach(function (username) {
var li = document.createElement("li");
var nameSpan = document.createElement("span");
nameSpan.textContent = username;
var removeBtn = document.createElement("button");
removeBtn.className = "let-unmonitor-btn";
removeBtn.textContent = "\u2715";
removeBtn.title = "Stop monitoring " + username;
removeBtn.addEventListener("click", function () {
letSettings.monitoredUsers = getMonitoredUsers().filter(
function (u) {
return u.toLowerCase() !== username.toLowerCase();
},
);
saveSettings(letSettings);
renderMonitoredList();
fetchLatest();
});
li.appendChild(nameSpan);
li.appendChild(removeBtn);
monitoredList.appendChild(li);
});
}
function addMonitoredFromInput() {
var name = monitorAddInput.value.trim();
if (!name) return;
var users = getMonitoredUsers();
var already = users.some(function (u) {
return u.toLowerCase() === name.toLowerCase();
});
if (!already) {
users.push(name);
letSettings.monitoredUsers = users;
saveSettings(letSettings);
}
monitorAddInput.value = "";
renderMonitoredList();
fetchLatest();
}
monitorAddBtn.addEventListener("click", addMonitoredFromInput);
monitorAddInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault();
addMonitoredFromInput();
}
});
renderMonitoredList();
// Ignored users management in settings
var ignoredList = flyout.querySelector(".let-ignored-list");
var ignoreAddInput = flyout.querySelector(".let-ignore-add-input");
var ignoreAddBtn = flyout.querySelector(".let-ignore-add-btn");
renderIgnoredList = function () {
ignoredList.innerHTML = "";
if (letSettings.ignoredUsers.length === 0) {
ignoredList.innerHTML =
'No ignored users ';
return;
}
letSettings.ignoredUsers.forEach(function (username) {
var li = document.createElement("li");
var nameSpan = document.createElement("span");
nameSpan.textContent = username;
var removeBtn = document.createElement("button");
removeBtn.className = "let-unignore-btn";
removeBtn.textContent = "\u2715";
removeBtn.title = "Unignore " + username;
removeBtn.addEventListener("click", function () {
letSettings.ignoredUsers = letSettings.ignoredUsers.filter(
function (u) {
return u.toLowerCase() !== username.toLowerCase();
},
);
saveSettings(letSettings);
renderIgnoredList();
});
li.appendChild(nameSpan);
li.appendChild(removeBtn);
ignoredList.appendChild(li);
});
};
function addIgnoredFromInput() {
var name = ignoreAddInput.value.trim();
if (!name) return;
var already = letSettings.ignoredUsers.some(function (u) {
return u.toLowerCase() === name.toLowerCase();
});
if (!already) {
letSettings.ignoredUsers.push(name);
saveSettings(letSettings);
}
ignoreAddInput.value = "";
renderIgnoredList();
}
ignoreAddBtn.addEventListener("click", addIgnoredFromInput);
ignoreAddInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault();
addIgnoredFromInput();
}
});
renderIgnoredList();
var ignoredTextInput = flyout.querySelector(".let-ignored-text");
ignoredTextInput.addEventListener("change", function () {
letSettings.ignoredText = this.value || LET_DEFAULTS.ignoredText;
saveSettings(letSettings);
});
rangeTitle.addEventListener("input", function () {
var v = parseInt(this.value, 10);
letSettings.titleFontSize = v;
valTitle.textContent = v + "px";
applyDynamicStyles();
saveSettings(letSettings);
});
rangeMeta.addEventListener("input", function () {
var v = parseInt(this.value, 10);
letSettings.metaFontSize = v;
valMeta.textContent = v + "px";
applyDynamicStyles();
saveSettings(letSettings);
});
chkReadPane.addEventListener("change", function () {
letSettings.readingPane = this.checked;
applyDynamicStyles();
saveSettings(letSettings);
toggleReadingPane(this.checked);
});
var chkPostCounter = flyout.querySelector(".let-chk-postcounter");
chkPostCounter.addEventListener("change", function () {
letSettings.postCounter = this.checked;
saveSettings(letSettings);
updatePostCounter();
});
flyout
.querySelector(".let-settings-reset")
.addEventListener("click", function () {
letSettings.titleFontSize = LET_DEFAULTS.titleFontSize;
letSettings.metaFontSize = LET_DEFAULTS.metaFontSize;
letSettings.hideSidebar = LET_DEFAULTS.hideSidebar;
letSettings.sidebarMode = LET_DEFAULTS.sidebarMode;
letSettings.compact = LET_DEFAULTS.compact;
chkCompact.checked = LET_DEFAULTS.compact;
letSettings.theme = LET_DEFAULTS.theme;
letSettings.accent = LET_DEFAULTS.accent;
applyTheme();
letmSyncSettingsUI();
letSettings.pushEnabled = LET_DEFAULTS.pushEnabled;
letSettings.pushoverUser = LET_DEFAULTS.pushoverUser;
letSettings.pushoverToken = LET_DEFAULTS.pushoverToken;
rangeTitle.value = LET_DEFAULTS.titleFontSize;
rangeMeta.value = LET_DEFAULTS.metaFontSize;
valTitle.textContent = LET_DEFAULTS.titleFontSize + "px";
valMeta.textContent = LET_DEFAULTS.metaFontSize + "px";
letSettings.readingPane = LET_DEFAULTS.readingPane;
chkReadPane.checked = LET_DEFAULTS.readingPane;
toggleReadingPane(false);
letSettings.postCounter = LET_DEFAULTS.postCounter;
chkPostCounter.checked = LET_DEFAULTS.postCounter;
updatePostCounter();
pushUserInput.value = "";
pushTokenInput.value = "";
pushSwitch.classList.remove("on");
letSettings.autoRefreshOn = LET_DEFAULTS.autoRefreshOn;
letSettings.autoRefreshSec = LET_DEFAULTS.autoRefreshSec;
arInput.value = LET_DEFAULTS.autoRefreshSec;
arSwitch.classList.remove("on");
clearInterval(arTimerId);
arTimerId = null;
letSettings.userMonitorAutoOn =
LET_DEFAULTS.userMonitorAutoOn;
letSettings.userMonitorInterval =
LET_DEFAULTS.userMonitorInterval;
letSettings.monitoredUsers =
LET_DEFAULTS.monitoredUsers.slice();
autoOn = LET_DEFAULTS.userMonitorAutoOn;
refreshSec = LET_DEFAULTS.userMonitorInterval;
clearInterval(intervalId);
intervalId = null;
lastSeenByUser = {};
storeRemove(LET_LAST_SEEN_KEY);
renderMonitoredList();
fetchLatest();
letSettings.ignoredUsers = [];
letSettings.ignoredText = LET_DEFAULTS.ignoredText;
ignoredTextInput.value = LET_DEFAULTS.ignoredText;
renderIgnoredList();
letSettings.catNotifOffers = LET_DEFAULTS.catNotifOffers;
letSettings.catNotifTransfers = LET_DEFAULTS.catNotifTransfers;
letSettings.catNotifIntervalSec = LET_DEFAULTS.catNotifIntervalSec;
letSettings.catNotifLastSeen = {};
chkCatOffers.checked = false;
chkCatTransfers.checked = false;
catIntervalInput.value = LET_DEFAULTS.catNotifIntervalSec;
restartCatPoller();
applyDynamicStyles();
saveSettings(letSettings);
});
link.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation();
flyout.classList.toggle("let-flyout-open");
});
document.addEventListener("click", function (e) {
if (!settingsWrap.contains(e.target)) {
flyout.classList.remove("let-flyout-open");
}
});
banner.appendChild(settingsWrap);
})();
// With nobody monitored yet, fall through to the banner's empty state
// rather than inventing a placeholder user to "load".
var bootUser = getMonitoredUsers()[0];
buildBanner(
bootUser
? [
{
username: bootUser,
prefix: "",
title: "loading...",
href: "#",
time: "",
ts: 0,
},
]
: [],
);
fetchLatest();
startTicker();
if (autoOn) startInterval();
})();
// Category notification poller — watches selected categories for net-new
// discussions (not replies) by tracking the max Discussion ID seen.
window.letCategoryPoller = (function () {
var timerId = null;
function isEnabled(cat) {
if (cat.key === "offers") return !!letSettings.catNotifOffers;
if (cat.key === "transfers") return !!letSettings.catNotifTransfers;
return false;
}
function sendPush(cat, post) {
var href = post.href || "";
var fullUrl = href.startsWith("http")
? href
: window.location.origin + href;
sendPushover({
title: "New in " + cat.label,
message: post.title,
url: fullUrl,
url_title: "View Post",
});
}
function checkCategory(cat) {
fetch("/categories/" + cat.slug, { credentials: "same-origin" })
.then(function (r) {
return r.text();
})
.then(function (html) {
var doc = new DOMParser().parseFromString(html, "text/html");
var items = doc.querySelectorAll(
"ul.DataList.Discussions > li.ItemDiscussion",
);
var posts = [];
items.forEach(function (li) {
var m = (li.id || "").match(/Discussion[_-]?(\d+)/i);
if (!m) return;
var id = parseInt(m[1], 10);
if (!id) return;
var titleA = li.querySelector(".Title a") || li.querySelector("h3 a");
if (!titleA) return;
posts.push({
id: id,
title: titleA.textContent.trim(),
href: titleA.getAttribute("href"),
});
});
if (!posts.length) return;
var maxId = posts.reduce(function (a, p) {
return p.id > a ? p.id : a;
}, 0);
var lastSeen = Number(letSettings.catNotifLastSeen[cat.slug] || 0);
if (!lastSeen) {
// First observation — record current max without notifying
letSettings.catNotifLastSeen[cat.slug] = maxId;
saveSettings(letSettings);
return;
}
var fresh = posts
.filter(function (p) {
return p.id > lastSeen;
})
.sort(function (a, b) {
return a.id - b.id;
});
fresh.forEach(function (p) {
sendPush(cat, p);
});
if (maxId > lastSeen) {
letSettings.catNotifLastSeen[cat.slug] = maxId;
saveSettings(letSettings);
}
})
.catch(function () {});
}
function tick() {
// The category poller has no per-tab UI, so followers skip the
// fetch entirely — one tab polling is enough.
if (!amLeader()) return;
LET_CATEGORIES.forEach(function (cat) {
if (isEnabled(cat)) checkCategory(cat);
});
}
function start() {
stop();
var anyOn = LET_CATEGORIES.some(isEnabled);
if (!anyOn) return;
var interval = Math.max(
30,
Number(letSettings.catNotifIntervalSec) || 300,
);
timerId = setInterval(tick, interval * 1000);
}
function stop() {
if (timerId) {
clearInterval(timerId);
timerId = null;
}
}
function restart() {
start();
tick();
}
// Kick off an initial check shortly after page load so first-run
// baselining happens promptly without blocking page render.
setTimeout(tick, 3000);
start();
return { restart: restart, tick: tick };
})();
} // end !IS_IFRAME
// Refresh just the content column in place — preserves the header
// (monitor banner stays running), the sidebar, the reading pane, and the
// user's scroll position. Falls back to a full reload on failure.
function reloadContent() {
if (IS_IFRAME) return;
fetch(window.location.href, { credentials: "same-origin" })
.then(function (r) {
return r.text();
})
.then(function (html) {
var doc = new DOMParser().parseFromString(html, "text/html");
var newCol = doc.querySelector(".Column.ContentColumn");
var oldCol = document.querySelector(".Column.ContentColumn");
if (!newCol || !oldCol) {
window.location.reload();
return;
}
oldCol.innerHTML = newCol.innerHTML;
restructureDiscussionRows();
restructureCommentHeaders();
setupIgnoreUsersOnComments();
setupSortUI();
updatePostCounter();
// Scroll to the most recent post — but only while auto-refresh is
// still enabled. The deferred handlers below (next-frame, image
// load, 500ms safety net) all re-check the setting so they don't
// snap the user back to the bottom after they've turned auto-
// refresh off mid-cycle.
var scrollToBottom = function () {
if (!letSettings.autoRefreshOn) return;
window.scrollTo(0, document.documentElement.scrollHeight);
};
scrollToBottom();
requestAnimationFrame(scrollToBottom);
oldCol.querySelectorAll("img").forEach(function (img) {
if (img.complete) return;
img.addEventListener("load", scrollToBottom, { once: true });
img.addEventListener("error", scrollToBottom, { once: true });
});
setTimeout(scrollToBottom, 500);
})
.catch(function () {
window.location.reload();
});
}
// Collapse Vanilla's stacked discussion markup into one dense row:
// [unread dot] avatar · title · [tags cat comments views new by date] · bookmark
// The meta spans get fixed grid columns in CSS so every row lines up.
function restructureDiscussionRows() {
document
.querySelectorAll("ul.DataList.Discussions > li.ItemDiscussion")
.forEach(function (li) {
if (li.classList.contains("let-done")) return;
const photoLink = li.querySelector(":scope > a.IndexPhoto");
const content = li.querySelector(":scope > .ItemContent.Discussion");
const options = li.querySelector(":scope > .Options");
if (!content) return;
const titleDiv = content.querySelector(".Title");
const meta = content.querySelector(".Meta.Meta-Discussion");
// 1. Avatar
const avatar = document.createElement("span");
avatar.className = "let-avatar";
if (photoLink) {
const img = photoLink.querySelector("img");
if (img) {
const newImg = document.createElement("img");
newImg.src = img.src;
newImg.alt = img.alt || "";
newImg.title = img.alt || "";
avatar.appendChild(newImg);
}
}
// 2. Title
const title = document.createElement("span");
title.className = "let-title";
if (titleDiv) {
const link = titleDiv.querySelector("a");
if (link) title.appendChild(link.cloneNode(true));
}
// 3. Meta grid
const metaSpan = document.createElement("span");
metaSpan.className = "let-meta";
function addCount(sourceSel, cls) {
const src = meta.querySelector(sourceSel);
if (!src) return null;
const wrap = document.createElement("span");
wrap.className = cls;
const num = src.querySelector(".Number");
const n = document.createElement("span");
n.className = "let-num";
n.textContent = num ? num.textContent.trim() : "";
if (num && num.getAttribute("title")) {
wrap.title = num.getAttribute("title");
}
wrap.appendChild(n);
metaSpan.appendChild(wrap);
return wrap;
}
if (meta) {
// Tags (Announcement / Closed) share one cell so the grid holds.
const tags = meta.querySelectorAll(".Tag");
if (tags.length) {
const tagWrap = document.createElement("span");
tagWrap.className = "let-tags";
tags.forEach(function (tag) {
const t = document.createElement("span");
t.className = "let-tag " + tag.className;
t.textContent = tag.textContent.trim();
tagWrap.appendChild(t);
});
metaSpan.appendChild(tagWrap);
}
// Category, with its colour dot
const cat = meta.querySelector(".Category");
if (cat) {
const g = document.createElement("span");
g.className = "let-cat";
const catLink = cat.querySelector("a");
if (catLink) {
const a = catLink.cloneNode(true);
a.insertBefore(
letmCategoryDot(a.getAttribute("href")),
a.firstChild,
);
g.appendChild(a);
} else {
g.textContent = cat.textContent.trim();
}
metaSpan.appendChild(g);
}
addCount(".CommentCount", "let-comments");
addCount(".ViewCount", "let-views");
// "N new" badge
const hasNew = meta.querySelector(".HasNew");
if (hasNew) {
const n = document.createElement("span");
n.className = "let-new";
n.textContent = hasNew.textContent.trim();
metaSpan.appendChild(n);
}
// Most recent by — name only, the "Most recent by" prose is noise
const lastBy = meta.querySelector(".LastCommentBy");
if (lastBy) {
const r = document.createElement("span");
r.className = "let-recent";
const byLink = lastBy.querySelector("a");
if (byLink) {
r.appendChild(byLink.cloneNode(true));
r.title = "Most recent by " + byLink.textContent.trim();
} else {
r.textContent = lastBy.textContent.trim();
}
metaSpan.appendChild(r);
} else {
// No replies yet — fall back to the thread starter.
const author = meta.querySelector(".DiscussionAuthor");
if (author) {
const a = document.createElement("span");
a.className = "let-recent";
a.innerHTML = author.innerHTML;
a.title = "Started by " + author.textContent.trim();
metaSpan.appendChild(a);
}
}
// Time
const lastDate = meta.querySelector(".LastCommentDate");
if (lastDate) {
const d = document.createElement("span");
d.className = "let-date";
const t = lastDate.querySelector("time");
d.textContent = (t || lastDate).textContent.trim();
if (t && t.getAttribute("title")) d.title = t.getAttribute("title");
metaSpan.appendChild(d);
}
}
// 4. Bookmark
const bookmark = document.createElement("span");
bookmark.className = "let-bookmark";
if (options) {
const bmLink = options.querySelector("a.Bookmark");
if (bmLink) bookmark.appendChild(bmLink);
const optMenu = options.querySelector(".OptionsMenu");
if (optMenu) bookmark.appendChild(optMenu);
}
// Store sortable data on li
if (meta) {
var vc = meta.querySelector(".ViewCount .Number");
li.setAttribute(
"data-views",
vc ? parseInt(vc.textContent.replace(/,/g, ""), 10) || 0 : 0,
);
var cc = meta.querySelector(".CommentCount .Number");
li.setAttribute(
"data-comments",
cc ? parseInt(cc.textContent.replace(/,/g, ""), 10) || 0 : 0,
);
var ldt = meta.querySelector(".LastCommentDate time");
li.setAttribute(
"data-lastpost",
ldt ? ldt.getAttribute("datetime") || "" : "",
);
}
li.appendChild(avatar);
li.appendChild(title);
li.appendChild(metaSpan);
li.appendChild(bookmark);
li.classList.add("let-done");
});
// Category links in the sidebar get the same dots as the rows.
document
.querySelectorAll('.Column.PanelColumn a[href*="/categories/"]')
.forEach(function (a) {
if (a.querySelector(".letm-dot")) return;
a.insertBefore(letmCategoryDot(a.getAttribute("href")), a.firstChild);
});
insertColumnHeader();
}
// Label the meta columns. Built from the same classes a row uses, so it
// picks up the identical grid template — including the responsive
// breakpoints that resize or drop columns — and can never drift out of
// step with the rows below it.
function insertColumnHeader() {
var list = document.querySelector("ul.DataList.Discussions");
if (!list || !list.querySelector("li.ItemDiscussion.let-done")) return;
if (
list.previousElementSibling &&
list.previousElementSibling.classList.contains("letm-colhead")
) {
return;
}
var head = document.createElement("div");
head.className = "letm-colhead";
head.setAttribute("aria-hidden", "true");
function cell(cls, label) {
var s = document.createElement("span");
s.className = cls;
if (label) s.textContent = label;
return s;
}
head.appendChild(cell("let-avatar"));
head.appendChild(cell("let-title", "Discussion"));
var meta = cell("let-meta");
// Empty tags cell: like a row with no Announcement/Closed tag, it
// collapses to zero and the remaining columns stay right-anchored.
meta.appendChild(cell("let-tags"));
// Category pages drop the per-row category, so only label that column
// when something actually fills it. The cell still holds its width.
var hasCategory = !!list.querySelector("li.ItemDiscussion .let-cat a");
meta.appendChild(cell("let-cat", hasCategory ? "Category" : ""));
meta.appendChild(cell("let-comments", "Replies"));
meta.appendChild(cell("let-views", "Views"));
meta.appendChild(cell("let-new", "New"));
meta.appendChild(cell("let-recent", "Last post by"));
meta.appendChild(cell("let-date", "Date"));
head.appendChild(meta);
head.appendChild(cell("let-bookmark"));
list.parentNode.insertBefore(head, list);
}
restructureDiscussionRows();
restructureCommentHeaders();
// --- Ignore users on discussion pages ---
function setupIgnoreUsersOnComments() {
var comments = document.querySelectorAll(".ItemComment");
if (!comments.length) return;
function isIgnored(username) {
var lower = username.toLowerCase();
return letSettings.ignoredUsers.some(function (u) {
return u.toLowerCase() === lower;
});
}
function addIgnored(username) {
if (!isIgnored(username)) {
letSettings.ignoredUsers.push(username);
saveSettings(letSettings);
}
}
function removeIgnored(username) {
var lower = username.toLowerCase();
letSettings.ignoredUsers = letSettings.ignoredUsers.filter(function (u) {
return u.toLowerCase() !== lower;
});
saveSettings(letSettings);
}
// Hide/restore quote bodies by moving the original nodes into a hidden
// wrapper instead of rewriting innerHTML, so handlers the site attached
// inside the quote (spoilers, embeds) survive an ignore round-trip.
function setQuoteIgnored(quoteText, authorLink, ignored) {
var hidden = quoteText.querySelector(":scope > .let-quote-original");
var placeholder = quoteText.querySelector(
":scope > .let-quote-placeholder",
);
if (ignored) {
if (hidden) return;
hidden = document.createElement("div");
hidden.className = "let-quote-original";
hidden.style.display = "none";
while (quoteText.firstChild) hidden.appendChild(quoteText.firstChild);
quoteText.appendChild(hidden);
placeholder = document.createElement("div");
placeholder.className = "let-quote-placeholder";
var attribution = document.createElement("p");
attribution.appendChild(authorLink.cloneNode(true));
attribution.appendChild(document.createTextNode(":"));
var body = document.createElement("p");
body.textContent = letSettings.ignoredText;
placeholder.appendChild(attribution);
placeholder.appendChild(body);
quoteText.appendChild(placeholder);
} else {
if (placeholder) placeholder.remove();
if (hidden) {
while (hidden.firstChild) {
quoteText.insertBefore(hidden.firstChild, hidden);
}
hidden.remove();
}
}
}
function applyQuoteIgnores(msg) {
var quotes = msg.querySelectorAll("blockquote.UserQuote");
quotes.forEach(function (q) {
var quoteText = q.querySelector(".QuoteText");
if (!quoteText) return;
var authorLink = quoteText.querySelector("a");
if (!authorLink) return;
var m = authorLink.textContent.trim().match(/^@(.+?)\s+said/i);
if (!m) return;
setQuoteIgnored(quoteText, authorLink, isIgnored(m[1]));
});
}
// Same idea for whole comments: the real .Message is hidden, and a
// sibling placeholder (also class Message, so it picks up the
// .let-ignored-post styling) carries the ignored text.
function applyIgnore(comment, username) {
var msg = comment.querySelector(".Message");
if (!msg) return;
var placeholder = comment.querySelector(".let-ignored-msg");
if (isIgnored(username)) {
if (!placeholder) {
placeholder = document.createElement("div");
placeholder.className = "Message let-ignored-msg";
msg.parentNode.insertBefore(placeholder, msg.nextSibling);
}
placeholder.textContent = "";
var p = document.createElement("p");
p.textContent = letSettings.ignoredText;
placeholder.appendChild(p);
msg.style.display = "none";
comment.classList.add("let-ignored-post");
return;
}
if (placeholder) placeholder.remove();
msg.style.display = "";
comment.classList.remove("let-ignored-post");
applyQuoteIgnores(msg);
}
comments.forEach(function (comment) {
var usernameEl =
comment.querySelector(".Username a") ||
comment.querySelector(".Username");
if (!usernameEl) return;
var username = usernameEl.textContent.trim();
if (!username) return;
// Apply ignore on load
applyIgnore(comment, username);
// Add Ignore/Unignore link into CommentInfo meta (next to Flag/Thanks)
var commentInfo = comment.querySelector(".Meta.CommentMeta.CommentInfo");
if (!commentInfo) commentInfo = comment.querySelector(".CommentMeta");
var ignWrap = document.createElement("span");
ignWrap.className = "MItem let-ignore-wrap";
var ignLink = document.createElement("a");
ignLink.className = "let-ignore-link";
ignLink.href = "#";
ignLink.textContent = isIgnored(username) ? "Unignore" : "Ignore";
ignLink.addEventListener("click", function (e) {
e.preventDefault();
if (isIgnored(username)) {
removeIgnored(username);
ignLink.textContent = "Ignore";
} else {
addIgnored(username);
ignLink.textContent = "Unignore";
}
// Re-apply to every comment so quotes of the toggled user
// get replaced/restored alongside their own posts.
comments.forEach(function (c) {
var uEl =
c.querySelector(".Username a") ||
c.querySelector(".Username");
if (!uEl) return;
var uName = uEl.textContent.trim();
applyIgnore(c, uName);
if (uName.toLowerCase() === username.toLowerCase()) {
var link = c.querySelector(".let-ignore-link");
if (link) {
link.textContent = isIgnored(uName) ? "Unignore" : "Ignore";
}
}
});
renderIgnoredList();
});
ignWrap.appendChild(ignLink);
if (commentInfo) commentInfo.appendChild(ignWrap);
});
}
setupIgnoreUsersOnComments();
// Create reading pane if enabled
if (!IS_IFRAME && letSettings.readingPane) createReadingPane();
// Sort dropdown for discussions
function setupSortUI() {
var list = document.querySelector("ul.DataList.Discussions");
if (!list) return;
// Find the PageControls nearest to the discussion list (before or after)
var pager =
list.parentNode.querySelector(".PageControls") ||
document.querySelector(".PageControls");
if (!pager) {
// Fallback: create a controls row above the list
pager = document.createElement("div");
pager.className = "PageControls";
pager.style.cssText =
"display:flex!important;align-items:center!important;justify-content:flex-end!important;padding:2px 0!important;";
list.parentNode.insertBefore(pager, list);
}
// Store original order
var items = list.querySelectorAll("li.ItemDiscussion.let-done");
items.forEach(function (li, i) {
li.setAttribute("data-sort-index", i);
});
// Build sort UI
var wrap = document.createElement("span");
wrap.className = "let-sort-wrap";
var sel = document.createElement("select");
sel.className = "let-sort-select";
[
{ value: "default", text: "Default Order" },
{ value: "lastpost", text: "Last Post" },
{ value: "views", text: "Views" },
{ value: "comments", text: "Comments" },
].forEach(function (o) {
var opt = document.createElement("option");
opt.value = o.value;
opt.textContent = o.text;
sel.appendChild(opt);
});
var dirBtn = document.createElement("button");
dirBtn.className = "let-sort-dir";
dirBtn.textContent = "\u25BC";
dirBtn.title = "Descending";
var ascending = false;
dirBtn.addEventListener("click", function () {
ascending = !ascending;
dirBtn.textContent = ascending ? "\u25B2" : "\u25BC";
dirBtn.title = ascending ? "Ascending" : "Descending";
doSort();
});
function doSort() {
var key = sel.value;
var rows = Array.from(
list.querySelectorAll("li.ItemDiscussion.let-done"),
);
if (key === "default") {
rows.sort(function (a, b) {
return (
parseInt(a.getAttribute("data-sort-index")) -
parseInt(b.getAttribute("data-sort-index"))
);
});
if (!ascending) rows.reverse();
} else if (key === "views" || key === "comments") {
rows.sort(function (a, b) {
var va = parseInt(a.getAttribute("data-" + key)) || 0;
var vb = parseInt(b.getAttribute("data-" + key)) || 0;
return ascending ? va - vb : vb - va;
});
} else if (key === "lastpost") {
rows.sort(function (a, b) {
var da = new Date(a.getAttribute("data-lastpost") || 0).getTime();
var db = new Date(b.getAttribute("data-lastpost") || 0).getTime();
return ascending ? da - db : db - da;
});
}
rows.forEach(function (li) {
list.appendChild(li);
});
}
sel.addEventListener("change", doSort);
wrap.appendChild(sel);
wrap.appendChild(dirBtn);
pager.insertBefore(wrap, pager.firstChild);
}
setupSortUI();
// Render post counter bar after the page has settled (matches the
// original post-counter userscript's 500ms delay).
setTimeout(updatePostCounter, 500);
});
// Condense footer into 3-column layout
function condenseFooter() {
var foot = document.getElementById("Foot");
if (!foot) return;
foot.innerHTML = "";
var row = document.createElement("div");
row.className = "Row";
row.style.cssText =
"display:flex!important;align-items:center!important;padding:2px 10px!important;height:auto!important;min-height:0!important;";
var left = document.createElement("div");
left.className = "let-foot-left";
left.style.cssText =
"flex:1!important;text-align:left!important;font-size:11px!important;";
left.textContent =
"2008-" + new Date().getFullYear() + " \u00A9 LowEndBox & LowEndTalk.";
var center = document.createElement("div");
center.className = "let-foot-center";
center.style.cssText =
"flex:1!important;text-align:center!important;font-size:11px!important;";
[
{ text: "Back to Top", href: "#top" },
{ text: "LowEndTalk", href: "https://lowendtalk.com" },
{ text: "LowEndBox", href: "https://www.lowendbox.com" },
{
text: "Dark Theme Config",
href: "https://lowendtalk.com/page/dark-theme-config",
},
{ text: "Advertise", href: "https://lowendbox.com/advertise/" },
].forEach(function (l, i) {
if (i > 0) center.appendChild(document.createTextNode(" | "));
var a = document.createElement("a");
a.href = l.href;
a.textContent = l.text;
center.appendChild(a);
});
var right = document.createElement("div");
right.className = "let-foot-right";
right.style.cssText =
"flex:1!important;text-align:right!important;font-size:11px!important;";
row.appendChild(left);
row.appendChild(center);
row.appendChild(right);
foot.appendChild(row);
}
// Run multiple times to survive late-running page scripts that modify the footer
if (!IS_IFRAME) {
document.addEventListener("DOMContentLoaded", condenseFooter);
window.addEventListener("load", function () {
condenseFooter();
setTimeout(condenseFooter, 500);
setTimeout(condenseFooter, 1500);
setTimeout(condenseFooter, 3000);
});
}
})();