From 173caca2582e2e421a9ce835dd0c7272e200a979 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 18 Sep 2026 16:07:00 -0700 Subject: [PATCH] feat: commit extension --- .gitignore | 1 + README.md | 25 ++++ manifest.json | 23 ++++ pluralkit-predict.user.js | 268 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 317 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 manifest.json create mode 100644 pluralkit-predict.user.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..c2658d7d1b31848c3b71960543cb0368e56cd4c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..4fa2737458d1a310ac1bac7f5003933b60fb01b6 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Discord Modification: PluralKit Predict + +This script/browser extension injects code into `discord.com` to modify how +users of PluralKit appear on your screen. Simply put, when a discord webhook is +sent very quickly after a user's message and the messages are nearly identical, +the original message is instantly hidden; This removes the layout flickering +that PluralKit webhooks cause. In addition, there is a heuristic to remember +what a system's autoproxy is set to, so in long conversations it will just feel +like the user is directly sending webhook messages. + +The extension is a self contained userscript: `pluralkit-predict.user.js` + +## Install + +**Userscript**: open [`pluralkit-predict.user.js`][raw] with Tampermonkey, +Violentmonkey, or Userscripts.app and confirm the install. + +**Chrome / Edge**: go to `chrome://extensions`, turn on Developer mode, choose +Load unpacked, and pick this folder. + +**Firefox**: go to `about:debugging#/runtime/this-firefox`, choose Load +Temporary Add-on, and pick `manifest.json`. Temporary add-ons are removed when +Firefox restarts. + +[raw]: https://shale.paperclover.net/userscripts/discord-pluralkit-predict/plain/-/pluralkit-predict.user.js diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..08aeab2ca1c9e51811b6e15a53d23c2fabb75c56 --- /dev/null +++ b/manifest.json @@ -0,0 +1,23 @@ +{ + "manifest_version": 3, + "name": "PluralKit Predict", + "version": "1.0.0", + "description": "Userscript for less jittery pluralkit interactions", + "browser_specific_settings": { + "gecko": { + "id": "pluralkit-predict@paperclover.net", + "strict_min_version": "127.0" + } + }, + "content_scripts": [ + { + "matches": [ + "https://discord.com/*", + "https://ptb.discord.com/*", + "https://canary.discord.com/*" + ], + "js": ["pluralkit-predict.user.js"], + "run_at": "document_idle" + } + ] +} diff --git a/pluralkit-predict.user.js b/pluralkit-predict.user.js new file mode 100644 index 0000000000000000000000000000000000000000..19bff46b6aac472891e2c4a8ad9976eb114ee0d3 --- /dev/null +++ b/pluralkit-predict.user.js @@ -0,0 +1,268 @@ +// ==UserScript== +// @name pluralkit-predict +// @namespace https://shale.paperclover.net/userscripts/discord-pluralkit-predict/ +// @version 1.0.0 +// @description Hides a PluralKit original the instant its proxy renders. +// @match https://discord.com/* +// @match https://ptb.discord.com/* +// @match https://canary.discord.com/* +// @run-at document-idle +// @inject-into content +// @grant none +// ==/UserScript== + +// general theory of how this script works made in an after by clover, +// written in two shots with ai. i have sincere apologies to those that +// dislike the tech, but i will at least say that i did not spend my +// own money on the slop machine. + +function normalize(s) { + return s.replace(/\s+/g, " ").trim(); +} + +function bigrams(s) { + const counts = new Map(); + for (let i = 0; i + 1 < s.length; i++) { + const gram = s.slice(i, i + 2); + counts.set(gram, (counts.get(gram) || 0) + 1); + } + return counts; +} + +function dice(a, b) { + const left = bigrams(a); + const right = bigrams(b); + let shared = 0; + for (const [gram, n] of left) shared += Math.min(n, right.get(gram) || 0); + return (2 * shared) / (a.length - 1 + b.length - 1); +} + +function score(proxied, candidate) { + const p = normalize(proxied); + const c = normalize(candidate); + if (!p && !c) return { confidence: 0.6, residue: 0 }; + if (p === c) return { confidence: 1, residue: 0 }; + if (p && c.includes(p) && c.length - p.length <= 8) { + return { confidence: 0.9, residue: c.length - p.length }; + } + if (p.length >= 12 && c.length >= 12) { + const coefficient = dice(p, c); + if (coefficient >= 0.92) return { confidence: coefficient, residue: 0 }; + } + return null; +} + +const HIDE_STYLE = "display: none !important"; +const THRESHOLD = 0.6; +const MAX_RULES = 200; +const MAX_LOOKBACK = 10; +const WINDOW_MS = 60_000; +const EMPTY_WINDOW_MS = 30_000; +const RECOVERY_MS = 10_000; +const AUTOPROXY_HITS = 2; +const AUTOPROXY_TTL_MS = 5 * 60_000; + +const sheet = document.head.appendChild(document.createElement("style")).sheet; +const hidden = new Map(); +const believedDeleted = new Set(); +const autoproxy = new Map(); + +function messageId(liId) { + return liId.slice(liId.lastIndexOf("-") + 1); +} + +function prevMessage(li) { + for (let n = li.previousElementSibling; n; n = n.previousElementSibling) { + if (n.matches('li[id^="chat-messages-"]')) return n; + } + return null; +} + +function header(li) { + for (let n = li; n; n = prevMessage(n)) { + if (n.querySelector('[id^="message-username-"]')) return n; + } + return null; +} + +function isApp(li) { + return !!header(li)?.querySelector('[class*="botTag"]'); +} + +function author(li) { + return header(li)?.querySelector('[id^="message-username-"]').textContent || + null; +} + +// Autoproxy leaves content byte-identical, so a confirmed exact match is the only +// evidence that lets us hide the next message before its proxy exists. +function autoproxied(li) { + const name = author(li); + const seen = name && autoproxy.get(name); + const fresh = seen && seen.hits >= AUTOPROXY_HITS && + Date.now() - seen.lastAt < AUTOPROXY_TTL_MS; + return fresh ? name : null; +} + +function timestamp(li) { + const time = li.querySelector("time[datetime]"); + const parsed = time && Date.parse(time.dateTime); + return Number.isFinite(parsed) ? parsed : null; +} + +// Scoped by id because a reply's preview of its parent is also a message-content. +function content(li) { + return li.querySelector(`[id="message-content-${messageId(li.id)}"]`); +} + +function text(li) { + const root = content(li); + if (!root) return ""; + const walker = document.createTreeWalker( + root, + NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, + { + acceptNode(node) { + if (node.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT; + if (node.matches('[class*="edited"]')) return NodeFilter.FILTER_REJECT; + return node.tagName === "IMG" && node.alt + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_SKIP; + }, + }, + ); + let out = ""; + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + out += n.nodeType === Node.TEXT_NODE ? n.data : n.alt; + } + return out; +} + +function revoke(id) { + const entry = hidden.get(id); + clearTimeout(entry.timer); + hidden.delete(id); + believedDeleted.delete(id); + const selector = `li[id="${entry.liId}"]`; + for (let i = 0; i < sheet.cssRules.length; i++) { + if (sheet.cssRules[i].selectorText === selector) { + sheet.deleteRule(i); + return; + } + } +} + +function hide(li, credit) { + const id = messageId(li.id); + sheet.insertRule( + `li[id="${li.id}"] { ${HIDE_STYLE} }`, + sheet.cssRules.length, + ); + hidden.set(id, { + liId: li.id, + timer: setTimeout(() => { + if (document.getElementById(hidden.get(id).liId)) { + revoke(id); + if (credit) autoproxy.delete(credit); + } else { + believedDeleted.add(id); + if (credit) { + autoproxy.set(credit, { + hits: (autoproxy.get(credit)?.hits || 0) + 1, + lastAt: Date.now(), + }); + } + } + }, RECOVERY_MS), + }); + if (hidden.size > MAX_RULES) revoke(hidden.keys().next().value); +} + +function claim(proxy) { + const proxied = normalize(text(proxy)); + const proxyAt = timestamp(proxy); + let best = null; + let candidate = prevMessage(proxy); + for ( + let back = 1; + candidate && back <= MAX_LOOKBACK; + back++, candidate = prevMessage(candidate) + ) { + const at = timestamp(candidate); + const age = proxyAt !== null && at !== null ? proxyAt - at : 0; + if (age > WINDOW_MS) break; + if (hidden.has(messageId(candidate.id)) || isApp(candidate)) continue; + if (!proxied && (back > 1 || age > EMPTY_WINDOW_MS)) continue; + const match = score(proxied, text(candidate)); + if (!match || match.confidence < THRESHOLD) continue; + if ( + !best || match.confidence > best.confidence || + (match.confidence === best.confidence && match.residue < best.residue) + ) { + best = { ...match, li: candidate }; + } + } + if (best) hide(best.li, best.confidence === 1 ? author(best.li) : null); +} + +// Discord renders a proxy as a group start while the hidden original sits between it +// and the previous proxy, until PluralKit deletes the original. +function ungroup(proxy) { + let prev = prevMessage(proxy); + if (!prev || !hidden.has(messageId(prev.id))) return; + while (prev && hidden.has(messageId(prev.id))) prev = prevMessage(prev); + if (!prev || !isApp(prev) || author(prev) !== author(proxy)) return; + const start = proxy.querySelector('[class*="groupStart"]'); + const body = content(proxy); + if (!start || !body) return; + start.classList.remove( + [...start.classList].find((c) => c.startsWith("groupStart")), + ); + for (const sibling of body.parentElement.children) { + if (sibling !== body) sibling.style.display = "none"; + } +} + +function onInserted(li) { + const id = messageId(li.id); + // A message PluralKit deleted can never re-mount, so a re-mount proves we hid a live one. + if (believedDeleted.has(id)) { + revoke(id); + return; + } + if (hidden.has(id)) return; + if (isApp(li)) { + claim(li); + ungroup(li); + return; + } + const name = autoproxied(li); + if (name) { + hide(li, name); + } +} + +const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + if (node.matches('li[id^="chat-messages-"]')) onInserted(node); + else {for ( + const li of node.querySelectorAll('li[id^="chat-messages-"]') + ) onInserted(li);} + } + } +}); + +let observed = null; +function attach() { + if (observed?.isConnected) return; + const list = document.querySelector('[data-list-id="chat-messages"]'); + if (!list) return; + observer.disconnect(); + observer.observe(list, { childList: true, subtree: true }); + observed = list; +} + +attach(); +setInterval(attach, 1000); -- 2.54.0