// ==UserScript==
// @name xgomi - X blocklist filter
// @namespace https://github.com/mikumiku-jp/xgomi
// @version 1.0.0
// @description xgomiのブロックリストに載っているアカウントとその投稿を X から非表示にします
// @author mikumiku-jp
// @license MIT
// @homepageURL https://github.com/mikumiku-jp/xgomi
// @supportURL https://github.com/mikumiku-jp/xgomi/issues
// @downloadURL https://raw.githubusercontent.com/mikumiku-jp/xgomi/main/userscript/xgomi.user.js
// @updateURL https://raw.githubusercontent.com/mikumiku-jp/xgomi/main/userscript/xgomi.user.js
// @match https://x.com/*
// @match https://twitter.com/*
// @match https://mobile.x.com/*
// @match https://mobile.twitter.com/*
// @run-at document-start
// @grant unsafeWindow
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @connect raw.githubusercontent.com
// @connect github.com
// @connect githubusercontent.com
// ==/UserScript==
(() => {
const W = (typeof unsafeWindow !== "undefined" && unsafeWindow) || window;
const DEFAULT_SOURCE =
"https://raw.githubusercontent.com/mikumiku-jp/xgomi/main/dist/blocklist.csv";
const LEGACY_SOURCES = [
"https://raw.githubusercontent.com/mikumiku-jp/xgomi/main/dist/ids.txt",
];
const K = {
sources: "xg_sources",
local: "xg_local",
cache: "xg_cache",
map: "xg_map",
settings: "xg_settings",
stats: "xg_stats",
log: "xg_log",
qid: "xg_qid",
except: "xg_except",
report: "xg_report",
};
const get = (k, d) => {
try {
const v = GM_getValue(k, undefined);
if (v === undefined || v === null) return d;
return typeof v === "string" ? JSON.parse(v) : v;
} catch (e) {
return d;
}
};
const set = (k, v) => {
try {
GM_setValue(k, JSON.stringify(v));
} catch (e) {}
};
const DEFAULT_SETTINGS = {
enabled: true,
autoUpdateHours: 6,
blockMentions: false,
blockProfile: true,
pruneNetwork: true,
removeMode: "remove",
showButton: true,
addButton: true,
autoResolve: true,
logLimit: 200,
};
let settings = Object.assign({}, DEFAULT_SETTINGS, get(K.settings, {}));
let sources = get(K.sources, null);
if (!Array.isArray(sources) || !sources.length)
sources = [{ url: DEFAULT_SOURCE, enabled: true, builtin: true }];
let local = get(K.local, []);
const except = get(K.except, []);
let report = get(K.report, []);
const cache = get(K.cache, {});
for (const s of sources)
if (s.builtin && LEGACY_SOURCES.includes(s.url)) {
delete cache[s.url];
s.url = DEFAULT_SOURCE;
}
let idMap = get(K.map, {});
let stats = Object.assign({ tweets: 0, users: 0, api: 0 }, get(K.stats, {}));
let logs = get(K.log, []);
const saveSettings = () => set(K.settings, settings);
const saveSources = () => set(K.sources, sources);
const saveLocal = () => set(K.local, local);
const saveExcept = () => set(K.except, except);
const saveReport = () => set(K.report, report);
const saveCache = () => set(K.cache, cache);
const saveMap = () => set(K.map, idMap);
let statsDirty = false;
setInterval(() => {
if (statsDirty) {
set(K.stats, stats);
statsDirty = false;
}
}, 3000);
const bypass = new Set();
const RE_ID = /^\d{5,25}$/;
const RE_NAME = /^[A-Za-z0-9_]{1,15}$/;
function parseLine(rawLine) {
let s = String(rawLine || "").trim();
if (!s || s.startsWith("#") || s.startsWith("//") || s.startsWith(";"))
return null;
s = s.split(/[#\t,]| \/\/ /)[0].trim();
// 「1646... someuser」のような空白区切りも読む。ここで区切り文字に空白を
// 足すと「username: someuser」の書き方を壊すので、前半が数値IDのときだけ切る
const sp = /^(\d{5,25})\s+\S/.exec(s);
if (sp) s = sp[1];
if (!s) return null;
let type = null;
const pref =
/^(id|userid|user_id|username|user|name|screen_name)\s*[:=]\s*(.+)$/i.exec(
s,
);
if (pref) {
const p = pref[1].toLowerCase();
type = p === "id" || p === "userid" || p === "user_id" ? "id" : "name";
s = pref[2].trim();
}
s = s
.replace(/^https?:\/\/(mobile\.)?(x|twitter)\.com\//i, "")
.replace(/^@+/, "")
.replace(/\/.*$/, "")
.trim();
if (!s) return null;
if (type === "id" || (!type && RE_ID.test(s)))
return RE_ID.test(s) ? { type: "id", v: s } : null;
if (!RE_NAME.test(s)) return null;
return { type: "name", v: s.toLowerCase() };
}
const DELISTED = /^(delisted|removed|inactive|off)$/i;
function parseSource(text) {
const raw = String(text || ""),
entries = [],
map = Object.create(null),
seen = new Set();
const push = (e) => {
if (!e) return;
const k = e.type + ":" + e.v;
if (seen.has(k)) return;
seen.add(k);
entries.push(e);
};
const pair = (id, name) => {
const i = String(id || ""),
n = String(name || "").replace(/^@+/, "");
if (RE_ID.test(i) && RE_NAME.test(n)) map[i] = n;
};
const done = () => {
let bare = 0;
for (const e of entries) if (e.type === "id" && !map[e.v]) bare++;
return {
entries,
map,
bare,
pairs: Object.keys(map).length,
};
};
const t = raw.trim();
if (t.startsWith("{") || t.startsWith("[")) {
try {
const j = JSON.parse(t);
const arr = Array.isArray(j)
? j
: j.accounts || j.users || j.entries || j.list || [];
for (const a of arr) {
if (!a) continue;
if (typeof a === "string") {
push(parseLine(a));
continue;
}
if (DELISTED.test(a.status || "")) continue;
const id = a.id || a.user_id || a.rest_id || a.id_str || "";
const name = a.username || a.screen_name || a.handle || "";
if (RE_ID.test(String(id))) {
push({ type: "id", v: String(id) });
pair(id, name);
} else if (RE_NAME.test(String(name)))
push({ type: "name", v: String(name).toLowerCase() });
}
return done();
} catch (e) {}
}
const lines = raw.split(/\r?\n/);
const head = (lines.find((l) => l.trim()) || "").trim();
const cols = head
.toLowerCase()
.split(",")
.map((c) => c.trim());
const iId = cols.findIndex(
(c) => c === "id" || c === "user_id" || c === "userid",
);
const iName = cols.findIndex(
(c) => c === "username" || c === "screen_name" || c === "handle",
);
const iStatus = cols.indexOf("status");
// 列名の行が1行あるなら、対応表として読めない形式でも本文として扱わない
// (「user_id,note」を @user_id として登録してしまうのを防ぐ)
const hasHeader = iId >= 0 || iName >= 0 || iStatus >= 0;
if (iId >= 0 && iName >= 0) {
for (const line of lines) {
const s = line.trim();
if (!s || s === head || /^[#;]/.test(s)) continue;
const c = s.split(",").map((x) => x.trim());
if (iStatus >= 0 && DELISTED.test(c[iStatus] || "")) continue;
const id = c[iId] || "",
nm = (c[iName] || "").replace(/^@+/, "");
if (RE_ID.test(id)) {
push({ type: "id", v: id });
pair(id, nm);
} else if (RE_NAME.test(nm))
push({ type: "name", v: nm.toLowerCase() });
}
return done();
}
for (const line of lines) {
if (hasHeader && line.trim() === head) continue;
const e = parseLine(line);
if (!e) continue;
push(e);
if (e.type !== "id") continue;
// 2列目は理由・カテゴリ・件数のことがある。列名で保証がない以上、
// @ で明示されたものだけ username として信用する。推測すると
// 「1646...,spam」から @spam を消してしまう
const m = /^[^#;]*?[,\t ]\s*@([A-Za-z0-9_]{1,15})\s*(?:[#;].*)?$/.exec(
line.trim(),
);
if (m) pair(e.v, m[1]);
}
return done();
}
const parseText = (text) => parseSource(text).entries;
const blockIds = new Set();
const blockNames = new Set();
const parsedCache = new Map();
function parsedOf(url, text) {
const c = parsedCache.get(url);
if (c && c.text === text) return c.parsed;
const parsed = parseSource(text);
parsedCache.set(url, { text, parsed });
return parsed;
}
let feedMap = Object.create(null);
const resolvable = new Set();
const nameOf = (id) => idMap[id] || feedMap[id] || "";
const exceptIds = new Set(),
exceptNames = new Set();
function rebuild() {
blockIds.clear();
blockNames.clear();
exceptIds.clear();
exceptNames.clear();
const add = (e) => {
if (e.type === "id") blockIds.add(e.v);
else blockNames.add(e.v);
};
for (const l of except) {
const e = parseLine(l);
if (!e) continue;
if (e.type === "id") exceptIds.add(e.v);
else exceptNames.add(e.v);
}
feedMap = Object.create(null);
resolvable.clear();
for (const src of sources) {
const c = cache[src.url];
const p = c && c.text ? parsedOf(src.url, c.text) : null;
src.bare = p ? p.bare : 0;
src.pairs = p ? p.pairs : 0;
if (!src.enabled || !p) continue;
p.entries.forEach(add);
Object.assign(feedMap, p.map);
if (src.resolve)
for (const e of p.entries)
if (e.type === "id" && !p.map[e.v]) resolvable.add(e.v);
}
for (const l of local) {
const e = parseLine(l);
if (!e) continue;
add(e);
if (e.type === "id") resolvable.add(e.v);
}
for (const id of [...blockIds]) {
const n = nameOf(id);
if (n) blockNames.add(n.toLowerCase());
}
for (const id of exceptIds) {
blockIds.delete(id);
const n = nameOf(id);
if (n) blockNames.delete(n.toLowerCase());
}
for (const n of exceptNames) blockNames.delete(n);
for (const t of bypass) {
blockIds.delete(t);
blockNames.delete(t.toLowerCase());
}
buildHints();
seen = new WeakSet();
scheduleScan();
updateDash();
}
let hintRe = null;
const SCAN_RE =
/"(?:screen_name|rest_id|user_id_str|id_str)":"([A-Za-z0-9_]{1,25})"/g;
function buildHints() {
if (blockIds.size + blockNames.size > 200) {
hintRe = null;
return;
}
const parts = [];
for (const id of blockIds) parts.push(id);
for (const n of blockNames) parts.push('"' + n + '"');
hintRe = parts.length ? new RegExp(parts.join("|"), "i") : null;
}
function rawHasBlocked(raw) {
if (!blockIds.size && !blockNames.size) return false;
if (hintRe) return hintRe.test(raw);
SCAN_RE.lastIndex = 0;
let m;
while ((m = SCAN_RE.exec(raw))) {
const v = m[1];
if (blockIds.has(v) || blockNames.has(v.toLowerCase())) return true;
}
return false;
}
const unresolvedIds = () => [...blockIds].filter((id) => !nameOf(id));
const pendingIds = () => unresolvedIds().filter((id) => resolvable.has(id));
const needsResolveUI = () =>
unresolvedIds().length > 0 ||
Object.keys(idMap).length > 0 ||
sources.some((s) => s.enabled && s.bare > 0);
function httpGet(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url,
headers: { "Cache-Control": "no-cache" },
timeout: 20000,
onload: (r) =>
r.status >= 200 && r.status < 400
? resolve({ text: r.responseText, finalUrl: r.finalUrl || url })
: reject(new Error("HTTP " + r.status)),
onerror: () => reject(new Error("network error")),
ontimeout: () => reject(new Error("timeout")),
});
});
}
async function refreshSource(src) {
const r = await httpGet(
src.url + (src.url.includes("?") ? "&" : "?") + "_=" + Date.now(),
);
const p = parseSource(r.text);
cache[src.url] = { text: r.text, at: Date.now(), count: p.entries.length };
src.bare = p.bare;
src.pairs = p.pairs;
saveCache();
return p.entries.length;
}
function askResolve(src, after) {
if (!src.bare || src.resolve !== undefined) {
if (after) after();
return;
}
confirmDialog(
{
title: "逆引きしますか?",
desc:
"このリストは user id だけで username の対応表を配っていません(" +
src.bare +
" 件)。x.com に問い合わせて username を逆引きしますか?逆引きしない場合、id 指定のアカウントは API 層でしか消せません。",
ok: "逆引きする",
no: "逆引きしない",
},
() => {
src.resolve = true;
saveSources();
rebuild();
resolveIds(50).then((n) => {
if (n) toast(n + " 件解決しました");
if (dash) renderTab();
});
if (after) after();
},
() => {
src.resolve = false;
saveSources();
rebuild();
if (after) after();
},
);
}
async function refreshAll(force, onProgress) {
const now = Date.now();
let ok = 0,
ng = 0;
const todo = sources.filter(
(s) =>
s.enabled &&
(force ||
!cache[s.url] ||
now - (cache[s.url].at || 0) >= settings.autoUpdateHours * 3600e3),
);
for (const s of todo) {
if (onProgress) onProgress(ok + ng, todo.length);
try {
await refreshSource(s);
s.error = null;
ok++;
} catch (e) {
ng++;
s.error = String(e.message || e);
}
}
if (onProgress) onProgress(ok + ng, todo.length);
saveSources();
rebuild();
if (settings.autoResolve) resolveIds(30);
return { ok, ng };
}
const auth = { bearer: "", csrf: "" };
const FALLBACK_BEARER =
"Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
let queryId = get(K.qid, "") || "xvmVfRLmnr1alc5f2dib0Q";
const cookie = (n) => {
const m = new RegExp("(?:^|; )" + n + "=([^;]*)").exec(
document.cookie || "",
);
return m ? decodeURIComponent(m[1]) : "";
};
const USER_FEATURES = {
hidden_profile_subscriptions_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
highlights_tweets_tab_ui_enabled: true,
responsive_web_twitter_article_notes_tab_enabled: true,
subscriptions_feature_can_gift_premium: true,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
};
async function findQueryId() {
const srcs = [...document.querySelectorAll("script[src]")]
.map((s) => s.src)
.filter((u) => /client-web\/(main|api)\.[0-9a-f]+\.js/.test(u));
for (const u of srcs) {
try {
const t = await W.fetch(u).then((r) => r.text());
const m =
/queryId:"([A-Za-z0-9_-]+)",operationName:"UserByRestId"/.exec(t);
if (m) {
queryId = m[1];
set(K.qid, queryId);
return queryId;
}
} catch (e) {}
}
return null;
}
async function apiUserById(id) {
const url =
"https://" +
location.hostname +
"/i/api/graphql/" +
queryId +
"/UserByRestId" +
"?variables=" +
encodeURIComponent(
JSON.stringify({ userId: id, withSafetyModeUserFields: true }),
) +
"&features=" +
encodeURIComponent(JSON.stringify(USER_FEATURES));
const res = await W.fetch(url, {
credentials: "include",
headers: {
authorization: auth.bearer || FALLBACK_BEARER,
"x-csrf-token": auth.csrf || cookie("ct0"),
"x-twitter-active-user": "yes",
"x-twitter-auth-type": "OAuth2Session",
},
});
if (res.status === 404) return { retry: true };
const j = await res.json().catch(() => null);
const u = j && j.data && j.data.user && j.data.user.result;
const sn =
u &&
((u.core && u.core.screen_name) || (u.legacy && u.legacy.screen_name));
return { name: sn || "", gone: !!(j && j.data && !sn) };
}
let resolving = false;
async function resolveIds(limit, onProgress, all) {
if (resolving) return 0;
const targets = (all ? unresolvedIds() : pendingIds()).slice(
0,
limit || 20,
);
if (!targets.length) return 0;
resolving = true;
let n = 0,
done = 0,
refreshed = false;
try {
for (const id of targets) {
if (onProgress) onProgress(done, targets.length);
done++;
try {
let r = await apiUserById(id);
if (r.retry && !refreshed) {
refreshed = true;
if (await findQueryId()) r = await apiUserById(id);
}
if (r.name) {
idMap[id] = r.name;
n++;
} else if (r.gone) {
idMap[id] = "";
}
} catch (e) {}
await new Promise((r) => setTimeout(r, 500));
}
} finally {
resolving = false;
if (onProgress) onProgress(targets.length, targets.length);
saveMap();
rebuild();
}
return n;
}
let logTimer = 0;
function pushLog(kind, who, where) {
logs.unshift({ t: Date.now(), kind, who, where });
if (logs.length > settings.logLimit) logs.length = settings.logLimit;
if (logs.length % 5 === 0 || logs.length < 5) set(K.log, logs);
if (dash && dash.tab === "log" && !logTimer)
logTimer = setTimeout(() => {
logTimer = 0;
if (dash && dash.tab === "log") renderTab();
}, 400);
}
const USER_ID_KEYS = new Set(["user_id_str", "userId", "user_id", "rest_id"]);
function harvest(obj) {
let changed = false;
const walk = (n, d) => {
if (!n || d > 24 || typeof n !== "object") return;
if (Array.isArray(n)) {
for (const v of n) walk(v, d + 1);
return;
}
const id = n.rest_id || n.id_str || (n.legacy && n.legacy.id_str);
if (id && blockIds.has(String(id)) && !nameOf(id)) {
const sn =
n.screen_name ||
(n.core && n.core.screen_name) ||
(n.legacy && n.legacy.screen_name);
if (sn && RE_NAME.test(String(sn))) {
idMap[id] = sn;
changed = true;
}
}
for (const k in n) {
const v = n[k];
if (v && typeof v === "object") walk(v, d + 1);
}
};
try {
walk(obj, 0);
} catch (e) {}
if (changed) {
saveMap();
rebuild();
}
return changed;
}
function jsonHasBlocked(n, d) {
if (!n || typeof n !== "object" || d > 24) return false;
if (Array.isArray(n)) {
for (const v of n) if (jsonHasBlocked(v, d + 1)) return true;
return false;
}
const sn =
n.screen_name ||
(n.core && n.core.screen_name) ||
(n.legacy && n.legacy.screen_name);
if (sn && blockNames.has(String(sn).toLowerCase())) return true;
if (sn) {
const id = n.rest_id || n.id_str || (n.legacy && n.legacy.id_str);
if (id && blockIds.has(String(id))) return true;
}
for (const k in n) {
const v = n[k];
if (
!settings.blockMentions &&
(k === "user_mentions" || k === "mentions")
)
continue;
if (typeof v === "string" || typeof v === "number") {
if (
USER_ID_KEYS.has(k) &&
blockIds.has(String(v)) &&
(k !== "rest_id" || n.__typename === "User" || sn)
)
return true;
} else if (v && typeof v === "object") {
if (jsonHasBlocked(v, d + 1)) return true;
}
}
return false;
}
const isEntryLike = (el) =>
!!(
el &&
typeof el === "object" &&
(typeof el.entryId === "string" ||
el.itemContent ||
(el.item && el.item.itemContent) ||
el.tweet_results ||
el.user_results)
);
function pruneJson(n, d) {
if (!n || typeof n !== "object" || d > 24) return 0;
let removed = 0;
if (Array.isArray(n)) {
for (let i = n.length - 1; i >= 0; i--) {
const el = n[i];
if (
el &&
typeof el === "object" &&
isEntryLike(el) &&
jsonHasBlocked(el, 0)
) {
n.splice(i, 1);
removed++;
} else removed += pruneJson(el, d + 1);
}
return removed;
}
for (const k in n) removed += pruneJson(n[k], d + 1);
return removed;
}
const API_RE =
/\/i\/api\/|\/graphql\/|\/1\.1\/|\/2\/(timeline|search|notifications)/;
const shortUrl = (u) => {
try {
const p = new URL(u, location.href).pathname.split("/");
return p[p.length - 1] || u;
} catch (e) {
return u;
}
};
function processBody(raw, url) {
if (typeof raw !== "string" || raw.length < 2) return raw;
if (!settings.enabled || !settings.pruneNetwork) return raw;
if (raw[0] !== "{" && raw[0] !== "[") return raw;
if (!rawHasBlocked(raw)) return raw;
let obj;
try {
obj = JSON.parse(raw);
} catch (e) {
return raw;
}
harvest(obj);
let removed = 0;
try {
removed = pruneJson(obj, 0);
} catch (e) {
return raw;
}
if (!removed) return raw;
stats.api += removed;
statsDirty = true;
pushLog("api", removed + "件", shortUrl(url));
try {
return JSON.stringify(obj);
} catch (e) {
return raw;
}
}
function hookXHR() {
const P = W.XMLHttpRequest && W.XMLHttpRequest.prototype;
if (!P) return;
const rt = Object.getOwnPropertyDescriptor(P, "responseText");
const rp = Object.getOwnPropertyDescriptor(P, "response");
const open = P.open,
send = P.send,
srh = P.setRequestHeader;
if (!rt || !rt.get) return;
P.open = function (_method, u) {
this.__xgUrl = String(u || "");
return open.apply(this, arguments);
};
P.setRequestHeader = function (k, v) {
const kk = String(k).toLowerCase();
if (kk === "authorization" && /^Bearer /i.test(String(v)))
auth.bearer = String(v);
else if (kk === "x-csrf-token") auth.csrf = String(v);
return srh.apply(this, arguments);
};
P.send = function () {
const url = this.__xgUrl || "";
if (API_RE.test(url)) {
const xhr = this;
let rawCache = null,
outCache = null,
objCache = null;
const proc = (raw) => {
if (raw === rawCache) return outCache;
rawCache = raw;
outCache = processBody(raw, url);
return outCache;
};
try {
Object.defineProperty(xhr, "responseText", {
configurable: true,
get() {
try {
return proc(rt.get.call(xhr));
} catch (e) {
return "";
}
},
});
if (rp && rp.get) {
Object.defineProperty(xhr, "response", {
configurable: true,
get() {
let v;
try {
v = rp.get.call(xhr);
} catch (e) {
return null;
}
if (typeof v === "string") return proc(v);
if (
v &&
typeof v === "object" &&
settings.enabled &&
settings.pruneNetwork &&
(blockIds.size || blockNames.size) &&
!(v instanceof W.Blob) &&
!(v instanceof W.ArrayBuffer)
) {
if (v !== objCache) {
objCache = v;
try {
harvest(v);
const n = pruneJson(v, 0);
if (n) {
stats.api += n;
statsDirty = true;
pushLog("api", n + "件", shortUrl(url));
}
} catch (e) {}
}
}
return v;
},
});
}
} catch (e) {}
}
return send.apply(this, arguments);
};
}
function hookFetch() {
const orig = W.fetch;
if (typeof orig !== "function") return;
W.fetch = function (...args) {
const p = orig.apply(this, args);
let url = "";
try {
const a = args[0];
url = typeof a === "string" ? a : (a && a.url) || "";
} catch (e) {}
if (!url || !API_RE.test(url) || url.includes("UserByRestId")) return p;
return p.then(async (res) => {
try {
if (
!/json/i.test(
(res.headers && res.headers.get("content-type")) || "",
)
)
return res;
const raw = await res.clone().text();
const out = processBody(raw, url);
if (out === raw) return res;
return new W.Response(out, {
status: res.status,
statusText: res.statusText,
headers: res.headers,
});
} catch (e) {
return res;
}
});
};
}
hookXHR();
hookFetch();
const RESERVED = new Set([
"i",
"home",
"explore",
"search",
"notifications",
"messages",
"settings",
"compose",
"hashtag",
"intent",
"login",
"logout",
"signup",
"about",
"tos",
"privacy",
"account",
"following",
"followers",
"topics",
"lists",
"bookmarks",
"jobs",
"status",
"share",
"widgets",
"download",
"help",
"x",
"twitter",
]);
function handlesIn(el) {
const out = new Set();
let links;
try {
links = el.querySelectorAll('a[href^="/"]');
} catch (e) {
return out;
}
for (const a of links) {
if (!settings.blockMentions && a.closest('[data-testid="tweetText"]'))
continue;
const href = a.getAttribute("href") || "";
const idm = /^\/i\/user\/(\d+)/.exec(href);
if (idm) {
out.add("\u0000" + idm[1]);
continue;
}
const m = /^\/([A-Za-z0-9_]{1,15})(?:$|[/?#])/.exec(href);
if (!m) continue;
const n = m[1].toLowerCase();
if (!RESERVED.has(n)) out.add(n);
}
return out;
}
function hitOf(el) {
const hs = handlesIn(el);
for (const h of hs) {
if (h[0] === "\u0000") {
if (blockIds.has(h.slice(1))) return h.slice(1);
continue;
}
if (blockNames.has(h)) return "@" + h;
}
return hs.size ? null : undefined;
}
function kill(el, kind, who) {
if (!el || !el.isConnected) return false;
try {
if (settings.removeMode === "hide") {
el.style.setProperty("display", "none", "important");
el.setAttribute("data-xgomi", "1");
} else el.remove();
} catch (e) {
return false;
}
if (kind === "user") stats.users++;
else stats.tweets++;
statsDirty = true;
pushLog(kind, who || "?", location.pathname);
return true;
}
function container(el) {
const cell = el.closest('[data-testid="cellInnerDiv"]');
if (cell) {
if (
el.matches('[data-testid="UserCell"]') &&
cell.querySelectorAll('[data-testid="UserCell"]').length > 1
)
return el;
if (el.matches("article") && cell.querySelectorAll("article").length > 1)
return el;
return cell;
}
const li = el.closest(
'li[role="listitem"], div[role="listitem"], div[role="option"]',
);
return li || el;
}
const CANDIDATES = [
'article[data-testid="tweet"]',
'article[role="article"]',
'[data-testid="cellInnerDiv"]',
'[data-testid="UserCell"]',
'[data-testid="tweet"]',
'div[role="option"]',
'[data-testid="typeaheadResult"]',
'li[role="listitem"]',
'[data-testid="HoverCard"]',
].join(",");
let seen = new WeakSet();
function sweep(root) {
const base = root && root.nodeType === 1 ? root : document;
injectAll(base);
if (!settings.enabled || (!blockNames.size && !blockIds.size)) {
blockProfilePage();
return;
}
let nodes;
try {
nodes = base.querySelectorAll(CANDIDATES);
} catch (e) {
return;
}
const list =
base !== document && base.matches && base.matches(CANDIDATES)
? [base, ...nodes]
: nodes;
for (const el of list) {
if (
seen.has(el) ||
!el.isConnected ||
(el.getAttribute && el.getAttribute("data-xgomi"))
)
continue;
if (
el.matches('[data-testid="cellInnerDiv"]') &&
(el.querySelectorAll('[data-testid="UserCell"]').length > 1 ||
el.querySelectorAll("article").length > 1)
)
continue;
const who = hitOf(el);
if (!who) {
if (who === null) seen.add(el);
continue;
}
const isUser = !!(
el.matches('[data-testid="UserCell"]') ||
el.querySelector('[data-testid="UserCell"]')
);
const target = container(el);
kill(
target,
isUser && !target.querySelector("article") ? "user" : "tweet",
who,
);
}
blockProfilePage();
}
const ADD_ICON =
'';
const UNDO_ICON =
'';
function authorOf(el) {
let links;
try {
links = el.querySelectorAll('[data-testid="User-Name"] a[href^="/"]');
} catch (e) {
return "";
}
for (const a of links) {
const m = /^\/([A-Za-z0-9_]{1,15})$/.exec(a.getAttribute("href") || "");
if (m && !RESERVED.has(m[1].toLowerCase())) return m[1];
}
return "";
}
let menuTarget = null;
function watchCaret() {
document.addEventListener(
"pointerdown",
(e) => {
const t = e.target;
const caret =
t && t.closest ? t.closest('[data-testid="caret"]') : null;
const article = caret && caret.closest("article");
const name = article ? authorOf(article) : "";
menuTarget = name ? { name, article, at: Date.now() } : null;
},
true,
);
}
function closeNativeMenu() {
const mask = document.querySelector('[data-testid="mask"]');
if (mask) {
mask.click();
return;
}
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Escape",
keyCode: 27,
bubbles: true,
}),
);
}
function injectMenuItem(menu) {
if (!settings.addButton || menu.querySelector("[data-xgomi-item]")) return;
const target = menuTarget;
if (!target || Date.now() - target.at > 5000 || !target.article.isConnected)
return;
const tpl =
menu.querySelector('div[role="menuitem"][data-testid="block"]') ||
menu.querySelector('div[role="menuitem"]');
if (!tpl) return;
const name = target.name;
const listed = local.includes("username:" + name.toLowerCase());
const item = tpl.cloneNode(true);
item.removeAttribute("data-testid");
item.setAttribute("data-xgomi-item", name);
const svg = item.querySelector("svg");
if (svg) svg.innerHTML = listed ? UNDO_ICON : ADD_ICON;
const span = item.querySelector("span") || item;
span.textContent = listed
? "@" + name + " をxgomiから外す"
: "@" + name + " をxgomiで消す";
item.addEventListener(
"click",
(e) => {
e.preventDefault();
e.stopPropagation();
closeNativeMenu();
if (listed) {
confirmDialog(
{
title: "xgomiから外しますか?",
desc:
"@" +
name +
" の投稿がタイムラインや検索に再び表示されるようになります。",
ok: "外す",
},
() => undoAdd(name),
);
return;
}
confirmDialog(
{
title: "xgomiで消しますか?",
desc:
"@" +
name +
" の投稿を、タイムライン・検索・返信などあらゆる場所から非表示にします。いつでも元に戻せます。",
ok: "消す",
danger: true,
},
() => addFromPost(name, target.article),
);
},
true,
);
const anchor = tpl.getAttribute("data-testid") === "block" ? tpl : null;
if (anchor && anchor.nextSibling)
anchor.parentElement.insertBefore(item, anchor.nextSibling);
else (tpl.parentElement || menu).appendChild(item);
}
function injectAll(base) {
if (!settings.addButton) return;
let menus;
try {
menus =
base.matches && base.matches('[role="menu"]')
? [base, ...base.querySelectorAll('[role="menu"]')]
: base.querySelectorAll('[role="menu"]');
} catch (e) {
return;
}
for (const m of menus) injectMenuItem(m);
}
function addFromPost(name, article) {
const key = "username:" + name.toLowerCase();
if (!local.includes(key)) {
local.push(key);
saveLocal();
}
const link = article.querySelector('a[href*="/status/"]');
const href = link ? link.getAttribute("href") || "" : "";
const m = /^\/[A-Za-z0-9_]{1,15}\/status\/\d+/.exec(href);
if (!report.some((r) => r.name.toLowerCase() === name.toLowerCase())) {
report.push({
name,
url: m ? "https://x.com" + m[0] : "",
at: Date.now(),
});
saveReport();
}
rebuild();
toast("@" + name + " を追加", "取り消す", () => undoAdd(name));
}
function undoAdd(name) {
const i = local.indexOf("username:" + name.toLowerCase());
if (i >= 0) {
local.splice(i, 1);
saveLocal();
}
const j = report.findIndex(
(r) => r.name.toLowerCase() === name.toLowerCase(),
);
if (j >= 0) {
report.splice(j, 1);
saveReport();
}
rebuild();
toast("@" + name + " の追加を取り消しました");
}
let scanQueued = false,
scanRoots = [],
scanAll = false;
function scheduleScan(root) {
if (root && root.nodeType === 1 && scanRoots.length < 64)
scanRoots.push(root);
else scanAll = true;
if (scanQueued) return;
scanQueued = true;
requestAnimationFrame(() => {
scanQueued = false;
const roots = scanRoots,
all = scanAll;
scanRoots = [];
scanAll = false;
try {
if (all) sweep(document);
else for (const r of roots) if (r.isConnected) sweep(r);
} catch (e) {}
});
}
function startObserver() {
new MutationObserver((recs) => {
for (const r of recs)
for (const n of r.addedNodes) if (n.nodeType === 1) scheduleScan(n);
}).observe(document.documentElement, { childList: true, subtree: true });
setInterval(() => scheduleScan(), 2000);
}
const PALETTES = {
light: {
bg: "#ffffff",
panel: "#f7f9f9",
border: "#eff3f4",
text: "#0f1419",
muted: "#536471",
hover: "rgba(15,20,25,.1)",
btnText: "#ffffff",
btnBorder: "#cfd9de",
shadow:
"rgba(101,119,134,.2) 0 0 15px, rgba(101,119,134,.15) 0 0 3px 1px",
},
dim: {
bg: "#15202b",
panel: "#1e2732",
border: "#38444d",
text: "#f7f9f9",
muted: "#8b98a5",
hover: "rgba(247,249,249,.1)",
btnText: "#0f1419",
btnBorder: "#5c6e7e",
shadow:
"rgba(136,153,166,.2) 0 0 15px, rgba(136,153,166,.15) 0 0 3px 1px",
},
dark: {
bg: "#000000",
panel: "#16181c",
border: "#2f3336",
text: "#e7e9ea",
muted: "#71767b",
hover: "rgba(231,233,234,.1)",
btnText: "#0f1419",
btnBorder: "#536471",
shadow:
"rgba(255,255,255,.2) 0 0 15px, rgba(255,255,255,.15) 0 0 3px 1px",
},
};
function chromatic(c) {
const m = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(c || "");
if (!m) return false;
const r = +m[1],
g = +m[2],
b = +m[3];
return Math.max(r, g, b) - Math.min(r, g, b) > 40;
}
function palette() {
let bg = "";
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) bg = (meta.content || "").toLowerCase();
if (!bg && document.body)
bg = getComputedStyle(document.body).backgroundColor;
let p = PALETTES.dark;
if (/#ffffff|255,\s*255,\s*255/.test(bg)) p = PALETTES.light;
else if (/#15202b|21,\s*32,\s*43/.test(bg)) p = PALETTES.dim;
let accent = "#1d9bf0";
try {
for (const sel of [
'[data-testid="tweetText"] a',
'a[href^="/hashtag/"]',
'[data-testid="trend"] a',
'main a[role="link"][style*="rgb"]',
]) {
const a = document.querySelector(sel);
if (!a) continue;
const c = getComputedStyle(a).color;
if (chromatic(c)) {
accent = c;
break;
}
}
} catch (e) {}
let font =
'"TwitterChirp", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
try {
if (document.body)
font = getComputedStyle(document.body).fontFamily + ", " + font;
} catch (e) {}
return Object.assign({ accent, font }, p);
}
const CSS = `
:host{all:initial}
*{box-sizing:border-box;margin:0;padding:0;font-family:var(--f)}
.ov{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:2147483646;display:flex;align-items:center;justify-content:center;animation:fade .1s ease}
.win{width:min(600px,100vw);height:min(680px,100vh);background:var(--bg);color:var(--tx);border-radius:16px;display:flex;flex-direction:column;overflow:hidden;box-shadow:var(--sh);animation:pop .12s cubic-bezier(.2,.8,.2,1)}
@keyframes fade{from{opacity:0}to{opacity:1}}
@keyframes pop{from{opacity:0;transform:scale(.98)}to{opacity:1;transform:none}}
@keyframes slideup{from{opacity:0;transform:translate(-50%,12px)}to{opacity:1;transform:translate(-50%,0)}}
@keyframes spin{to{transform:rotate(360deg)}}
@media (max-width:620px){.win{border-radius:0}}
.hd{display:flex;align-items:center;gap:20px;height:53px;padding:0 16px;flex:0 0 auto}
.ic{width:34px;height:34px;border-radius:9999px;border:0;background:transparent;color:var(--tx);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:19px;line-height:1}
.ic:hover{background:var(--hv)}
.ti{font-size:20px;font-weight:800;flex:1}
.tabs{display:flex;border-bottom:1px solid var(--bd);flex:0 0 auto}
.tab{flex:1;height:53px;display:flex;align-items:center;justify-content:center;position:relative;cursor:pointer;color:var(--mu);font-size:15px;font-weight:500;background:transparent;border:0;white-space:nowrap}
.tab:hover{background:var(--hv)}
.tab.on{color:var(--tx);font-weight:700}
.tab::after{content:"";position:absolute;bottom:0;left:50%;width:70%;max-width:60px;height:4px;border-radius:9999px;background:var(--ac);transform:translateX(-50%) scaleX(0);opacity:0;transition:transform .18s cubic-bezier(.2,.8,.2,1),opacity .18s ease}
.tab.on::after{transform:translateX(-50%) scaleX(1);opacity:1}
.bd{flex:1;overflow-y:auto;overscroll-behavior:contain}
.sec{border-bottom:1px solid var(--bd)}
.sh{padding:12px 16px 4px;font-size:20px;font-weight:800}
.row{display:flex;align-items:center;gap:12px;padding:12px 16px;min-height:52px}
.row.cl{cursor:pointer}
.row.cl:hover{background:var(--hv)}
.row .gr{flex:1;min-width:0}
.row .t{font-size:15px;font-weight:400;color:var(--tx)}
.row .d{font-size:13px;color:var(--mu);margin-top:2px;line-height:1.4}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.mu{color:var(--mu)}
.grid{display:grid;grid-template-columns:repeat(3,1fr)}
.st{padding:14px 16px;border-right:1px solid var(--bd);border-bottom:1px solid var(--bd)}
.st .n{font-size:20px;font-weight:800}
.st .l{font-size:13px;color:var(--mu);margin-top:2px}
.btn{height:34px;padding:0 16px;border-radius:9999px;border:1px solid transparent;font-size:14px;font-weight:700;cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:8px;transition:background .1s ease,filter .1s ease,transform .08s ease}
.btn:active:not([disabled]){transform:scale(.96)}
.sp{width:14px;height:14px;border-radius:9999px;border:2px solid currentColor;border-top-color:transparent;animation:spin .7s linear infinite;flex:0 0 auto}
.tab:focus-visible,.btn:focus-visible,.sw:focus-visible,.rd:focus-visible,.ic:focus-visible,.tf:focus-visible,.row.cl:focus-visible{outline:2px solid var(--ac);outline-offset:2px}
.bar.busy{cursor:progress}
.btn.p{background:var(--tx);color:var(--bt)}
.btn.p:hover{opacity:.9}
.btn.o{background:transparent;color:var(--tx);border-color:var(--bo)}
.btn.o:hover{background:var(--hv)}
.btn.a{background:var(--ac);color:#fff}
.btn.a:hover{filter:brightness(.92)}
.btn.d{background:transparent;color:#f4212e;border-color:rgba(244,33,46,.4)}
.btn.d:hover{background:rgba(244,33,46,.1)}
.btn[disabled]{cursor:default}
.btn[disabled]:not([aria-busy]){background:transparent;color:var(--mu);border-color:var(--bd);filter:none}
.btn[aria-busy]{opacity:.75}
.btn.sm{height:30px;padding:0 12px;font-size:13px}
.sw{width:44px;height:24px;border-radius:9999px;background:transparent;border:2px solid var(--mu);position:relative;flex:0 0 auto;cursor:pointer;transition:background .2s ease,border-color .2s ease}
.sw i{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:9999px;background:var(--mu);transition:transform .2s cubic-bezier(.2,.8,.2,1),background .2s ease}
.sw.on{background:var(--ac);border-color:var(--ac)}
.sw.on i{transform:translateX(20px);background:#fff}
.row.cl:hover .sw{border-color:var(--tx)}
.row.cl:hover .sw.on{border-color:var(--ac)}
.row.of,.rd.of{cursor:default}
.row.of:hover,.rd.of:hover{background:transparent}
.row.of .t,.row.of .d,.rd.of .t,.rd.of .d{color:var(--mu)}
.row.of .sw,.row.of:hover .sw{background:transparent;border-color:var(--bd)}
.row.of .sw i{background:var(--bd)}
.rd.of .c{border-color:var(--bd)}
.rd.of.on .c::after{background:var(--bd)}
.tf{width:100%;background:transparent;border:1px solid var(--bo);border-radius:4px;padding:11px 12px;color:var(--tx);font-size:15px;outline:none}
.tf::placeholder{color:var(--mu)}
.tf:focus{border-color:var(--ac)}
textarea.tf{min-height:96px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}
.bar{display:flex;gap:8px;padding:12px 16px;flex-wrap:wrap;align-items:center}
.it{display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--bd)}
.it:hover{background:var(--hv)}
.it .gr{flex:1;min-width:0;overflow:hidden}
.it .a{font-size:15px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.it .b{font-size:13px;color:var(--mu);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.em{padding:32px 24px;text-align:center;color:var(--mu);font-size:15px}
.rd{display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer}
.rd:hover{background:var(--hv)}
.rd .c{width:20px;height:20px;border-radius:9999px;border:2px solid var(--mu);flex:0 0 auto;position:relative}
.rd.on .c{border-color:var(--ac)}
.rd.on .c::after{content:"";position:absolute;inset:3px;border-radius:9999px;background:var(--ac)}
.fab{position:fixed;left:12px;bottom:12px;z-index:2147483645;height:32px;padding:0 14px;border-radius:9999px;border:1px solid var(--bo);background:var(--bg);color:var(--tx);font-size:13px;font-weight:700;cursor:pointer;font-family:var(--f)}
.fab:hover{background:var(--hv)}
.fab.off{color:#f4212e;border-color:rgba(244,33,46,.6)}
.warn{display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--bd);background:rgba(244,33,46,.1)}
.warn .t{font-size:15px;font-weight:700;color:#f4212e}
.warn .d{font-size:13px;color:var(--mu);margin-top:2px}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
@media (prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}.sp{animation:spin 1.2s linear infinite!important}}
`;
const vars = (p) =>
`--bg:${p.bg};--tx:${p.text};--mu:${p.muted};--bd:${p.border};--bo:${p.btnBorder};--hv:${p.hover};--ac:${p.accent};--bt:${p.btnText};--sh:${p.shadow};--f:${p.font};`;
let dash = null;
function h(tag, attrs, ...kids) {
const e = typeof tag === "string" ? document.createElement(tag) : tag;
if (attrs)
for (const k in attrs) {
const v = attrs[k];
if (k === "cls") e.className = v;
else if (k.startsWith("on")) e[k] = v;
else if (v !== null && v !== undefined && v !== false)
e.setAttribute(k, v === true ? "" : v);
}
for (const kid of kids.flat()) {
if (kid === null || kid === undefined || kid === false) continue;
e.appendChild(
typeof kid === "object" ? kid : document.createTextNode(String(kid)),
);
}
return e;
}
const announce = (msg) => {
if (dash) dash.live.textContent = msg;
};
async function busy(btn, label, fn) {
const prev = btn.textContent;
const tx = h("span", null, label);
btn.textContent = "";
btn.append(h("span", { cls: "sp" }), tx);
btn.disabled = true;
btn.setAttribute("aria-busy", "true");
if (btn.parentElement) btn.parentElement.classList.add("busy");
announce(label);
const progress = (done, total) => {
const s = total > 1 ? `${label} ${done}/${total}` : label;
tx.textContent = s;
announce(s);
};
try {
return await fn(progress);
} finally {
if (btn.parentElement) btn.parentElement.classList.remove("busy");
btn.removeAttribute("aria-busy");
btn.disabled = false;
btn.textContent = prev;
}
}
const fmtTime = (t) => {
if (!t) return "未取得";
const d = new Date(t),
p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}/${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
};
function openDash() {
if (dash) {
dash.host.style.display = "";
applyTheme();
renderTab();
focusDash();
return;
}
const host = h("div");
host.style.cssText = "all:initial";
const sr = host.attachShadow({ mode: "open" });
const style = h("style");
style.textContent = CSS;
sr.appendChild(style);
const body = h("div", { cls: "bd", role: "tabpanel", tabindex: "0" });
const tabsEl = h("div", { cls: "tabs", role: "tablist" });
const live = h("div", {
cls: "sr",
role: "status",
"aria-live": "polite",
});
const win = h(
"div",
{
cls: "win",
role: "dialog",
"aria-modal": "true",
"aria-label": "xgomi ダッシュボード",
},
live,
h(
"div",
{ cls: "hd" },
h(
"button",
{ cls: "ic", title: "閉じる", onclick: closeDash },
"\u2715",
),
h("div", { cls: "ti" }, "xgomi"),
h(
"button",
{
cls: "btn p",
id: "toggle",
onclick: () => {
settings.enabled = !settings.enabled;
saveSettings();
rebuild();
scheduleScan();
renderTab();
},
},
"",
),
),
tabsEl,
body,
);
const ov = h(
"div",
{
cls: "ov",
onclick: (e) => {
if (e.target === ov) closeDash();
},
},
win,
);
sr.appendChild(ov);
(document.body || document.documentElement).appendChild(host);
dash = { host, sr, ov, body, tabsEl, live, tab: "overview" };
applyTheme();
renderTabs();
renderTab();
focusDash();
document.addEventListener("keydown", escClose, true);
}
function focusDash() {
const t = dash && dash.sr.querySelector(".tab.on");
if (t) t.focus({ preventScroll: true });
}
function applyTheme() {
const p = palette();
if (dash) dash.ov.setAttribute("style", vars(p));
if (fab) fab.el.setAttribute("style", vars(p));
}
const escClose = (e) => {
if (e.key === "Escape" && dash && dash.host.style.display !== "none")
closeDash();
};
const closeDash = () => {
if (!dash || dash.host.style.display === "none") return;
dash.host.style.display = "none";
if (fab) fab.el.focus({ preventScroll: true });
};
const TABS = [
["overview", "概要"],
["list", "リスト"],
["sources", "ソース"],
["ids", "ID"],
["settings", "設定"],
["log", "ログ"],
];
const visibleTabs = () =>
TABS.filter(([id]) => id !== "ids" || needsResolveUI());
function renderTabs() {
dash.tabsEl.textContent = "";
const tabs = visibleTabs();
dash.tabsKey = tabs.map(([id]) => id).join();
if (!tabs.some(([id]) => id === dash.tab)) dash.tab = "overview";
for (const [id, label] of tabs) {
dash.tabsEl.appendChild(
h(
"button",
{
cls: "tab" + (dash.tab === id ? " on" : ""),
role: "tab",
"aria-selected": dash.tab === id ? "true" : "false",
onclick: () => {
dash.tab = id;
renderTabs();
renderTab();
},
onkeydown: (e) => {
const i = tabs.findIndex(([x]) => x === dash.tab);
const d =
e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!d) return;
e.preventDefault();
dash.tab = tabs[(i + d + tabs.length) % tabs.length][0];
renderTabs();
renderTab();
focusDash();
},
},
label,
),
);
}
}
let renderLock = 0;
const updateDash = () => {
if (!renderLock && dash && dash.host.style.display !== "none") renderTab();
};
function updateToggleBtn() {
const t = dash && dash.sr.querySelector("#toggle");
if (t) {
t.textContent = settings.enabled ? "有効" : "停止中";
t.className = "btn " + (settings.enabled ? "p" : "d");
}
updateFab();
}
function renderTab() {
if (!dash) return;
updateToggleBtn();
if (
visibleTabs()
.map(([id]) => id)
.join() !== dash.tabsKey
)
renderTabs();
dash.body.textContent = "";
({
overview: tabOverview,
list: tabList,
sources: tabSources,
ids: tabIds,
settings: tabSettings,
log: tabLog,
})[dash.tab](dash.body);
}
const stat = (n, l) =>
h(
"div",
{ cls: "st" },
h("div", { cls: "n" }, String(n)),
h("div", { cls: "l" }, l),
);
function switchRow(key, title, desc, onChange, dep) {
const off = dep && !settings.enabled;
const sw = h(
"div",
{ cls: "sw" + (settings[key] && !off ? " on" : "") },
h("i"),
);
const flip = () => {
if (off) {
toast("「有効」がオフのため変更できません", "有効にする", () => {
settings.enabled = true;
saveSettings();
renderTab();
updateToggleBtn();
renderLock++;
try {
rebuild();
scheduleScan();
} finally {
renderLock--;
}
});
return;
}
settings[key] = !settings[key];
saveSettings();
sw.className = "sw" + (settings[key] ? " on" : "");
row.setAttribute("aria-checked", settings[key] ? "true" : "false");
renderLock++;
try {
rebuild();
scheduleScan();
} finally {
renderLock--;
}
updateToggleBtn();
announce(title + (settings[key] ? " オン" : " オフ"));
if (onChange) onChange();
};
const row = h(
"div",
{
cls: "row cl" + (off ? " of" : ""),
role: "switch",
tabindex: "0",
"aria-checked": settings[key] ? "true" : "false",
"aria-disabled": off ? "true" : null,
onclick: flip,
onkeydown: (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
flip();
}
},
},
h(
"div",
{ cls: "gr" },
h("div", { cls: "t" }, title),
desc ? h("div", { cls: "d" }, desc) : null,
),
sw,
);
return row;
}
function tabOverview(b) {
if (!settings.enabled) {
b.appendChild(
h(
"div",
{ cls: "warn" },
h(
"div",
{ cls: "gr" },
h("div", { cls: "t" }, "停止中"),
h(
"div",
{ cls: "d" },
"除去処理はすべて止まっています。ブロック対象も普通に表示されます。",
),
),
h(
"button",
{
cls: "btn p",
onclick: () => {
settings.enabled = true;
saveSettings();
toggleFab();
rebuild();
scheduleScan();
renderTab();
},
},
"有効にする",
),
),
);
}
const lastAt = Math.max(
0,
...sources.map((s) => (cache[s.url] || {}).at || 0),
);
b.appendChild(
h(
"div",
{ cls: "grid" },
stat(blockNames.size, "username"),
stat(blockIds.size, "user id"),
needsResolveUI() ? stat(unresolvedIds().length, "未解決 id") : null,
stat(stats.api, "API段階で除去"),
stat(stats.tweets, "削除した投稿"),
stat(stats.users, "削除したユーザー"),
),
);
b.appendChild(
h(
"div",
{ cls: "bar" },
h(
"button",
{
cls: "btn a",
onclick: async (e) => {
const r = await busy(e.currentTarget, "更新中", (p) =>
refreshAll(true, p),
);
renderTab();
toast(`更新: 成功 ${r.ok} / 失敗 ${r.ng}`);
},
},
"リスト更新",
),
needsResolveUI()
? h(
"button",
{
cls: "btn o",
onclick: async (e) => {
const n = await busy(e.currentTarget, "解決中", (p) =>
resolveIds(50, p, true),
);
renderTab();
toast(
n
? `${n} 件解決しました`
: "解決できる id はありませんでした",
);
},
},
"ID解決",
)
: null,
h(
"button",
{
cls: "btn o",
onclick: () => {
const before = stats.tweets + stats.users;
sweep(document);
renderTab();
const n = stats.tweets + stats.users - before;
toast(n ? `${n} 件除去しました` : "このページに対象はありません");
},
},
"再スキャン",
),
),
);
b.appendChild(
h(
"div",
{ cls: "row" },
h(
"div",
{ cls: "gr" },
h("div", { cls: "t" }, "最終取得"),
h("div", { cls: "d" }, fmtTime(lastAt)),
),
),
);
}
const ISSUE_BASE = "https://github.com/mikumiku-jp/xgomi/issues/new";
const ROW_CAP = 200;
function reportBody(items) {
const lines = [
"xgomi への追加リクエストです。",
"",
"| username | 投稿 |",
"| --- | --- |",
];
for (const r of items)
lines.push("| @" + r.name + " | " + (r.url || "-") + " |");
return lines.join("\n");
}
function issueUrl(items) {
const title = "追加リクエスト: " + items.length + " 件";
return (
ISSUE_BASE +
"?title=" +
encodeURIComponent(title) +
"&body=" +
encodeURIComponent(reportBody(items))
);
}
function listRows(b, arr, label, onDelete) {
const shown = arr.slice(0, ROW_CAP);
shown.forEach((raw, i) => {
const e = parseLine(raw) || { type: "?", v: raw };
b.appendChild(
h(
"div",
{ cls: "it" },
h(
"div",
{ cls: "gr" },
h(
"div",
{ cls: "a mono" },
e.type === "id"
? nameOf(e.v)
? "@" + nameOf(e.v)
: e.v
: "@" + e.v,
),
h("div", { cls: "b mono" }, e.type === "id" ? "id: " + e.v : label),
),
h("button", { cls: "btn d sm", onclick: () => onDelete(i) }, "削除"),
),
);
});
if (arr.length > shown.length)
b.appendChild(
h("div", { cls: "em" }, `他 ${arr.length - shown.length} 件は非表示`),
);
}
function tabList(b) {
const ta = h("textarea", {
cls: "tf",
placeholder:
"@username / username / 1234567890 / id:123 / username:foo / URL\n1行1件",
});
b.appendChild(
h(
"div",
{ cls: "sec" },
h("div", { cls: "bar" }, ta),
h(
"div",
{ cls: "bar" },
h(
"button",
{
cls: "btn a",
onclick: () => {
const add = parseText(ta.value);
if (!add.length) return toast("追加できる行がありません");
let n = 0;
for (const e of add) {
const v = (e.type === "id" ? "id:" : "username:") + e.v;
if (!local.includes(v)) {
local.push(v);
n++;
}
}
saveLocal();
ta.value = "";
rebuild();
renderTab();
toast(n + " 件追加しました");
},
},
"追加",
),
h(
"div",
{ cls: "mu", style: "font-size:13px" },
`手動 ${local.length} 件 / 全体 ${blockIds.size + blockNames.size} 件`,
),
),
),
);
b.appendChild(
h(
"div",
{ cls: "sec" },
h("div", { cls: "sh" }, `報告リスト (${report.length})`),
h(
"div",
{ cls: "row" },
h(
"div",
{ cls: "gr" },
h(
"div",
{ cls: "d" },
"投稿の追加ボタンで入れたアカウントを、xgomiにissueとしてまとめて提出します",
),
),
),
h(
"div",
{ cls: "bar" },
h(
"button",
{
cls: "btn a",
disabled: !report.length,
onclick: () => {
const url = issueUrl(report);
if (url.length > 7500)
toast("件数が多いのでコピーして貼り付けてください");
W.open(url, "_blank", "noopener");
},
},
"issueを作成",
),
h(
"button",
{
cls: "btn o",
disabled: !report.length,
onclick: () => {
navigator.clipboard.writeText(reportBody(report)).then(
() => toast("コピーしました"),
() => toast("コピーできませんでした"),
);
},
},
"本文をコピー",
),
h(
"button",
{
cls: "btn o",
disabled: !report.length,
onclick: () => {
report = [];
saveReport();
renderTab();
},
},
"報告リストを空に",
),
),
),
);
report.slice(0, ROW_CAP).forEach((r, i) => {
b.appendChild(
h(
"div",
{ cls: "it" },
h(
"div",
{ cls: "gr" },
h("div", { cls: "a mono" }, "@" + r.name),
h("div", { cls: "b mono" }, r.url || "投稿URLなし"),
),
h(
"button",
{
cls: "btn o sm",
onclick: () => {
report.splice(i, 1);
saveReport();
renderTab();
},
},
"外す",
),
),
);
});
const ex = h("textarea", {
cls: "tf",
placeholder: "例外にする @username / id(1行1件)",
});
b.appendChild(
h(
"div",
{ cls: "sec" },
h("div", { cls: "sh" }, `例外リスト (${except.length})`),
h(
"div",
{ cls: "row" },
h(
"div",
{ cls: "gr" },
h(
"div",
{ cls: "d" },
"リストに入っていても、ここにあるアカウントは消しません",
),
),
),
h("div", { cls: "bar" }, ex),
h(
"div",
{ cls: "bar" },
h(
"button",
{
cls: "btn a",
onclick: () => {
const add = parseText(ex.value);
if (!add.length) return toast("追加できる行がありません");
let n = 0;
for (const e of add) {
const v = (e.type === "id" ? "id:" : "username:") + e.v;
if (!except.includes(v)) {
except.push(v);
n++;
}
}
saveExcept();
ex.value = "";
rebuild();
renderTab();
toast(n + " 件を例外にしました(表示に戻すには再読み込み)");
},
},
"例外に追加",
),
),
),
);
listRows(b, except, "例外", (i) => {
except.splice(i, 1);
saveExcept();
rebuild();
renderTab();
});
if (!local.length) {
b.appendChild(h("div", { cls: "em" }, "手動追加はまだありません"));
return;
}
b.appendChild(h("div", { cls: "sh" }, `手動リスト (${local.length})`));
listRows(b, local, "username", (i) => {
local.splice(i, 1);
saveLocal();
rebuild();
renderTab();
});
}
function tabSources(b) {
const inp = h("input", {
cls: "tf",
type: "text",
placeholder: "https://example.com/list.txt",
});
b.appendChild(
h(
"div",
{ cls: "sec" },
h("div", { cls: "bar" }, inp),
h(
"div",
{ cls: "bar" },
h(
"button",
{
cls: "btn a",
onclick: async (e) => {
const url = inp.value.trim();
if (!/^https?:\/\//.test(url))
return toast("URLを入力してください");
if (sources.some((s) => s.url === url))
return toast("登録済みです");
const src = { url, enabled: true };
sources.push(src);
saveSources();
let got = false;
await busy(e.currentTarget, "取得中", async () => {
try {
const n = await refreshSource(src);
got = true;
toast(
n +
" 件読み込みました" +
(src.pairs ? "(対応表 " + src.pairs + " 件)" : ""),
);
} catch (err) {
src.error = String(err.message || err);
toast("取得に失敗しました");
}
});
inp.value = "";
rebuild();
renderTab();
if (got) askResolve(src, renderTab);
},
},
"追加",
),
h(
"div",
{ cls: "mu", style: "font-size:13px" },
"数字=id / 英数字=username として自動判別",
),
),
),
);
sources.forEach((s, i) => {
const c = cache[s.url] || {};
const sw = h("div", { cls: "sw" + (s.enabled ? " on" : "") }, h("i"));
b.appendChild(
h(
"div",
{ cls: "it" },
h(
"div",
{
cls: "gr",
style: "cursor:pointer",
onclick: () => {
s.enabled = !s.enabled;
saveSources();
sw.className = "sw" + (s.enabled ? " on" : "");
renderLock++;
try {
rebuild();
} finally {
renderLock--;
}
},
},
h(
"div",
{ cls: "a" },
s.builtin ? "デフォルトリスト" : s.url.split("/").pop() || s.url,
),
h("div", { cls: "b mono" }, s.url),
h(
"div",
{ cls: "b" },
(c.count == null ? "-" : c.count + " 件") +
" / " +
fmtTime(c.at) +
(s.error ? " / " + s.error : ""),
),
h(
"div",
{ cls: "b" },
c.text
? s.bare
? "対応表なし " +
s.bare +
" 件 / 逆引き " +
(s.resolve ? "ON" : "OFF")
: "対応表つき(逆引き不要)"
: "",
),
),
sw,
s.bare
? h(
"button",
{
cls: "btn o sm",
onclick: () => {
s.resolve = !s.resolve;
saveSources();
rebuild();
renderTab();
if (s.resolve)
resolveIds(50).then((n) => {
if (n) toast(n + " 件解決しました");
if (dash) renderTab();
});
},
},
s.resolve ? "逆引き停止" : "逆引き",
)
: null,
h(
"button",
{
cls: "btn o sm",
onclick: async (e) => {
await busy(e.currentTarget, "取得中", async () => {
try {
const n = await refreshSource(s);
s.error = null;
toast(n + " 件読み込みました");
} catch (err) {
s.error = String(err.message || err);
toast("取得に失敗しました");
}
});
saveSources();
rebuild();
renderTab();
askResolve(s, renderTab);
},
},
"更新",
),
s.builtin
? h(
"button",
{
cls: "btn d sm",
disabled: true,
title:
"デフォルトリストは削除できません(スイッチでOFFにできます)",
},
"除去",
)
: h(
"button",
{
cls: "btn d sm",
onclick: () => {
delete cache[s.url];
saveCache();
sources.splice(i, 1);
saveSources();
rebuild();
renderTab();
},
},
"除去",
),
),
);
});
}
function tabIds(b) {
const un = unresolvedIds();
b.appendChild(
h(
"div",
{ cls: "sec" },
h(
"div",
{ cls: "bar" },
h(
"button",
{
cls: "btn a",
onclick: async (e) => {
const n = await busy(e.currentTarget, "解決中", (p) =>
resolveIds(50, p, true),
);
renderTab();
toast(
n
? n + " 件解決しました"
: "解決できる id はありませんでした",
);
},
},
"未解決を逆引き",
),
h(
"button",
{
cls: "btn o",
onclick: () => {
idMap = {};
saveMap();
rebuild();
renderTab();
},
},
"対応表を消去",
),
h(
"div",
{ cls: "mu", style: "font-size:13px" },
`未解決 ${un.length} 件 / ソース配布 ${Object.keys(feedMap).length} 件`,
),
),
),
);
const keys = [...blockIds].filter((k) => nameOf(k)).slice(0, ROW_CAP);
const others = Object.keys(idMap)
.filter((k) => idMap[k] && !blockIds.has(k))
.slice(0, ROW_CAP);
if (!keys.length && !un.length)
b.appendChild(h("div", { cls: "em" }, "idのブロック対象はありません"));
for (const k of keys) {
b.appendChild(
h(
"div",
{ cls: "it" },
h(
"div",
{ cls: "gr" },
h("div", { cls: "a" }, "@" + nameOf(k)),
h("div", { cls: "b mono" }, k),
),
h(
"div",
{ cls: "mu", style: "font-size:13px" },
idMap[k] ? "逆引き" : "ソース配布",
),
),
);
}
for (const k of un.slice(0, ROW_CAP)) {
b.appendChild(
h(
"div",
{ cls: "it" },
h(
"div",
{ cls: "gr" },
h("div", { cls: "a mono" }, k),
h("div", { cls: "b" }, "username 未解決(idでの除去は動作します)"),
),
),
);
}
if (others.length) {
b.appendChild(
h(
"div",
{ cls: "row" },
h(
"div",
{ cls: "gr" },
h("div", { cls: "d" }, `収集済みの対応表: 他 ${others.length} 件`),
),
),
);
}
}
function tabSettings(b) {
b.appendChild(
h(
"div",
{ cls: "sec" },
switchRow("enabled", "有効", "すべての除去処理のオン/オフ", () =>
renderTab(),
),
switchRow(
"pruneNetwork",
"API段階で除去",
"描画される前にレスポンスから削除する(推奨)",
null,
true,
),
switchRow(
"blockMentions",
"メンションも対象",
"対象を@で言及しただけの他人の投稿も消す",
null,
true,
),
switchRow(
"blockProfile",
"プロフィールを非表示",
"対象のプロフィールページに案内を出す",
null,
true,
),
switchRow(
"autoResolve",
"idを自動で逆引き",
"逆引きを許可したソースの id だけ x.com に問い合わせる",
null,
true,
),
switchRow(
"showButton",
"ボタンを表示",
"画面左下のxgomiボタン",
toggleFab,
),
switchRow(
"addButton",
"投稿のメニューに追加",
"投稿の「…」メニューからリストに追加する",
() => scheduleScan(),
true,
),
),
);
const pick = (v, t) => {
if (!settings.enabled) {
toast("「有効」がオフのため変更できません", "有効にする", () => {
settings.enabled = true;
saveSettings();
renderTab();
updateToggleBtn();
renderLock++;
try {
rebuild();
scheduleScan();
} finally {
renderLock--;
}
});
return;
}
settings.removeMode = v;
saveSettings();
renderTab();
announce(t);
};
b.appendChild(
h(
"div",
{ cls: "sec" },
h("div", { cls: "sh" }, "削除方法"),
...[
["remove", "DOMから削除", "完全に取り除く"],
["hide", "非表示にする", "display:none で隠す"],
].map(([v, t, d]) =>
h(
"div",
{
cls:
"rd" +
(settings.removeMode === v ? " on" : "") +
(settings.enabled ? "" : " of"),
role: "radio",
tabindex: "0",
"aria-checked": settings.removeMode === v ? "true" : "false",
"aria-disabled": settings.enabled ? null : "true",
onclick: () => pick(v, t),
onkeydown: (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
pick(v, t);
},
},
h("div", { cls: "c" }),
h(
"div",
{ cls: "gr" },
h("div", { cls: "t" }, t),
h("div", { cls: "d" }, d),
),
),
),
),
);
const nInp = h("input", {
cls: "tf",
type: "number",
min: "0",
max: "168",
value: String(settings.autoUpdateHours),
style: "width:90px",
onchange: (e) => {
settings.autoUpdateHours = Math.max(0, Number(e.target.value) || 0);
saveSettings();
},
});
const lInp = h("input", {
cls: "tf",
type: "number",
min: "10",
max: "2000",
value: String(settings.logLimit),
style: "width:90px",
onchange: (e) => {
settings.logLimit = Math.min(
2000,
Math.max(10, Number(e.target.value) || 200),
);
e.target.value = String(settings.logLimit);
saveSettings();
if (logs.length > settings.logLimit) {
logs.length = settings.logLimit;
set(K.log, logs);
}
},
});
b.appendChild(
h(
"div",
{ cls: "sec" },
h("div", { cls: "sh" }, "自動更新"),
h(
"div",
{ cls: "row" },
h(
"div",
{ cls: "gr" },
h("div", { cls: "t" }, "更新間隔(時間)"),
h("div", { cls: "d" }, "0で自動更新しない"),
),
nInp,
),
h(
"div",
{ cls: "row" },
h(
"div",
{ cls: "gr" },
h("div", { cls: "t" }, "ログ保持件数"),
h("div", { cls: "d" }, "古いものから自動で破棄されます"),
),
lInp,
),
),
);
b.appendChild(
h(
"div",
{ cls: "sec" },
h("div", { cls: "sh" }, "データ"),
h(
"div",
{ cls: "bar" },
h(
"button",
{
cls: "btn o",
onclick: () => {
const data = JSON.stringify(
{ sources, local, settings, map: idMap },
null,
2,
);
navigator.clipboard.writeText(data).then(
() => toast("コピーしました"),
() => toast("コピーできませんでした"),
);
},
},
"エクスポート",
),
h(
"button",
{
cls: "btn o",
onclick: () => {
const s = prompt("エクスポートしたJSONを貼り付け");
if (!s) return;
try {
const d = JSON.parse(s);
if (d.sources) {
sources = d.sources;
saveSources();
}
if (d.local) {
local = d.local;
saveLocal();
}
if (d.settings) {
settings = Object.assign({}, DEFAULT_SETTINGS, d.settings);
saveSettings();
}
if (d.map) {
idMap = d.map;
saveMap();
}
rebuild();
renderTab();
toast("読み込みました");
} catch (e) {
toast("JSONが不正です");
}
},
},
"インポート",
),
h(
"button",
{
cls: "btn d",
onclick: () => {
stats = { tweets: 0, users: 0, api: 0 };
set(K.stats, stats);
logs = [];
set(K.log, logs);
renderTab();
toast("リセットしました");
},
},
"統計をリセット",
),
),
),
);
}
function tabLog(b) {
b.appendChild(
h(
"div",
{ cls: "sec" },
h(
"div",
{ cls: "bar" },
h(
"button",
{
cls: "btn o",
onclick: () => {
logs = [];
set(K.log, logs);
renderTab();
},
},
"クリア",
),
h(
"div",
{ cls: "mu", style: "font-size:13px" },
`直近 ${logs.length} 件 / 最大 ${settings.logLimit} 件まで保持`,
),
),
),
);
if (!logs.length) {
b.appendChild(h("div", { cls: "em" }, "まだ何も削除していません"));
return;
}
const kindLabel = { api: "API", tweet: "投稿", user: "ユーザー" };
for (const l of logs) {
b.appendChild(
h(
"div",
{ cls: "it" },
h(
"div",
{ cls: "gr" },
h("div", { cls: "a mono" }, l.who),
h(
"div",
{ cls: "b" },
(kindLabel[l.kind] || l.kind) + " / " + l.where,
),
),
h(
"div",
{ cls: "mu", style: "font-size:13px" },
new Date(l.t).toLocaleTimeString(),
),
),
);
}
}
let toastEl = null;
let confirmUI = null;
function confirmDialog(opt, onOk, onCancel) {
const p = palette();
if (!confirmUI) {
const host = h("div");
host.style.cssText = "all:initial";
const sr = host.attachShadow({ mode: "open" });
const st = h("style");
st.textContent = `:host{all:initial}
*{box-sizing:border-box;margin:0;padding:0;font-family:var(--f)}
.cv{position:fixed;inset:0;background:rgba(91,112,131,.4);z-index:2147483647;display:flex;align-items:center;justify-content:center;animation:cfade .1s ease}
.cc{width:min(320px,92vw);background:var(--bg);color:var(--tx);border-radius:16px;padding:32px;box-shadow:var(--sh);animation:cpop .12s cubic-bezier(.2,.8,.2,1)}
.ch{font-size:20px;font-weight:800;line-height:24px;margin-bottom:8px}
.cp{font-size:15px;line-height:20px;color:var(--mu);margin-bottom:24px}
.cb{display:block;width:100%;height:44px;border-radius:9999px;font-size:15px;font-weight:700;cursor:pointer;border:1px solid transparent;transition:background .2s ease,filter .2s ease}
.cb:focus-visible{outline:2px solid var(--ac);outline-offset:2px}
.cb.ok{background:var(--ac);color:#fff;margin-bottom:12px}
.cb.ok:hover{filter:brightness(.88)}
.cb.ok.dg{background:#f4212e}
.cb.no{background:transparent;color:var(--tx);border-color:var(--bo)}
.cb.no:hover{background:var(--hv)}
@keyframes cfade{from{opacity:0}to{opacity:1}}
@keyframes cpop{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){.cv,.cc{animation:none}.cb{transition:none}}`;
sr.appendChild(st);
(document.body || document.documentElement).appendChild(host);
confirmUI = { host, sr };
}
const prev = document.activeElement;
const close = (confirmed) => {
if (confirmUI.ov) confirmUI.ov.remove();
confirmUI.ov = null;
document.removeEventListener("keydown", onKey, true);
if (prev && prev.focus) prev.focus({ preventScroll: true });
if (!confirmed && onCancel) onCancel();
};
const onKey = (e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
close();
} else if (e.key === "Tab") {
e.preventDefault();
const btns = [ok, no];
btns[btns.indexOf(confirmUI.sr.activeElement) === 0 ? 1 : 0].focus();
}
};
const ok = h(
"button",
{
cls: "cb ok" + (opt.danger ? " dg" : ""),
onclick: () => {
close(true);
onOk();
},
},
opt.ok,
);
const no = h(
"button",
{ cls: "cb no", onclick: () => close() },
opt.no || "キャンセル",
);
const ov = h(
"div",
{
cls: "cv",
onclick: (e) => {
if (e.target === ov) close();
},
role: "presentation",
},
h(
"div",
{
cls: "cc",
role: "dialog",
"aria-modal": "true",
"aria-label": opt.title,
},
h("div", { cls: "ch" }, opt.title),
h("div", { cls: "cp" }, opt.desc),
ok,
no,
),
);
ov.setAttribute("style", vars(p));
if (confirmUI.ov) confirmUI.ov.remove();
confirmUI.ov = ov;
confirmUI.sr.appendChild(ov);
document.addEventListener("keydown", onKey, true);
ok.focus({ preventScroll: true });
}
function toast(msg, actionLabel, onAction) {
const p = palette();
if (!toastEl) {
const host = h("div");
const sr = host.attachShadow({ mode: "open" });
const st = h("style");
st.textContent = `.t{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--ac);color:#fff;font-family:var(--f);font-size:14px;font-weight:500;padding:12px 16px;border-radius:4px;z-index:2147483647;max-width:90vw;display:flex;align-items:center;gap:16px;animation:tin .16s ease}
.t button{background:transparent;border:0;color:#fff;font:inherit;font-weight:700;text-decoration:underline;cursor:pointer;padding:0}
@keyframes tin{from{opacity:0;transform:translate(-50%,8px)}to{opacity:1;transform:translate(-50%,0)}}
@media (prefers-reduced-motion:reduce){.t{animation:none}}`;
sr.appendChild(st);
const el = h("div", { cls: "t", role: "status", "aria-live": "polite" });
sr.appendChild(el);
(document.body || document.documentElement).appendChild(host);
toastEl = { host, el };
}
toastEl.el.setAttribute("style", vars(p));
toastEl.el.textContent = msg;
if (actionLabel && onAction)
toastEl.el.appendChild(
h(
"button",
{
onclick: () => {
toastEl.host.style.display = "none";
onAction();
},
},
actionLabel,
),
);
toastEl.host.style.display = "";
toastEl.el.classList.remove("t");
void toastEl.el.offsetWidth;
toastEl.el.classList.add("t");
clearTimeout(toastEl.tm);
toastEl.tm = setTimeout(
() => {
toastEl.host.style.display = "none";
},
actionLabel ? 6000 : 2600,
);
}
let noticeFor = "";
function blockProfilePage() {
const primary = document.querySelector(
'main [data-testid="primaryColumn"]',
);
const m = /^\/([A-Za-z0-9_]{1,15})(?:\/.*)?$/.exec(location.pathname);
const mid = /^\/i\/user\/(\d+)/.exec(location.pathname);
let who = null,
key = "";
if (settings.enabled && settings.blockProfile) {
if (
m &&
!RESERVED.has(m[1].toLowerCase()) &&
blockNames.has(m[1].toLowerCase())
) {
who = "@" + m[1];
key = m[1].toLowerCase();
} else if (mid && blockIds.has(mid[1])) {
who = mid[1];
key = mid[1];
}
}
if (!who) {
if (noticeFor) {
noticeFor = "";
const old = document.getElementById("xgomi-notice");
if (old) old.remove();
document.querySelectorAll("[data-xgomi-hidden]").forEach((el) => {
el.style.removeProperty("display");
el.removeAttribute("data-xgomi-hidden");
});
}
return;
}
if (!primary) return;
for (const el of primary.children) {
if (el.id === "xgomi-notice") continue;
if (!el.hasAttribute("data-xgomi-hidden")) {
el.setAttribute("data-xgomi-hidden", "1");
el.style.setProperty("display", "none", "important");
}
}
if (noticeFor === key && document.getElementById("xgomi-notice")) return;
noticeFor = key;
const old = document.getElementById("xgomi-notice");
if (old) old.remove();
const p = palette();
const box = h("div", { id: "xgomi-notice" });
const sr = box.attachShadow({ mode: "open" });
const st = h("style");
st.textContent =
CSS +
`
.wrap{padding:48px 32px;max-width:460px;margin:0 auto;text-align:center;font-family:var(--f);color:var(--tx)}
.wrap h2{font-size:20px;font-weight:800;line-height:1.3}
.wrap p{margin-top:8px;font-size:15px;color:var(--mu);line-height:1.5;word-break:break-all}
.wrap .btns{margin-top:24px;display:flex;gap:8px;justify-content:center}
`;
sr.appendChild(st);
sr.appendChild(
h(
"div",
{ cls: "wrap", style: vars(p) },
h("h2", null, "このアカウントは非表示です"),
h(
"p",
null,
who +
" はブロックリストに含まれているため、投稿とプロフィールを表示していません。",
),
h(
"div",
{ cls: "btns" },
h(
"button",
{
cls: "btn o",
onclick: () => {
bypass.add(key);
rebuild();
},
},
"表示する",
),
h("button", { cls: "btn p", onclick: openDash }, "xgomi"),
),
),
);
primary.appendChild(box);
}
let fab = null;
function updateFab() {
if (!fab) return;
fab.el.className = "fab" + (settings.enabled ? "" : " off");
fab.el.textContent = settings.enabled ? "xgomi" : "xgomi ・ 停止中";
fab.el.setAttribute(
"aria-label",
"xgomi ダッシュボードを開く" + (settings.enabled ? "" : "(停止中)"),
);
}
function toggleFab() {
if (!settings.showButton) {
if (fab) fab.host.style.display = "none";
return;
}
if (fab) {
fab.host.style.display = "";
updateFab();
applyTheme();
return;
}
const host = h("div");
const sr = host.attachShadow({ mode: "open" });
const st = h("style");
st.textContent = CSS;
sr.appendChild(st);
const el = h("button", { cls: "fab", onclick: openDash }, "xgomi");
sr.appendChild(el);
(document.body || document.documentElement).appendChild(host);
fab = { host, el };
updateFab();
applyTheme();
}
try {
GM_registerMenuCommand("xgomi ダッシュボード", openDash);
} catch (e) {}
function ready(fn) {
if (document.body) fn();
else
new MutationObserver((_r, o) => {
if (document.body) {
o.disconnect();
fn();
}
}).observe(document.documentElement, { childList: true, subtree: true });
}
rebuild();
ready(() => {
toggleFab();
watchCaret();
startObserver();
scheduleScan();
let last = location.href;
setInterval(() => {
if (location.href !== last) {
last = location.href;
noticeFor = "";
if (bypass.size) {
bypass.clear();
rebuild();
}
scheduleScan();
}
}, 400);
refreshAll(false);
if (settings.autoResolve) setTimeout(() => resolveIds(20), 5000);
});
})();