Skip to content

Instantly share code, notes, and snippets.

@maxtheaxe
Last active January 27, 2026 12:42
Show Gist options
  • Select an option

  • Save maxtheaxe/04304ba7caf25d55fb94748818c5819f to your computer and use it in GitHub Desktop.

Select an option

Save maxtheaxe/04304ba7caf25d55fb94748818c5819f to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name collab email helper
// @version 0.33
// @grant none
// @match https://mail.google.com/*
// @run-at document-idle
// @downloadURL https://gist.github.com/maxtheaxe/04304ba7caf25d55fb94748818c5819f/raw/a04400197abeda9aa815c8f4ae1efb510732ee61/collab-email.user.js
// @updateURL https://gist.github.com/maxtheaxe/04304ba7caf25d55fb94748818c5819f/raw/a04400197abeda9aa815c8f4ae1efb510732ee61/collab-email.user.js
// ==/UserScript==
(() => {
'use strict';
const LABEL_PREFIX = 'collab/';
const TARGET_BUTTON_TEXT = 'Reply all';
const DEBOUNCE_MS = 150;
// threadId -> WeakSet<HTMLElement>
const processedByThread = new Map();
function getThreadPermId() {
// In thread view Gmail commonly renders the subject as h2 with data-thread-perm-id
const h2 = document.querySelector('div[role="main"] h2[data-thread-perm-id]');
return h2?.getAttribute('data-thread-perm-id') ?? null;
}
function findReplyAllSpan() {
const xpath = `//button[normalize-space(.)="${TARGET_BUTTON_TEXT}"]`;
return document
.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)
.singleNodeValue;
}
function findState() {
const threadPermId = getThreadPermId();
if (!threadPermId) return null;
const labelEl = document.querySelector(`[aria-label^="Remove label ${LABEL_PREFIX}"]`);
if (!labelEl || !labelEl.isConnected) return null;
const replyAllSpan = findReplyAllSpan();
if (!replyAllSpan || !replyAllSpan.isConnected) return null;
return { threadPermId, labelEl, replyAllSpan };
}
function apply({ threadPermId, replyAllSpan }) {
let set = processedByThread.get(threadPermId);
if (!set) {
set = new WeakSet();
processedByThread.set(threadPermId, set);
}
// If we already styled THIS exact node for THIS thread, don't do it again.
if (set.has(replyAllSpan)) return;
set.add(replyAllSpan);
replyAllSpan.style.border = '2px solid lime';
}
let runTimer = null;
function debouncedCheck() {
if (runTimer) clearTimeout(runTimer);
runTimer = setTimeout(() => {
runTimer = null;
const state = findState();
if (state) apply(state);
}, DEBOUNCE_MS);
}
const obs = new MutationObserver(debouncedCheck);
obs.observe(document.documentElement, { childList: true, subtree: true });
debouncedCheck();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment