Skip to content

Instantly share code, notes, and snippets.

@LawrenceHwang
Last active June 16, 2026 06:32
Show Gist options
  • Select an option

  • Save LawrenceHwang/5967d5c834869339338fb1f1673a8ce9 to your computer and use it in GitHub Desktop.

Select an option

Save LawrenceHwang/5967d5c834869339338fb1f1673a8ce9 to your computer and use it in GitHub Desktop.
instagram-quick-actions
// ==UserScript==
// @name Instagram Quick Actions
// @namespace https://www.instagram.com/
// @version 0.6.3
// @description Adds "Not Interested" and "Block Account" shortcut buttons to every Instagram feed post.
// @match https://www.instagram.com/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(() => {
"use strict";
const INJECTED_ATTR = "data-iq-injected";
const OWN_ATTR = "data-iq-control";
let actionInProgress = false;
// ── DOM helpers ────────────────────────────────────────────────────────────
/**
* Find the first interactive element whose trimmed text matches needle.
* caseless defaults to true; partial defaults to false (exact match).
*/
// Broad selector: catches buttons, roleified divs/spans, and bare list items
// Instagram's bottom-sheet items vary across A/B versions.
const INTERACTIVE_SEL =
'button, [role="button"], [role="menuitem"], [role="option"], [role="listitem"], li';
const ACTIONABLE_SEL = INTERACTIVE_SEL + ', [tabindex="0"], [tabindex="-1"], a';
function normalizeText(value, caseless = true) {
const text = (value || "").trim().replace(/\s+/g, " ");
return caseless ? text.toLowerCase() : text;
}
function scoreTextCandidate(el, matchText, wantsPartial) {
const normalized = normalizeText(el.textContent);
if (!normalized) return -1;
const isExact = normalized === matchText;
const isMatch = wantsPartial ? normalized.includes(matchText) : isExact;
if (!isMatch) return -1;
const rect = el.getBoundingClientRect();
const childCount = el.children.length;
const interactive = el.matches(ACTIONABLE_SEL);
const hasInteractiveChild = !!el.querySelector(ACTIONABLE_SEL);
const sizePenalty = Math.min(normalized.length, 200);
let score = 0;
if (isExact) score += 1000;
if (interactive) score += 200;
if (!hasInteractiveChild) score += 80;
if (childCount === 0) score += 50;
if (childCount <= 2) score += 20;
if (rect.width > 0 && rect.width < window.innerWidth * 0.9) score += 20;
score -= sizePenalty;
score -= childCount * 10;
return score;
}
function findByText(text, { partial = false, caseless = true } = {}, root = document.body) {
const needle = normalizeText(text, caseless);
const candidates = new Map();
for (const el of root.querySelectorAll(ACTIONABLE_SEL + ', div, span')) {
if (!isVisible(el) || isOwnControl(el)) continue;
const score = scoreTextCandidate(el, needle, partial);
if (score < 0) continue;
const target = getActionTarget(el, root);
let adjustedScore = score;
if (target !== el) adjustedScore += 140;
if (target.matches(ACTIONABLE_SEL)) adjustedScore += 80;
if (target.children.length <= 3) adjustedScore += 15;
const existing = candidates.get(target);
if (!existing || adjustedScore > existing.score) {
candidates.set(target, { el: target, score: adjustedScore });
}
}
if (!candidates.size) return null;
return [...candidates.values()].sort((a, b) => b.score - a.score)[0].el;
}
/** Resolve with the first matching element, or reject after timeout ms. */
function waitForText(text, opts = {}, timeout = 6000) {
return new Promise((resolve, reject) => {
const hit = findByText(text, opts);
if (hit) return resolve(hit);
const obs = new MutationObserver(() => {
const el = findByText(text, opts);
if (!el) return;
obs.disconnect();
clearTimeout(timer);
resolve(el);
});
obs.observe(document.body, { childList: true, subtree: true });
const timer = setTimeout(() => {
obs.disconnect();
reject(new Error(`instagram-quick-actions: waitForText timeout — "${text}"`));
}, timeout);
});
}
function pressEscape() {
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })
);
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isVisible(el) {
if (!el || !(el instanceof Element)) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function isOwnControl(el) {
return !!el?.closest(`[${OWN_ATTR}]`);
}
function getActionTarget(el, root = document.body) {
const target = el.closest(ACTIONABLE_SEL);
if (target && root.contains(target) && !isOwnControl(target) && isVisible(target)) {
return target;
}
return el;
}
/**
* Fire the full pointer+mouse+click event sequence that React expects.
* Plain el.click() is often ignored on React-synthetic-event buttons.
*/
function robustClick(el) {
const opts = { bubbles: true, cancelable: true, composed: true };
el.dispatchEvent(new PointerEvent("pointerover", opts));
el.dispatchEvent(new MouseEvent("mouseover", opts));
el.dispatchEvent(new PointerEvent("pointerenter", { ...opts, bubbles: false }));
el.dispatchEvent(new PointerEvent("pointerdown", opts));
el.dispatchEvent(new MouseEvent("mousedown", opts));
el.dispatchEvent(new PointerEvent("pointerup", opts));
el.dispatchEvent(new MouseEvent("mouseup", opts));
el.dispatchEvent(new MouseEvent("click", opts));
}
function getDotsButton(post) {
const allClickables = [
...post.querySelectorAll(
'button, [role="button"], [tabindex="0"], [tabindex="-1"], [aria-label]'
),
].filter((el) => !isOwnControl(el));
for (const label of ["More options", "More", "Additional actions", "Post options"]) {
const hit = allClickables.find(
(el) => (el.getAttribute("aria-label") || "") === label
);
if (hit) return hit;
}
for (const el of allClickables) {
const lbl = (el.getAttribute("aria-label") || "").toLowerCase();
if (lbl.includes("more") || lbl.includes("option")) return el;
}
const postRect = post.getBoundingClientRect();
const probePoints = [
[postRect.right - 18, postRect.top + 18],
[postRect.right - 30, postRect.top + 18],
[postRect.right - 18, postRect.top + 30],
[postRect.right - 30, postRect.top + 30],
[postRect.right - 42, postRect.top + 24],
];
for (const [x, y] of probePoints) {
const stack = document.elementsFromPoint(x, y);
for (const el of stack) {
if (!post.contains(el) || isOwnControl(el)) continue;
for (let cur = el; cur && cur !== post; cur = cur.parentElement) {
if (isOwnControl(cur)) break;
if (!isVisible(cur)) continue;
const rect = cur.getBoundingClientRect();
const text = cur.textContent.trim();
const aria = (cur.getAttribute("aria-label") || "").toLowerCase();
const isTopRight =
rect.top <= postRect.top + postRect.height * 0.25
&& rect.right >= postRect.right - 70;
const looksLikeControl =
cur.matches('button, [role="button"], [tabindex="0"], [tabindex="-1"], svg, path')
|| !!cur.querySelector("svg")
|| !!cur.closest("svg")
|| !!aria;
if (isTopRight && looksLikeControl && text.length <= 2) {
return cur;
}
}
}
}
return null;
}
function collectVisibleActionTexts() {
const roots = [
...document.querySelectorAll('[role="dialog"], [role="menu"], [aria-modal="true"]'),
].filter(isVisible);
const sourceRoots = roots.length ? roots : [document.body];
const texts = new Set();
for (const root of sourceRoots) {
for (const el of root.querySelectorAll(INTERACTIVE_SEL + ', div, span')) {
if (!isVisible(el) || isOwnControl(el)) continue;
const text = el.textContent.trim().replace(/\s+/g, " ");
if (text.length < 3 || text.length > 80) continue;
texts.add(text);
}
}
return [...texts];
}
async function waitForMenuText(text, opts = {}, timeout = 3500) {
try {
return await waitForText(text, opts, timeout);
} catch (err) {
await delay(250);
console.warn("[IQ diag] visible action texts:", collectVisibleActionTexts());
throw err;
}
}
async function waitForOptionalMenuText(text, opts = {}, timeout = 1600) {
try {
return await waitForText(text, opts, timeout);
} catch {
return null;
}
}
async function waitForAnyMenuText(options, timeout = 2600) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
for (const option of options) {
const el = findByText(option.text, option.opts || {});
if (el) return { el, option };
}
await delay(120);
}
await delay(250);
console.warn("[IQ diag] visible action texts:", collectVisibleActionTexts());
throw new Error(
`instagram-quick-actions: waitForAnyMenuText timeout — ${options.map((option) => `"${option.text}"`).join(", ")}`
);
}
/** One-time helper: log every clickable inside a post so we can identify the dots button. */
function diagnosePosts() {
const posts = document.querySelectorAll("article");
if (!posts.length) { console.warn("[IQ diag] No <article> elements found on page."); return; }
const post = posts[0];
const els = [
...post.querySelectorAll('button, [role="button"], [tabindex="0"], [tabindex="-1"], [aria-label], svg'),
];
console.log(`[IQ diag] ${els.length} clickable(s) in first article:`);
els.forEach((el, i) => {
console.log(
` [${i}] <${el.tagName.toLowerCase()}>`
+ ` aria-label="${el.getAttribute("aria-label") ?? ""}"`
+ ` role="${el.getAttribute("role") ?? ""}"`
+ ` text="${el.textContent.trim().slice(0, 40)}"`
+ ` rect=${JSON.stringify(el.getBoundingClientRect().toJSON())}`
);
});
}
setTimeout(diagnosePosts, 3000);
// ── Action flows ───────────────────────────────────────────────────────────
async function doNotInterested(post) {
if (actionInProgress) return;
const dots = getDotsButton(post);
if (!dots) { console.warn("[IQ] dots button not found"); return; }
actionInProgress = true;
try {
robustClick(dots);
robustClick(await waitForMenuText("not interested", { partial: true }));
await delay(300);
const dontSuggestAction = await waitForOptionalMenuText(
"suggest posts from",
{ partial: true },
2200
);
if (dontSuggestAction) {
robustClick(dontSuggestAction);
await delay(200);
}
await delay(200);
} catch (err) {
console.error("[IQ] Not Interested flow failed:", err);
pressEscape();
} finally {
actionInProgress = false;
}
}
async function doBlock(post) {
if (actionInProgress) return;
const dots = getDotsButton(post);
if (!dots) { console.warn("[IQ] dots button not found"); return; }
actionInProgress = true;
try {
// Three-dot menu → Report
robustClick(dots);
robustClick(await waitForMenuText("report", { partial: true }));
await delay(350);
// Report reason → "I just don't like it"
robustClick(await waitForMenuText("i just don't like it", { partial: true }));
await delay(350);
// Post-report suggestion → "Block @username" (partial on "block ")
const blockAccountAction = await waitForMenuText("block ", { partial: true });
robustClick(blockAccountAction);
await delay(350);
// Some variants show a standalone "Block" confirmation, while others
// finish with a visible Close action after the account row click.
let confirmBlockAction = await waitForOptionalMenuText("block");
if (!confirmBlockAction) {
const retryBlockAccountAction = findByText("block ", { partial: true });
if (retryBlockAccountAction) {
robustClick(retryBlockAccountAction);
await delay(350);
confirmBlockAction = await waitForOptionalMenuText("block");
}
}
if (confirmBlockAction) {
robustClick(confirmBlockAction);
await delay(250);
}
// Success screen → Dismiss / Close
const exitAction = await waitForAnyMenuText([
{ text: "dismiss", opts: { partial: true } },
{ text: "close" },
]);
robustClick(exitAction.el);
} catch (err) {
console.error("[IQ] Block flow failed:", err);
pressEscape();
} finally {
actionInProgress = false;
}
}
// ── Button factory ─────────────────────────────────────────────────────────
function makeBtn(ariaLabel, svgBody, onClick) {
const btn = document.createElement("button");
btn.type = "button";
btn.setAttribute(OWN_ATTR, "");
btn.setAttribute("aria-label", ariaLabel);
btn.title = ariaLabel;
Object.assign(btn.style, {
background: "linear-gradient(180deg, rgba(255,255,255,0.72), rgba(242,244,247,0.58))",
border: "1px solid rgba(15,23,42,0.06)",
borderRadius: "999px",
boxShadow: "0 8px 18px rgba(15,23,42,0.08)",
cursor: "pointer",
padding: "5px",
margin: "0",
color: "#0f172a",
opacity: "0.9",
lineHeight: "0",
flexShrink: "0",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "transform 140ms ease, box-shadow 140ms ease, background 140ms ease",
backdropFilter: "blur(10px)",
});
btn.innerHTML =
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" ` +
`viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ` +
`stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${svgBody}</svg>`;
btn.addEventListener("mouseenter", () => {
btn.style.transform = "translateY(-1px) scale(1.03)";
btn.style.boxShadow = "0 10px 20px rgba(15,23,42,0.12)";
btn.style.background = "linear-gradient(180deg, rgba(255,255,255,0.84), rgba(248,250,252,0.7))";
});
btn.addEventListener("mouseleave", () => {
btn.style.transform = "translateY(0) scale(1)";
btn.style.boxShadow = "0 8px 18px rgba(15,23,42,0.08)";
btn.style.background = "linear-gradient(180deg, rgba(255,255,255,0.72), rgba(242,244,247,0.58))";
});
btn.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
onClick();
});
return btn;
}
// Circle-X → "Not Interested"
const SVG_NOT_INTERESTED =
'<circle cx="12" cy="12" r="9"/>' +
'<line x1="8" y1="8" x2="16" y2="16"/>' +
'<line x1="16" y1="8" x2="8" y2="16"/>';
// Circle with diagonal slash → "Block Account"
const SVG_BLOCK =
'<circle cx="12" cy="12" r="9"/>' +
'<line x1="4.22" y1="4.22" x2="19.78" y2="19.78"/>';
// ── Injection ──────────────────────────────────────────────────────────────
function injectIntoPost(post) {
if (post.hasAttribute(INJECTED_ATTR)) return;
post.setAttribute(INJECTED_ATTR, "");
// Use absolute positioning so the wrapper is never clipped by
// overflow:hidden on the native flex containers.
if (getComputedStyle(post).position === "static") {
post.style.position = "relative";
}
const wrap = document.createElement("div");
wrap.setAttribute(OWN_ATTR, "");
Object.assign(wrap.style, {
position: "absolute",
top: "4px",
right: "132px",
display: "flex",
alignItems: "center",
gap: "6px",
zIndex: "10",
padding: "4px",
borderRadius: "999px",
background: "rgba(255,255,255,0.42)",
border: "1px solid rgba(15,23,42,0.06)",
boxShadow: "0 8px 20px rgba(15,23,42,0.08)",
backdropFilter: "blur(14px)",
});
wrap.appendChild(makeBtn("Not Interested", SVG_NOT_INTERESTED, () => doNotInterested(post)));
wrap.appendChild(makeBtn("Block Account", SVG_BLOCK, () => doBlock(post)));
post.appendChild(wrap);
}
function scan() {
document.querySelectorAll("article").forEach(injectIntoPost);
}
const pageObserver = new MutationObserver(scan);
pageObserver.observe(document.body, { childList: true, subtree: true });
scan();
// Periodic fallback for React hydration races
setInterval(scan, 2000);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment