🔨 | Robust + menu injection with MutationObserver and multiple fallback selectors

This commit is contained in:
2026-05-19 03:18:13 +03:00
parent 4342cfce5d
commit 11698cbcc2

View File

@@ -655,52 +655,104 @@ function createMenuItem(label, iconSvg, onClick) {
return el; return el;
} }
// ── Inject items into the + menu popout ───────────────────────── // ── Identify the + popout and inject items ──────────────────────
function injectPlusMenuItems() { function isAttachMenu(scroller) {
// Look for open menu scrollers — the + popout is a menu layer // Check menu item text for keywords unique to the + popout
const scrollers = document.querySelectorAll('[class*="menu"] [class*="scroller"]'); const items = scroller.querySelectorAll('[role="menuitem"], [class*="item"]');
for (const scroller of scrollers) { for (const item of items) {
// Already injected? const t = (item.textContent || "").toLowerCase();
if (scroller.querySelector("[data-unsloth-menu-item]")) continue; if (/upload|file|thread|create|attach|app|integration|gif|sticker|poll/i.test(t)) {
return true;
// Identify the + menu: it contains items for upload / create thread etc. }
// We look for any element that hints this is the channel-attach popout.
const hint =
scroller.querySelector('[class*="upload"], [class*="attach"]') ||
scroller.closest('[class*="channelTextArea"]');
if (!hint) continue;
// Append a divider then our two items
const divider = document.createElement("div");
divider.style.cssText =
"margin:4px 6px;border-top:1px solid var(--background-modifier-accent);";
const askAi = createMenuItem("Ask AI", SPARKLE_ICON, () => {
openModal((p) => <PromptModal close={p.close} />);
});
const suggest = createMenuItem("Suggest reply", REPLY_ICON, () => {
suggestReply();
});
scroller.appendChild(divider);
scroller.appendChild(askAi);
scroller.appendChild(suggest);
} }
// Fallback: is the popout positioned near the chat input area?
const menuRoot = scroller.closest('[class*="menu"]') || scroller.closest('[class*="popout"]');
if (menuRoot) {
const cta = document.querySelector('[class*="channelTextArea"]');
if (cta) {
const mr = menuRoot.getBoundingClientRect();
const cr = cta.getBoundingClientRect();
if (mr.bottom >= cr.top - 20 && mr.top <= cr.bottom + 20) return true;
}
}
return false;
} }
// ── Hook the + button to inject items when popout opens ────────── function addItemsToScroller(scroller) {
if (!scroller || scroller.querySelector("[data-unsloth-menu-item]")) return;
const divider = document.createElement("div");
divider.style.cssText =
"margin:4px 6px;border-top:1px solid var(--background-modifier-accent);";
const askAi = createMenuItem("Ask AI", SPARKLE_ICON, () => {
openModal((p) => <PromptModal close={p.close} />);
});
const suggest = createMenuItem("Suggest reply", REPLY_ICON, () => {
suggestReply();
});
scroller.appendChild(divider);
scroller.appendChild(askAi);
scroller.appendChild(suggest);
}
// ── Strategy 1: MutationObserver watches for any menu popout ────
const menuObserver = new MutationObserver((mutations) => {
for (const mut of mutations) {
for (const node of mut.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
// The added node may be the menu itself …
if (node.matches && node.matches('[class*="menu"]')) {
const sc = node.querySelector('[class*="scroller"]');
if (sc && isAttachMenu(sc)) addItemsToScroller(sc);
}
// … or it may contain a menu subtree deeper inside (portals)
if (node.querySelectorAll) {
node.querySelectorAll('[class*="menu"]').forEach(menu => {
const sc = menu.querySelector('[class*="scroller"]');
if (sc && isAttachMenu(sc)) addItemsToScroller(sc);
});
}
}
}
});
menuObserver.observe(document.body, { childList: true, subtree: true });
scoped.onDispose(() => menuObserver.disconnect());
// ── Strategy 2: hook the + button click for faster injection ────
function hookAttachButton(btn) { function hookAttachButton(btn) {
if (btn.dataset.unslothHooked) return; if (btn.dataset.unslothHooked) return;
btn.dataset.unslothHooked = "1"; btn.dataset.unslothHooked = "1";
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
// The popout renders asynchronously, so wait a tick let tries = 0;
setTimeout(injectPlusMenuItems, 50); const poll = () => {
if (tries++ > 15) return;
const scrollers = document.querySelectorAll('[class*="menu"] [class*="scroller"]');
for (const sc of scrollers) {
if (!sc.querySelector("[data-unsloth-menu-item]") && isAttachMenu(sc)) {
addItemsToScroller(sc);
return;
}
}
setTimeout(poll, 40);
};
setTimeout(poll, 30);
}); });
} }
// Multiple possible selectors for the + button (Discord changes these)
scoped.observeDom( scoped.observeDom(
'[class*="channelTextArea"] [class*="attachButton"]', [
'[class*="channelTextArea"] [class*="attachButton"]',
'[class*="channelTextArea"] [class*="attachWrapper"] > button',
'[class*="channelTextArea"] button[class*="attach"]',
'[class*="channelTextArea"] [aria-label*="ttach"]',
'[class*="channelTextArea"] [class*="uploadInput"]',
].join(", "),
hookAttachButton, hookAttachButton,
); );