authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-18 16:07:00-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-18 16:12:16-07:00
log173caca2582e2e421a9ce835dd0c7272e200a979
tree6fbeb6858d02a8aa00dd17eb6cda4932e005241e
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

feat: commit extension


4 files changed, 317 insertions(+), 0 deletions(-)

.gitignore created+1
......@@ -0,0 +1 @@
1node_modules/
README.md created+25
......@@ -0,0 +1,25 @@
1# Discord Modification: PluralKit Predict
2
3This script/browser extension injects code into `discord.com` to modify how
4users of PluralKit appear on your screen. Simply put, when a discord webhook is
5sent very quickly after a user's message and the messages are nearly identical,
6the original message is instantly hidden; This removes the layout flickering
7that PluralKit webhooks cause. In addition, there is a heuristic to remember
8what a system's autoproxy is set to, so in long conversations it will just feel
9like the user is directly sending webhook messages.
10
11The extension is a self contained userscript: `pluralkit-predict.user.js`
12
13## Install
14
15**Userscript**: open [`pluralkit-predict.user.js`][raw] with Tampermonkey,
16Violentmonkey, or Userscripts.app and confirm the install.
17
18**Chrome / Edge**: go to `chrome://extensions`, turn on Developer mode, choose
19Load unpacked, and pick this folder.
20
21**Firefox**: go to `about:debugging#/runtime/this-firefox`, choose Load
22Temporary Add-on, and pick `manifest.json`. Temporary add-ons are removed when
23Firefox restarts.
24
25[raw]: https://shale.paperclover.net/userscripts/discord-pluralkit-predict/plain/-/pluralkit-predict.user.js
manifest.json created+23
......@@ -0,0 +1,23 @@
1{
2 "manifest_version": 3,
3 "name": "PluralKit Predict",
4 "version": "1.0.0",
5 "description": "Userscript for less jittery pluralkit interactions",
6 "browser_specific_settings": {
7 "gecko": {
8 "id": "pluralkit-predict@paperclover.net",
9 "strict_min_version": "127.0"
10 }
11 },
12 "content_scripts": [
13 {
14 "matches": [
15 "https://discord.com/*",
16 "https://ptb.discord.com/*",
17 "https://canary.discord.com/*"
18 ],
19 "js": ["pluralkit-predict.user.js"],
20 "run_at": "document_idle"
21 }
22 ]
23}
pluralkit-predict.user.js created+268
......@@ -0,0 +1,268 @@
1// ==UserScript==
2// @name pluralkit-predict
3// @namespace https://shale.paperclover.net/userscripts/discord-pluralkit-predict/
4// @version 1.0.0
5// @description Hides a PluralKit original the instant its proxy renders.
6// @match https://discord.com/*
7// @match https://ptb.discord.com/*
8// @match https://canary.discord.com/*
9// @run-at document-idle
10// @inject-into content
11// @grant none
12// ==/UserScript==
13
14// general theory of how this script works made in an after by clover,
15// written in two shots with ai. i have sincere apologies to those that
16// dislike the tech, but i will at least say that i did not spend my
17// own money on the slop machine.
18
19function normalize(s) {
20 return s.replace(/\s+/g, " ").trim();
21}
22
23function bigrams(s) {
24 const counts = new Map();
25 for (let i = 0; i + 1 < s.length; i++) {
26 const gram = s.slice(i, i + 2);
27 counts.set(gram, (counts.get(gram) || 0) + 1);
28 }
29 return counts;
30}
31
32function dice(a, b) {
33 const left = bigrams(a);
34 const right = bigrams(b);
35 let shared = 0;
36 for (const [gram, n] of left) shared += Math.min(n, right.get(gram) || 0);
37 return (2 * shared) / (a.length - 1 + b.length - 1);
38}
39
40function score(proxied, candidate) {
41 const p = normalize(proxied);
42 const c = normalize(candidate);
43 if (!p && !c) return { confidence: 0.6, residue: 0 };
44 if (p === c) return { confidence: 1, residue: 0 };
45 if (p && c.includes(p) && c.length - p.length <= 8) {
46 return { confidence: 0.9, residue: c.length - p.length };
47 }
48 if (p.length >= 12 && c.length >= 12) {
49 const coefficient = dice(p, c);
50 if (coefficient >= 0.92) return { confidence: coefficient, residue: 0 };
51 }
52 return null;
53}
54
55const HIDE_STYLE = "display: none !important";
56const THRESHOLD = 0.6;
57const MAX_RULES = 200;
58const MAX_LOOKBACK = 10;
59const WINDOW_MS = 60_000;
60const EMPTY_WINDOW_MS = 30_000;
61const RECOVERY_MS = 10_000;
62const AUTOPROXY_HITS = 2;
63const AUTOPROXY_TTL_MS = 5 * 60_000;
64
65const sheet = document.head.appendChild(document.createElement("style")).sheet;
66const hidden = new Map();
67const believedDeleted = new Set();
68const autoproxy = new Map();
69
70function messageId(liId) {
71 return liId.slice(liId.lastIndexOf("-") + 1);
72}
73
74function prevMessage(li) {
75 for (let n = li.previousElementSibling; n; n = n.previousElementSibling) {
76 if (n.matches('li[id^="chat-messages-"]')) return n;
77 }
78 return null;
79}
80
81function header(li) {
82 for (let n = li; n; n = prevMessage(n)) {
83 if (n.querySelector('[id^="message-username-"]')) return n;
84 }
85 return null;
86}
87
88function isApp(li) {
89 return !!header(li)?.querySelector('[class*="botTag"]');
90}
91
92function author(li) {
93 return header(li)?.querySelector('[id^="message-username-"]').textContent ||
94 null;
95}
96
97// Autoproxy leaves content byte-identical, so a confirmed exact match is the only
98// evidence that lets us hide the next message before its proxy exists.
99function autoproxied(li) {
100 const name = author(li);
101 const seen = name && autoproxy.get(name);
102 const fresh = seen && seen.hits >= AUTOPROXY_HITS &&
103 Date.now() - seen.lastAt < AUTOPROXY_TTL_MS;
104 return fresh ? name : null;
105}
106
107function timestamp(li) {
108 const time = li.querySelector("time[datetime]");
109 const parsed = time && Date.parse(time.dateTime);
110 return Number.isFinite(parsed) ? parsed : null;
111}
112
113// Scoped by id because a reply's preview of its parent is also a message-content.
114function content(li) {
115 return li.querySelector(`[id="message-content-${messageId(li.id)}"]`);
116}
117
118function text(li) {
119 const root = content(li);
120 if (!root) return "";
121 const walker = document.createTreeWalker(
122 root,
123 NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
124 {
125 acceptNode(node) {
126 if (node.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT;
127 if (node.matches('[class*="edited"]')) return NodeFilter.FILTER_REJECT;
128 return node.tagName === "IMG" && node.alt
129 ? NodeFilter.FILTER_ACCEPT
130 : NodeFilter.FILTER_SKIP;
131 },
132 },
133 );
134 let out = "";
135 for (let n = walker.nextNode(); n; n = walker.nextNode()) {
136 out += n.nodeType === Node.TEXT_NODE ? n.data : n.alt;
137 }
138 return out;
139}
140
141function revoke(id) {
142 const entry = hidden.get(id);
143 clearTimeout(entry.timer);
144 hidden.delete(id);
145 believedDeleted.delete(id);
146 const selector = `li[id="${entry.liId}"]`;
147 for (let i = 0; i < sheet.cssRules.length; i++) {
148 if (sheet.cssRules[i].selectorText === selector) {
149 sheet.deleteRule(i);
150 return;
151 }
152 }
153}
154
155function hide(li, credit) {
156 const id = messageId(li.id);
157 sheet.insertRule(
158 `li[id="${li.id}"] { ${HIDE_STYLE} }`,
159 sheet.cssRules.length,
160 );
161 hidden.set(id, {
162 liId: li.id,
163 timer: setTimeout(() => {
164 if (document.getElementById(hidden.get(id).liId)) {
165 revoke(id);
166 if (credit) autoproxy.delete(credit);
167 } else {
168 believedDeleted.add(id);
169 if (credit) {
170 autoproxy.set(credit, {
171 hits: (autoproxy.get(credit)?.hits || 0) + 1,
172 lastAt: Date.now(),
173 });
174 }
175 }
176 }, RECOVERY_MS),
177 });
178 if (hidden.size > MAX_RULES) revoke(hidden.keys().next().value);
179}
180
181function claim(proxy) {
182 const proxied = normalize(text(proxy));
183 const proxyAt = timestamp(proxy);
184 let best = null;
185 let candidate = prevMessage(proxy);
186 for (
187 let back = 1;
188 candidate && back <= MAX_LOOKBACK;
189 back++, candidate = prevMessage(candidate)
190 ) {
191 const at = timestamp(candidate);
192 const age = proxyAt !== null && at !== null ? proxyAt - at : 0;
193 if (age > WINDOW_MS) break;
194 if (hidden.has(messageId(candidate.id)) || isApp(candidate)) continue;
195 if (!proxied && (back > 1 || age > EMPTY_WINDOW_MS)) continue;
196 const match = score(proxied, text(candidate));
197 if (!match || match.confidence < THRESHOLD) continue;
198 if (
199 !best || match.confidence > best.confidence ||
200 (match.confidence === best.confidence && match.residue < best.residue)
201 ) {
202 best = { ...match, li: candidate };
203 }
204 }
205 if (best) hide(best.li, best.confidence === 1 ? author(best.li) : null);
206}
207
208// Discord renders a proxy as a group start while the hidden original sits between it
209// and the previous proxy, until PluralKit deletes the original.
210function ungroup(proxy) {
211 let prev = prevMessage(proxy);
212 if (!prev || !hidden.has(messageId(prev.id))) return;
213 while (prev && hidden.has(messageId(prev.id))) prev = prevMessage(prev);
214 if (!prev || !isApp(prev) || author(prev) !== author(proxy)) return;
215 const start = proxy.querySelector('[class*="groupStart"]');
216 const body = content(proxy);
217 if (!start || !body) return;
218 start.classList.remove(
219 [...start.classList].find((c) => c.startsWith("groupStart")),
220 );
221 for (const sibling of body.parentElement.children) {
222 if (sibling !== body) sibling.style.display = "none";
223 }
224}
225
226function onInserted(li) {
227 const id = messageId(li.id);
228 // A message PluralKit deleted can never re-mount, so a re-mount proves we hid a live one.
229 if (believedDeleted.has(id)) {
230 revoke(id);
231 return;
232 }
233 if (hidden.has(id)) return;
234 if (isApp(li)) {
235 claim(li);
236 ungroup(li);
237 return;
238 }
239 const name = autoproxied(li);
240 if (name) {
241 hide(li, name);
242 }
243}
244
245const observer = new MutationObserver((records) => {
246 for (const record of records) {
247 for (const node of record.addedNodes) {
248 if (node.nodeType !== Node.ELEMENT_NODE) continue;
249 if (node.matches('li[id^="chat-messages-"]')) onInserted(node);
250 else {for (
251 const li of node.querySelectorAll('li[id^="chat-messages-"]')
252 ) onInserted(li);}
253 }
254 }
255});
256
257let observed = null;
258function attach() {
259 if (observed?.isConnected) return;
260 const list = document.querySelector('[data-list-id="chat-messages"]');
261 if (!list) return;
262 observer.disconnect();
263 observer.observe(list, { childList: true, subtree: true });
264 observed = list;
265}
266
267attach();
268setInterval(attach, 1000);