// ==UserScript==
// @name Ultimate Twitter Block
// @namespace twitter-block-userscript
// @version 2.2.1
// @description Add one-click block/mute buttons to tweets, profiles, and search suggestions on Twitter/X
// @author nemut.ai
// @match https://x.com/*
// @match https://twitter.com/*
// @updateURL https://raw.githubusercontent.com/satomasahiro2005/ultimate-twitter-block/main/userscripts/twitter-block.user.js
// @downloadURL https://raw.githubusercontent.com/satomasahiro2005/ultimate-twitter-block/main/userscripts/twitter-block.user.js
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
if (window.__twblockInjected) return;
window.__twblockInjected = true;
const PROCESSED = 'data-twblock';
const RESERVED_PATHS = new Set([
'home', 'explore', 'search', 'notifications', 'messages',
'settings', 'i', 'compose', 'login', 'logout', 'signup',
'tos', 'privacy', 'about', 'help', 'jobs', 'download',
]);
const PROFILE_SUBPATHS = new Set([
'with_replies', 'media', 'likes', 'highlights', 'articles',
'followers', 'following', 'verified_followers',
]);
const ICON_CACHE_VERSION = 4;
const BLOCK_MENU_LABEL_RE = /\bBlock\b|ブロック|屏蔽/;
const UNBLOCK_MENU_LABEL_RE = /\bUnblock\b|ブロック解除|取消屏蔽/;
const MUTE_MENU_LABEL_RE = /\bMute\b|ミュート|隐藏/;
const UNMUTE_MENU_LABEL_RE = /\bUnmute\b|ミュート解除|取消隐藏/;
const CONVERSATION_MENU_LABEL_RE = /\bconversation\b|会話|对话|此对话/;
const SVG_NS = 'http://www.w3.org/2000/svg';
const ICON_DEBUG_STORAGE_KEY = 'twblock:debug-icons';
const MAX_ICON_DEBUG_HISTORY = 20;
const BLOCK_ICON_SIGNATURES = new Set(['498278e7']);
const MUTE_ICON_SIGNATURES = new Set(['d3853445']);
const ICON_SHAPE_ATTRS = {
path: ['d', 'transform', 'fill-rule', 'clip-rule', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit'],
circle: ['cx', 'cy', 'r', 'transform', 'stroke-width'],
ellipse: ['cx', 'cy', 'rx', 'ry', 'transform', 'stroke-width'],
rect: ['x', 'y', 'width', 'height', 'rx', 'ry', 'transform', 'stroke-width'],
line: ['x1', 'y1', 'x2', 'y2', 'transform', 'stroke-width', 'stroke-linecap'],
polyline: ['points', 'transform', 'stroke-width', 'stroke-linecap', 'stroke-linejoin'],
polygon: ['points', 'transform', 'stroke-width', 'stroke-linejoin', 'fill-rule', 'clip-rule'],
};
const ICON_GROUP_ATTRS = ['transform'];
const FALLBACK_BLOCK_ICON =
'';
const FALLBACK_MUTE_ICON =
'';
// ---- SVGアイコン(ストレージ or パッシブ取得で動的設定) ----
let BLOCK_ICON = '';
let MUTE_ICON = '';
let iconDebugEnabled = false;
const iconDebugHistory = [];
const CHECK_ICON =
'';
function escapeAttr(s) {
return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>');
}
function normalizeSpace(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function hashString(value) {
let hash = 2166136261;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
function getIconSignatureSet(action) {
return action === 'block' ? BLOCK_ICON_SIGNATURES : MUTE_ICON_SIGNATURES;
}
function rememberIconSignature(action, signature) {
if (!signature) return;
getIconSignatureSet(action).add(signature);
}
function loadStoredIconSignatures(signatures) {
if (!signatures || typeof signatures !== 'object') return;
['block', 'mute'].forEach((action) => {
const values = Array.isArray(signatures[action]) ? signatures[action] : [];
values.forEach((value) => {
if (typeof value === 'string' && value) rememberIconSignature(action, value);
});
});
}
function getStoredIconSignatures() {
return {
block: Array.from(BLOCK_ICON_SIGNATURES),
mute: Array.from(MUTE_ICON_SIGNATURES),
};
}
function persistIcons() {
chrome.storage.local.set({
icons: {
version: ICON_CACHE_VERSION,
block: BLOCK_ICON,
mute: MUTE_ICON,
signatures: getStoredIconSignatures(),
},
});
}
function getPaintState(node, attrName) {
const tag = node.tagName.toLowerCase();
const value = normalizeSpace(node.getAttribute(attrName));
if (attrName === 'fill') {
const strokeValue = normalizeSpace(node.getAttribute('stroke'));
const hasVisibleStroke = strokeValue && strokeValue !== 'none';
if (node.hasAttribute('fill')) return value === 'none' ? 'none' : 'paint';
return (tag === 'line' || hasVisibleStroke) ? 'none' : 'paint';
}
if (!node.hasAttribute('stroke')) return 'none';
return value === 'none' ? 'none' : 'paint';
}
function appendIconSignatureParts(node, parts) {
Array.from(node.children).forEach((child) => {
const tag = child.tagName.toLowerCase();
if (tag === 'g') {
const transform = normalizeSpace(child.getAttribute('transform'));
if (transform) parts.push('g:transform=' + transform);
appendIconSignatureParts(child, parts);
if (transform) parts.push('/g');
return;
}
const attrs = ICON_SHAPE_ATTRS[tag];
if (!attrs) return;
const attrParts = [];
attrs.forEach((attr) => {
const value = normalizeSpace(child.getAttribute(attr));
if (value) attrParts.push(attr + '=' + value);
});
attrParts.push('fill=' + getPaintState(child, 'fill'));
attrParts.push('stroke=' + getPaintState(child, 'stroke'));
parts.push(tag + ':' + attrParts.join(','));
});
}
function getIconSignature(svgEl) {
if (!svgEl) return '';
const parts = ['viewBox=' + (normalizeSpace(svgEl.getAttribute('viewBox')) || '0 0 24 24')];
appendIconSignatureParts(svgEl, parts);
return hashString(parts.join('|'));
}
function copySvgAttributes(source, target, attrs) {
attrs.forEach((attr) => {
const value = normalizeSpace(source.getAttribute(attr));
if (value) target.setAttribute(attr, value);
});
}
function applySanitizedPaint(source, target) {
const tag = source.tagName.toLowerCase();
const fillValue = normalizeSpace(source.getAttribute('fill'));
const strokeValue = normalizeSpace(source.getAttribute('stroke'));
const hasVisibleStroke = strokeValue && strokeValue !== 'none';
if (source.hasAttribute('fill')) {
target.setAttribute('fill', fillValue === 'none' ? 'none' : 'currentColor');
} else if (tag === 'line' || hasVisibleStroke) {
target.setAttribute('fill', 'none');
} else {
target.setAttribute('fill', 'currentColor');
}
if (source.hasAttribute('stroke')) {
target.setAttribute('stroke', strokeValue === 'none' ? 'none' : 'currentColor');
}
}
function sanitizeSvgNode(node) {
const tag = node.tagName.toLowerCase();
if (tag === 'g') {
const group = document.createElementNS(SVG_NS, 'g');
copySvgAttributes(node, group, ICON_GROUP_ATTRS);
Array.from(node.children).forEach((child) => {
const sanitizedChild = sanitizeSvgNode(child);
if (sanitizedChild) group.appendChild(sanitizedChild);
});
return (group.childNodes.length || group.attributes.length) ? group : null;
}
const attrs = ICON_SHAPE_ATTRS[tag];
if (!attrs) return null;
const sanitized = document.createElementNS(SVG_NS, tag);
copySvgAttributes(node, sanitized, attrs);
applySanitizedPaint(node, sanitized);
return sanitized;
}
function buildInlineIconSvg(svgEl) {
if (!svgEl) return '';
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('viewBox', normalizeSpace(svgEl.getAttribute('viewBox')) || '0 0 24 24');
svg.setAttribute('width', '20');
svg.setAttribute('height', '20');
svg.setAttribute('aria-hidden', 'true');
Array.from(svgEl.children).forEach((child) => {
const sanitizedChild = sanitizeSvgNode(child);
if (sanitizedChild) svg.appendChild(sanitizedChild);
});
return svg.childNodes.length ? svg.outerHTML : '';
}
function getMenuItemLabelMatch(text) {
if (BLOCK_MENU_LABEL_RE.test(text) && !UNBLOCK_MENU_LABEL_RE.test(text)) return 'block';
if (MUTE_MENU_LABEL_RE.test(text) &&
!UNMUTE_MENU_LABEL_RE.test(text) &&
!CONVERSATION_MENU_LABEL_RE.test(text)) return 'mute';
return '';
}
function describeMenuItem(item) {
const text = normalizeSpace(item.textContent || '');
const svgEl = item.querySelector('svg');
const signature = getIconSignature(svgEl);
let signatureMatch = '';
if (signature) {
if (BLOCK_ICON_SIGNATURES.has(signature)) signatureMatch = 'block';
else if (MUTE_ICON_SIGNATURES.has(signature)) signatureMatch = 'mute';
}
return {
text,
signature,
signatureMatch,
labelMatch: getMenuItemLabelMatch(text),
matchedBy: '',
iconMarkup: buildInlineIconSvg(svgEl),
};
}
function buildMenuIconSnapshot(menuItems, reason) {
return {
reason,
timestamp: new Date().toISOString(),
entries: Array.from(menuItems).map(describeMenuItem),
};
}
function loadIconDebugFlag() {
try {
iconDebugEnabled = window.localStorage.getItem(ICON_DEBUG_STORAGE_KEY) === '1';
} catch (err) {
iconDebugEnabled = false;
}
}
function setIconDebugEnabled(enabled) {
iconDebugEnabled = Boolean(enabled);
try {
if (iconDebugEnabled) window.localStorage.setItem(ICON_DEBUG_STORAGE_KEY, '1');
else window.localStorage.removeItem(ICON_DEBUG_STORAGE_KEY);
} catch (err) {
// Ignore storage access errors.
}
console.info('[twblock] icon debug ' + (iconDebugEnabled ? 'enabled' : 'disabled'));
}
function logIconDebugSnapshot(snapshot) {
const rows = snapshot.entries.map((entry) => ({
text: entry.text,
signature: entry.signature,
signatureMatch: entry.signatureMatch,
labelMatch: entry.labelMatch,
matchedBy: entry.matchedBy,
}));
console.groupCollapsed('[twblock] icon debug: ' + snapshot.reason);
if (rows.length) console.table(rows);
else console.info('[twblock] no menu items found');
console.log(snapshot);
console.groupEnd();
}
function recordIconDebugSnapshot(snapshot) {
iconDebugHistory.push(snapshot);
if (iconDebugHistory.length > MAX_ICON_DEBUG_HISTORY) {
iconDebugHistory.shift();
}
if (iconDebugEnabled) logIconDebugSnapshot(snapshot);
}
function dumpCurrentMenuIcons(reason) {
const snapshot = buildMenuIconSnapshot(document.querySelectorAll('[role="menuitem"]'), reason || 'manual-dump');
recordIconDebugSnapshot(snapshot);
return snapshot;
}
function installIconDebugHooks() {
if (window.__twblockIconDebugHooksInstalled) return;
window.__twblockIconDebugHooksInstalled = true;
window.addEventListener('twblock:debug-icons', (event) => {
const action = event.detail && typeof event.detail.action === 'string'
? event.detail.action
: 'dump';
if (action === 'on') {
setIconDebugEnabled(true);
return;
}
if (action === 'off') {
setIconDebugEnabled(false);
return;
}
if (action === 'history') {
console.log(iconDebugHistory.slice());
return;
}
dumpCurrentMenuIcons('manual-' + action);
});
}
function getIcon(action) {
if (action === 'block') return BLOCK_ICON || FALLBACK_BLOCK_ICON;
return MUTE_ICON || FALLBACK_MUTE_ICON;
}
// ---- i18n ----
const _lang = (navigator.language || '').toLowerCase();
const _L = _lang.startsWith('ja') ? 'ja' : (_lang.startsWith('zh') ? 'zh_CN' : 'en');
const _M = {"en":{"extName":"Ultimate Twitter Block","extDescription":"Add one-click block & mute buttons to every tweet, retweet, quote tweet, and profile on Twitter/X. Native UI design.","blockLabel":"Block","muteLabel":"Mute","blockedStatus":"Blocked","mutedStatus":"Muted","unblockLabel":"Unblock","unmuteLabel":"Unmute","toastBlocked":"Blocked @$1","toastMuted":"Muted @$1","toastUnblocked":"Unblocked @$1","toastUnmuted":"Unmuted @$1","errorTimeout":"Timed out","errorOccurred":"An error occurred","popupDescription":"One-click block & mute from tweets and profiles","settingsLabel":"Settings","sectionButtons":"Button Display","showBlockButton":"Show block button","showMuteButton":"Show mute button","confirmBlockFollowingLabel":"Confirm before blocking followed users","confirmBlockFollowing":"You are following @$1. Block anyway?","sectionStats":"Statistics","statsBlockedLabel":"Blocked","statsMutedLabel":"Muted","resetStats":"Reset Statistics","sectionReset":"Reset","resetHint":"Reset all data (statistics, icons, settings) to defaults","fullReset":"Full Reset Extension","confirmReset":"Reset all data (statistics and settings)?","supportLabel":"Support"},"ja":{"extName":"Ultimate Twitter Block","extDescription":"Twitter/Xのタイムラインにワンクリックのブロック&ミュートボタンを追加。ツイート・RT・引用RT・プロフィールに対応。","blockLabel":"ブロック","muteLabel":"ミュート","blockedStatus":"ブロック済み","mutedStatus":"ミュート済み","unblockLabel":"ブロック解除","unmuteLabel":"ミュート解除","toastBlocked":"@$1 をブロックしました","toastMuted":"@$1 をミュートしました","toastUnblocked":"@$1 のブロックを解除しました","toastUnmuted":"@$1 のミュートを解除しました","errorTimeout":"タイムアウトしました","errorOccurred":"エラーが発生しました","popupDescription":"ツイートやプロフィールに表示されるボタンでワンクリックブロック&ミュート","settingsLabel":"設定","sectionButtons":"ボタン表示","showBlockButton":"ブロックボタンを表示","showMuteButton":"ミュートボタンを表示","confirmBlockFollowingLabel":"フォロー中のユーザーをブロックする前に確認する","confirmBlockFollowing":"@$1 はフォロー中です。ブロックしますか?","sectionStats":"統計","statsBlockedLabel":"ブロック","statsMutedLabel":"ミュート","resetStats":"統計をリセット","sectionReset":"リセット","resetHint":"統計・アイコン・設定をすべて初期状態に戻します","fullReset":"拡張機能を完全リセット","confirmReset":"すべてのデータ(統計・設定)をリセットしますか?","supportLabel":"サポート"},"zh_CN":{"extName":"Ultimate Twitter Block","extDescription":"在 Twitter/X 上为每条推文、转发、引用推文和个人资料添加一键屏蔽与隐藏按钮。原生界面风格。","blockLabel":"屏蔽","muteLabel":"隐藏","blockedStatus":"已屏蔽","mutedStatus":"已隐藏","unblockLabel":"取消屏蔽","unmuteLabel":"取消隐藏","toastBlocked":"已屏蔽 @$1","toastMuted":"已隐藏 @$1","toastUnblocked":"已对 @$1 取消屏蔽","toastUnmuted":"已对 @$1 取消隐藏","errorTimeout":"请求超时","errorOccurred":"发生错误","popupDescription":"在推文和个人资料中一键屏蔽与隐藏","settingsLabel":"设置","sectionButtons":"按钮显示","showBlockButton":"显示屏蔽按钮","showMuteButton":"显示隐藏按钮","confirmBlockFollowingLabel":"屏蔽已关注用户前先确认","confirmBlockFollowing":"你已关注 @$1。仍要屏蔽吗?","sectionStats":"统计","statsBlockedLabel":"屏蔽","statsMutedLabel":"隐藏","resetStats":"重置统计","sectionReset":"重置","resetHint":"将统计、图标和设置恢复为默认值","fullReset":"完全重置扩展","confirmReset":"要重置所有数据(统计和设置)吗?","supportLabel":"支持"}};
function _i18n(key) { return (_M[_L] || _M.en)[key] || key; }
const i18n = {};
function cacheI18n() {
const keys = [
'blockLabel', 'muteLabel', 'blockedStatus', 'mutedStatus',
'unblockLabel', 'unmuteLabel', 'errorTimeout', 'errorOccurred',
];
for (const k of keys) i18n[k] = _i18n(k);
}
function msg(key, sub) {
if (sub != null) {
const s = _i18n(key);
return s.replace(/\$1/g, sub);
}
return i18n[key] || _i18n(key) || key;
}
// ---- 設定 ----
let showBlock = true;
let showMute = true;
let confirmBlockFollowing = false;
// ---- ブロック/ミュート済みユーザーの永続化 ----
const blockedUsers = new Map(); // screenName → 'block' | 'mute'
function loadBlockedUsers() {
return new Promise((resolve) => {
chrome.storage.local.get('blockedUsers', (data) => {
if (data.blockedUsers) {
for (const [k, v] of Object.entries(data.blockedUsers)) {
blockedUsers.set(k, v);
}
}
resolve();
});
});
}
function saveBlockedUsers() {
chrome.storage.local.set({ blockedUsers: Object.fromEntries(blockedUsers) });
}
function addBlockedUser(screenName, action) {
blockedUsers.set(screenName, action);
saveBlockedUsers();
}
function removeBlockedUser(screenName, action) {
if (blockedUsers.get(screenName) === action) {
blockedUsers.delete(screenName);
saveBlockedUsers();
}
}
// ---- アイコン更新(ストレージ or パッシブ監視) ----
let iconsExtracted = false;
// ストレージから保存済みアイコンを読み込み
function loadStoredIcons() {
return new Promise((resolve) => {
try {
const stored = JSON.parse(localStorage.getItem('twblock_icons'));
if (stored && stored.version === ICON_CACHE_VERSION) {
if (stored.block) BLOCK_ICON = stored.block;
if (stored.mute) MUTE_ICON = stored.mute;
iconsExtracted = Boolean(BLOCK_ICON && MUTE_ICON);
}
} catch {}
resolve();
});
}
// 設定を読み込み
function loadSettings() {
return new Promise((resolve) => {
try {
const stored = JSON.parse(localStorage.getItem('twblock_settings'));
if (stored) {
showBlock = stored.showBlock !== false;
showMute = stored.showMute !== false;
confirmBlockFollowing = stored.confirmBlockFollowing === true;
}
} catch {}
resolve();
});
}
// 既存ボタンのアイコンを一括差し替え
function replaceAllButtonIcons() {
document.querySelectorAll('.twblock-block:not(.twblock-success)').forEach(btn => {
btn.innerHTML = getIcon('block');
});
document.querySelectorAll('.twblock-mute:not(.twblock-success)').forEach(btn => {
btn.innerHTML = getIcon('mute');
});
}
// メニューアイテムからBlock/MuteのSVGを抽出する共通ロジック
function extractIconsFromMenuItems(menuItems) {
const snapshot = buildMenuIconSnapshot(menuItems, 'extract');
let foundBlock = false;
let foundMute = false;
let nextBlockIcon = BLOCK_ICON;
let nextMuteIcon = MUTE_ICON;
for (const entry of snapshot.entries) {
if (!entry.iconMarkup) continue;
if (!foundBlock && entry.signatureMatch === 'block') {
nextBlockIcon = entry.iconMarkup;
foundBlock = true;
entry.matchedBy = 'signature:block';
rememberIconSignature('block', entry.signature);
}
if (!foundMute && entry.signatureMatch === 'mute') {
nextMuteIcon = entry.iconMarkup;
foundMute = true;
entry.matchedBy = 'signature:mute';
rememberIconSignature('mute', entry.signature);
}
if (!foundBlock && entry.labelMatch === 'block') {
nextBlockIcon = entry.iconMarkup;
foundBlock = true;
entry.matchedBy = 'label:block';
rememberIconSignature('block', entry.signature);
}
if (!foundMute && entry.labelMatch === 'mute') {
nextMuteIcon = entry.iconMarkup;
foundMute = true;
entry.matchedBy = 'label:mute';
rememberIconSignature('mute', entry.signature);
}
}
snapshot.foundBlock = foundBlock;
snapshot.foundMute = foundMute;
snapshot.blockSignatureCount = BLOCK_ICON_SIGNATURES.size;
snapshot.muteSignatureCount = MUTE_ICON_SIGNATURES.size;
recordIconDebugSnapshot(snapshot);
if (foundBlock || foundMute) {
if (foundBlock) BLOCK_ICON = nextBlockIcon;
if (foundMute) MUTE_ICON = nextMuteIcon;
iconsExtracted = Boolean(BLOCK_ICON && MUTE_ICON);
persistIcons();
replaceAllButtonIcons();
}
}
// アクティブ取得: layersを非表示にしてメニューを開き、アイコン抽出後にメニュー要素をdisplay:noneで隠す
let extractRetries = 0;
function extractIconsOnce() {
if (iconsExtracted) return;
const caret = document.querySelector('[data-testid="caret"]');
const layers = document.getElementById('layers');
if (!caret || !layers) {
if (++extractRetries <= 5) {
setTimeout(extractIconsOnce, 2000);
}
return;
}
// メニュー展開前の#layers子要素を記録
const childrenBefore = new Set(layers.children);
// メニューを見えなくする
layers.style.visibility = 'hidden';
// MutationObserverでメニュー出現を即検知
const mo = new MutationObserver(() => {
const menuItems = document.querySelectorAll('[role="menuitem"]');
if (menuItems.length === 0) return;
mo.disconnect();
extractIconsFromMenuItems(menuItems);
// layersのvisibilityを復元
layers.style.visibility = '';
// メニューで追加された要素をdisplay:noneで隠す
// DOM削除するとReactのfiber treeが壊れるため、非表示にするだけ
for (const child of layers.children) {
if (!childrenBefore.has(child)) {
child.style.display = 'none';
}
}
});
mo.observe(layers, { childList: true, subtree: true });
caret.click();
// タイムアウト: 3秒以内に完了しなければ中止
setTimeout(() => {
mo.disconnect();
layers.style.visibility = '';
}, 3000);
}
// パッシブ監視: ユーザーが⋯メニューを開いた時にアイコンを抽出・更新
function observeLayers() {
const layers = document.getElementById('layers');
if (!layers) {
setTimeout(observeLayers, 1000);
return;
}
const layersObserver = new MutationObserver(() => {
setTimeout(() => {
const menuItems = document.querySelectorAll('[role="menuitem"]');
if (menuItems.length > 0) extractIconsFromMenuItems(menuItems);
}, 300);
});
layersObserver.observe(layers, { childList: true, subtree: true });
}
// ---- ページスクリプト注入(@grant none: ページコンテキストで直接実行) ----
function injectPageScript() {
(function () {
'use strict';
let capturedHeaders = null;
function captureHeaders(headers) {
if (!headers) return;
const normalized = {};
for (const [key, value] of Object.entries(headers)) {
normalized[String(key).toLowerCase()] = value;
}
if (!normalized.authorization || !normalized['x-csrf-token']) return;
capturedHeaders = {
authorization: normalized.authorization,
'x-csrf-token': normalized['x-csrf-token'],
'x-twitter-active-user': normalized['x-twitter-active-user'] || 'yes',
'x-twitter-auth-type': normalized['x-twitter-auth-type'] || 'OAuth2Session',
'x-twitter-client-language': normalized['x-twitter-client-language'] || document.documentElement.lang || 'en',
};
}
// Twitterのfetchをインターセプトして認証ヘッダーを取得
const originalFetch = window.fetch;
window.fetch = function (...args) {
const [url, options] = args;
if (typeof url === 'string' && url.includes('/i/api/')) {
if (options && options.headers) {
const headers =
options.headers instanceof Headers
? Object.fromEntries(options.headers.entries())
: options.headers;
captureHeaders(headers);
}
}
return originalFetch.apply(this, args);
};
// フォールバック: XMLHttpRequestもインターセプト
const origOpen = XMLHttpRequest.prototype.open;
const origSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
const origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._twblockUrl = url;
this._twblockHeaders = {};
return origOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
if (this._twblockHeaders) {
this._twblockHeaders[name.toLowerCase()] = value;
}
return origSetRequestHeader.call(this, name, value);
};
XMLHttpRequest.prototype.send = function (...args) {
if (this._twblockUrl && this._twblockUrl.includes('/i/api/')) {
captureHeaders(this._twblockHeaders);
}
return origSend.apply(this, args);
};
// ct0 cookieからCSRFトークンを取得
function getCsrfToken() {
const match = document.cookie.match(/ct0=([^;]+)/);
return match ? match[1] : null;
}
// 公開ベアラートークン(Twitter Web Appに埋め込まれている固定値)
const BEARER_TOKEN =
'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs' +
'%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
function getHeaders() {
if (capturedHeaders) return { ...capturedHeaders };
const csrf = getCsrfToken();
if (csrf) {
return {
authorization: 'Bearer ' + decodeURIComponent(BEARER_TOKEN),
'x-csrf-token': csrf,
'x-twitter-active-user': 'yes',
'x-twitter-auth-type': 'OAuth2Session',
'x-twitter-client-language': document.documentElement.lang || 'en',
};
}
return null;
}
// ブロック/ミュートAPIを呼び出す
async function performAction(action, screenName) {
const headers = getHeaders();
if (!headers) {
return { success: false, error: 'NO_AUTH', message: '認証情報が取得できません。ページを操作してから再試行してください。' };
}
const endpoints = {
block: 'https://x.com/i/api/1.1/blocks/create.json',
unblock: 'https://x.com/i/api/1.1/blocks/destroy.json',
mute: 'https://x.com/i/api/1.1/mutes/users/create.json',
unmute: 'https://x.com/i/api/1.1/mutes/users/destroy.json',
};
const url = endpoints[action];
if (!url) {
return { success: false, error: 'INVALID_ACTION', message: '不明なアクション: ' + action };
}
try {
const response = await originalFetch(url, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/x-www-form-urlencoded',
},
credentials: 'include',
body: 'screen_name=' + encodeURIComponent(screenName),
});
if (response.ok) {
const data = await response.json();
return { success: true, data };
}
// 403: CSRFトークン失効 → ct0 cookieから再取得してリトライ
if (response.status === 403) {
const freshCsrf = getCsrfToken();
if (freshCsrf && freshCsrf !== headers['x-csrf-token']) {
const retryResponse = await originalFetch(url, {
method: 'POST',
headers: {
...headers,
'x-csrf-token': freshCsrf,
'Content-Type': 'application/x-www-form-urlencoded',
},
credentials: 'include',
body: 'screen_name=' + encodeURIComponent(screenName),
});
if (retryResponse.ok) {
capturedHeaders = { ...headers, 'x-csrf-token': freshCsrf };
const data = await retryResponse.json();
return { success: true, data };
}
}
return { success: false, error: 'FORBIDDEN', message: 'セッションが期限切れです。ページを再読み込みしてください。' };
}
if (response.status === 429) {
return { success: false, error: 'RATE_LIMITED', message: 'レート制限に達しました。しばらく待ってから再試行してください。' };
}
return { success: false, error: 'HTTP_' + response.status, message: await response.text() };
} catch (err) {
return { success: false, error: 'NETWORK', message: err.message };
}
}
// フォロー状態を確認するAPI
async function checkFollowing(screenName) {
const headers = getHeaders();
if (!headers) {
return { following: false };
}
try {
const url = 'https://x.com/i/api/1.1/friendships/show.json?source_screen_name=&target_screen_name=' + encodeURIComponent(screenName);
const response = await originalFetch(url, {
method: 'GET',
headers: { ...headers },
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
return { following: data.relationship?.source?.following === true };
}
return { following: false };
} catch (err) {
return { following: false };
}
}
// content.jsからのメッセージを受信
window.addEventListener('message', async (event) => {
if (event.source !== window) return;
if (event.data && event.data.type === '__TWBLOCK_ACTION') {
const { action, screenName, requestId } = event.data;
const result = await performAction(action, screenName);
window.postMessage(
{ type: '__TWBLOCK_RESULT', requestId, ...result },
'*'
);
}
if (event.data && event.data.type === '__TWBLOCK_CHECK_FOLLOWING') {
const { screenName, requestId } = event.data;
const result = await checkFollowing(screenName);
window.postMessage(
{ type: '__TWBLOCK_RESULT', requestId, ...result },
'*'
);
}
});
// 準備完了を通知
window.postMessage({ type: '__TWBLOCK_READY' }, '*');
})();
}
// ---- メッセージブリッジ ----
const pending = new Map();
let reqId = 0;
function sendAction(action, screenName) {
return new Promise((resolve) => {
const id = '__twb_' + ++reqId;
pending.set(id, resolve);
window.postMessage(
{ type: '__TWBLOCK_ACTION', action, screenName, requestId: id },
'*'
);
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
resolve({ success: false, error: 'TIMEOUT', message: msg('errorTimeout') });
}
}, 15000);
});
}
function checkFollowing(screenName) {
return new Promise((resolve) => {
const id = '__twb_' + ++reqId;
pending.set(id, resolve);
window.postMessage(
{ type: '__TWBLOCK_CHECK_FOLLOWING', screenName, requestId: id },
'*'
);
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
resolve({ following: false });
}
}, 5000);
});
}
window.addEventListener('message', (e) => {
if (e.source !== window || !e.data) return;
if (e.data.type !== '__TWBLOCK_RESULT') return;
const cb = pending.get(e.data.requestId);
if (cb) {
pending.delete(e.data.requestId);
cb(e.data);
}
});
// ---- screen_name 抽出 ----
function extractScreenName(el) {
const links = el.querySelectorAll('a[role="link"]');
for (const link of links) {
const href = link.getAttribute('href');
if (href && /^\/[A-Za-z0-9_]{1,15}$/.test(href)) {
return href.substring(1);
}
}
const spans = el.querySelectorAll('span');
for (const span of spans) {
const m = span.textContent.match(/^@([A-Za-z0-9_]{1,15})$/);
if (m) return m[1];
}
const allLinks = el.querySelectorAll('a[href]');
for (const link of allLinks) {
const m = link.getAttribute('href')?.match(/^\/([A-Za-z0-9_]{1,15})\/status\//);
if (m) return m[1];
}
return null;
}
function getProfilePathInfo() {
const parts = window.location.pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
const screenName = parts[0];
if (!/^[A-Za-z0-9_]{1,15}$/.test(screenName) || RESERVED_PATHS.has(screenName.toLowerCase())) {
return null;
}
if (parts.length === 1) {
return { screenName, section: null };
}
if (parts.length === 2 && PROFILE_SUBPATHS.has(parts[1].toLowerCase())) {
return { screenName, section: parts[1].toLowerCase() };
}
return null;
}
function getProfileScreenName() {
const info = getProfilePathInfo();
return info ? info.screenName : null;
}
function isViewingProfileTimeline(screenName) {
const info = getProfilePathInfo();
return Boolean(info && info.screenName.toLowerCase() === screenName.toLowerCase());
}
let myScreenName = null;
function getMyScreenName() {
if (myScreenName) return myScreenName;
const navLink = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
if (navLink) {
const href = navLink.getAttribute('href');
if (href) { myScreenName = href.replace('/', ''); return myScreenName; }
}
return null;
}
// ---- トースト通知 ----
// ---- Twitterアクセントカラー取得 ----
const ACCENT_COLORS = new Set([
'rgb(29, 155, 240)', // Blue
'rgb(255, 212, 0)', // Yellow
'rgb(249, 24, 128)', // Pink
'rgb(120, 86, 255)', // Purple
'rgb(255, 122, 0)', // Orange
'rgb(0, 186, 124)', // Green
]);
const DEFAULT_ACCENT = 'rgb(29, 155, 240)';
let cachedAccentColor = null;
function loadStoredAccentColor() {
return new Promise((resolve) => {
try {
const stored = localStorage.getItem('twblock_accentColor');
if (stored && ACCENT_COLORS.has(stored)) {
cachedAccentColor = stored;
}
} catch {}
resolve();
});
}
function getAccentColor() {
const activeTab = document.querySelector('[role="tab"][aria-selected="true"]');
if (activeTab) {
for (const div of activeTab.querySelectorAll('div')) {
const bg = getComputedStyle(div).backgroundColor;
if (ACCENT_COLORS.has(bg)) {
if (bg !== cachedAccentColor) {
cachedAccentColor = bg;
localStorage.setItem('twblock_accentColor', bg);
}
return bg;
}
}
}
return cachedAccentColor || DEFAULT_ACCENT;
}
function showToast(message) {
const existing = document.querySelector('.twblock-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'twblock-toast';
toast.textContent = message;
toast.style.backgroundColor = getAccentColor();
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('twblock-toast-hide');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// ---- ツイート非表示(共通ロジック) ----
function createHiddenBar(screenName, action, onUndo) {
const bar = document.createElement('div');
bar.className = 'twblock-hidden-bar';
const statusLabel = action === 'block' ? msg('blockedStatus') : msg('mutedStatus');
const undoLabel = action === 'block' ? msg('unblockLabel') : msg('unmuteLabel');
const undoAction = action === 'block' ? 'unblock' : 'unmute';
const undoToastKey = action === 'block' ? 'toastUnblocked' : 'toastUnmuted';
bar.innerHTML =
'' + statusLabel + ' @' + screenName + '' +
'';
bar.querySelector('.twblock-show-btn').addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const btn = e.currentTarget;
btn.disabled = true;
btn.textContent = '…';
const result = await sendAction(undoAction, screenName);
if (result.success) {
removeBlockedUser(screenName, action);
onUndo(action);
bar.remove();
showToast(msg(undoToastKey, screenName));
} else {
btn.disabled = false;
btn.textContent = undoLabel;
}
});
return bar;
}
function hideTweet(tweet, screenName, action) {
if (tweet.querySelector(':scope > .twblock-hidden-bar')) return;
const contentWrapper = tweet.querySelector(':scope > div');
if (!contentWrapper) return;
contentWrapper.style.display = 'none';
const bar = createHiddenBar(screenName, action, (act) => {
contentWrapper.style.display = '';
const twblockBtn = tweet.querySelector('.twblock-' + act + '.twblock-success');
if (twblockBtn) {
twblockBtn.classList.remove('twblock-success');
twblockBtn.innerHTML = getIcon(act);
twblockBtn._isActive = false;
}
});
tweet.insertBefore(bar, tweet.firstChild);
}
// ---- 引用ツイート非表示 ----
function hideQuotedTweet(quotedBlock, screenName, action) {
if (quotedBlock.querySelector('.twblock-hidden-bar')) return;
const hiddenChildren = [];
for (const child of quotedBlock.children) {
child.style.display = 'none';
hiddenChildren.push(child);
}
const bar = createHiddenBar(screenName, action, (act) => {
hiddenChildren.forEach(child => { child.style.display = ''; });
const twblockBtn = quotedBlock.querySelector('.twblock-' + act + '.twblock-success');
if (twblockBtn) {
twblockBtn.classList.remove('twblock-success');
twblockBtn.innerHTML = getIcon(act);
twblockBtn._isActive = false;
}
});
quotedBlock.insertBefore(bar, quotedBlock.firstChild);
}
// ---- ボタン作成 ----
function createButtons(screenName, tweet) {
if (!showBlock && !showMute) return null;
const container = document.createElement('div');
container.className = 'twblock-btn-container';
container.setAttribute('data-screen-name', screenName);
if (showBlock) {
container.appendChild(createButton(screenName, 'block', msg('blockLabel'), tweet));
}
if (showMute) {
container.appendChild(createButton(screenName, 'mute', msg('muteLabel'), tweet));
}
return container;
}
function createButton(screenName, action, label, tweet) {
const btn = document.createElement('button');
btn.className = 'twblock-btn twblock-' + action;
btn.setAttribute('aria-label', label + ' @' + screenName);
btn.title = label + ' @' + screenName;
btn.innerHTML = getIcon(action);
btn._isActive = false;
const undoAction = action === 'block' ? 'unblock' : 'unmute';
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
if (btn.disabled) return;
btn.disabled = true;
btn.classList.add('twblock-loading');
const currentAction = btn._isActive ? undoAction : action;
// フォロー中ユーザーのブロック確認
if (confirmBlockFollowing && action === 'block' && !btn._isActive) {
const followResult = await checkFollowing(screenName);
if (followResult.following) {
btn.classList.remove('twblock-loading');
btn.disabled = false;
if (!confirm(msg('confirmBlockFollowing', screenName))) return;
btn.disabled = true;
btn.classList.add('twblock-loading');
}
}
const result = await sendAction(currentAction, screenName);
btn.classList.remove('twblock-loading');
if (result.success) {
if (!btn._isActive) {
btn._isActive = true;
btn.classList.add('twblock-success');
btn.innerHTML = CHECK_ICON;
btn.title = (action === 'block' ? msg('blockedStatus') : msg('mutedStatus')) + ' @' + screenName;
btn.disabled = false;
addBlockedUser(screenName, action);
showToast(msg(action === 'block' ? 'toastBlocked' : 'toastMuted', screenName));
const btnContainer = btn.closest('.twblock-btn-container');
if (action === 'block' && btnContainer && btnContainer.classList.contains('twblock-profile')) {
setTimeout(() => window.location.reload(), 300);
return;
}
// 引用ツイート内のボタンなら引用部分にバー表示
if (btnContainer && btnContainer._quotedBlock) {
setTimeout(() => hideQuotedTweet(btnContainer._quotedBlock, screenName, action), 300);
} else {
const parentTweet = btn.closest('article[data-testid="tweet"]');
if (parentTweet) {
setTimeout(() => hideTweet(parentTweet, screenName, action), 300);
}
}
} else {
btn._isActive = false;
btn.classList.remove('twblock-success');
btn.innerHTML = getIcon(action);
btn.title = label + ' @' + screenName;
removeBlockedUser(screenName, action);
btn.disabled = false;
}
} else {
btn.classList.add('twblock-error');
btn.title = result.message || msg('errorOccurred');
btn.disabled = false;
setTimeout(() => btn.classList.remove('twblock-error'), 3000);
}
});
return btn;
}
// ---- Grok/caretの行を見つけて、その中にボタンを挿入 ----
function findGrokRow(tweet) {
const caret = tweet.querySelector('[data-testid="caret"]');
if (!caret) return null;
let fallbackRow = null;
let node = caret.parentElement;
for (let i = 0; i < 8; i++) {
if (!node || node === tweet) break;
const cs = getComputedStyle(node);
if (cs.display === 'flex' && cs.flexDirection === 'row') {
const grokBtn = node.querySelector('[aria-label^="Grok"]');
if (grokBtn) return { row: node, grokBtn, caret };
// caretの直近の狭い行(67px)ではなく、アクションバー全体の広い行(>200px)を使う
if (node.contains(caret) && node.offsetWidth > 200) {
fallbackRow = node;
break;
}
}
node = node.parentElement;
}
return fallbackRow ? { row: fallbackRow, grokBtn: null, caret } : null;
}
// ---- RT: リツイーターと元投稿者を分離抽出 ----
function extractRetweetInfo(tweet) {
const sc = tweet.querySelector('[data-testid="socialContext"]');
if (!sc) return null;
const link = sc.closest('a[href]');
if (!link) return null;
const href = link.getAttribute('href');
if (!href || !/^\/[A-Za-z0-9_]{1,15}$/.test(href)) return null;
// "reposted"リンクの親flex-row と リンク要素自体
let scRow = link.parentElement;
for (let i = 0; i < 3; i++) {
if (!scRow) break;
const cs = getComputedStyle(scRow);
if (cs.display === 'flex' && cs.flexDirection === 'row') break;
scRow = scRow.parentElement;
}
// リンクの直接の親(flex-column) — ここをflex-rowにしてボタンを横並びにする
const scLinkParent = link.parentElement;
return { retweeter: href.substring(1), scRow, scLinkParent };
}
// ツイート本文エリアからscreen_nameを抽出(socialContext内のリンクを除外)
function extractAuthorScreenName(tweet) {
const userName = tweet.querySelector('[data-testid="User-Name"]');
if (userName) {
const result = extractScreenName(userName);
if (result) return result;
}
return null;
}
// ---- ボタン挿入: タイムラインツイート ----
function processTweets() {
const me = getMyScreenName();
const tweets = document.querySelectorAll(
'article[data-testid="tweet"]:not([' + PROCESSED + '])'
);
tweets.forEach((tweet) => {
// 内部DOMが未レンダリングならスキップ(次回再試行)
if (!tweet.querySelector('[data-testid="User-Name"]') ||
!tweet.querySelector('[data-testid="caret"]')) return;
try {
tweet.setAttribute(PROCESSED, '1');
const rtInfo = extractRetweetInfo(tweet);
// RT者のボタンを"reposted"行に挿入
if (rtInfo && rtInfo.retweeter !== me && rtInfo.scLinkParent) {
const rtButtons = createButtons(rtInfo.retweeter, tweet);
if (rtButtons) {
rtButtons.classList.add('twblock-tweet');
rtButtons.classList.add('twblock-repost');
rtInfo.scLinkParent.classList.add('twblock-repost-row');
rtInfo.scLinkParent.appendChild(rtButtons);
}
}
// 元投稿者のボタンをgrok/caret行に挿入
const authorName = extractAuthorScreenName(tweet) || extractScreenName(tweet);
if (!authorName || authorName === me) {
processQuotedTweet(tweet, me);
return;
}
const grokInfo = findGrokRow(tweet);
if (grokInfo) {
const { row, grokBtn } = grokInfo;
const buttons = createButtons(authorName, tweet);
if (!buttons) return;
buttons.classList.add('twblock-tweet');
buttons.style.marginLeft = 'auto';
buttons.style.paddingLeft = '4px';
if (grokBtn) {
let grokChild = null;
for (const child of row.children) {
if (child.contains(grokBtn)) { grokChild = child; break; }
}
if (grokChild) {
row.insertBefore(buttons, grokChild);
} else {
row.insertBefore(buttons, row.firstChild);
}
} else {
// caretを含む子要素の直前に挿入(⋯の左側に配置)
let caretChild = null;
for (const child of row.children) {
if (child.contains(grokInfo.caret)) { caretChild = child; break; }
}
if (caretChild) {
row.insertBefore(buttons, caretChild);
} else {
row.appendChild(buttons);
}
}
}
// ブロック/ミュート済みユーザーのツイートを自動非表示
const blockedAction = blockedUsers.get(authorName);
if (blockedAction) {
const activeBtn = tweet.querySelector('.twblock-' + blockedAction + ':not(.twblock-success)');
if (activeBtn) {
activeBtn._isActive = true;
activeBtn.classList.add('twblock-success');
activeBtn.innerHTML = CHECK_ICON;
}
if (!isViewingProfileTimeline(authorName)) {
hideTweet(tweet, authorName, blockedAction);
}
}
processQuotedTweet(tweet, me);
} catch (e) {
tweet.removeAttribute(PROCESSED);
}
});
}
// ---- ボタン挿入: 引用ツイート ----
function processQuotedTweet(parentTweet, me) {
const candidates = parentTweet.querySelectorAll(
'div[role="link"], div[tabindex="0"]'
);
candidates.forEach((block) => {
if (block.hasAttribute(PROCESSED)) return;
if (block.closest('article') !== parentTweet) return;
const userName = block.querySelector('[data-testid="User-Name"]');
if (!userName) return;
const parentUserName = parentTweet.querySelector('[data-testid="User-Name"]');
if (userName === parentUserName) return;
const qtScreenName = extractScreenName(block);
if (!qtScreenName || qtScreenName === me) return;
block.setAttribute(PROCESSED, '1');
const buttons = createButtons(qtScreenName, null);
if (!buttons) return;
buttons._quotedBlock = block;
// User-Nameの親flex-rowを探してインラインに挿入
let targetRow = null;
let node = userName.parentElement;
for (let i = 0; i < 5; i++) {
if (!node || node === block) break;
const cs = getComputedStyle(node);
if (cs.display === 'flex' && cs.flexDirection === 'row') {
targetRow = node;
break;
}
node = node.parentElement;
}
if (!targetRow) return;
// targetRow〜block間の祖先コンテナを広げて全幅にする
let ancestor = targetRow;
while (ancestor && ancestor !== block) {
ancestor.style.flexGrow = '1';
ancestor.style.minWidth = '0';
ancestor = ancestor.parentElement;
}
buttons.classList.add('twblock-tweet');
buttons.style.marginLeft = 'auto';
buttons.style.paddingLeft = '8px';
targetRow.appendChild(buttons);
// ブロック/ミュート済みユーザーの引用ツイートを自動非表示
const blockedAction = blockedUsers.get(qtScreenName);
if (blockedAction) {
const activeBtn = buttons.querySelector('.twblock-' + blockedAction + ':not(.twblock-success)');
if (activeBtn) {
activeBtn._isActive = true;
activeBtn.classList.add('twblock-success');
activeBtn.innerHTML = CHECK_ICON;
}
if (!isViewingProfileTimeline(qtScreenName)) {
hideQuotedTweet(block, qtScreenName, blockedAction);
}
}
});
}
// ---- ボタン挿入: 全Followボタン共通処理 ----
function processFollowButtons() {
const me = getMyScreenName();
const followBtns = document.querySelectorAll(
'[data-testid$="-follow"]:not([' + PROCESSED + ']), [data-testid$="-unfollow"]:not([' + PROCESSED + '])'
);
followBtns.forEach((btn) => {
if (btn.closest('article[data-testid="tweet"]')) return;
btn.setAttribute(PROCESSED, '1');
const hoverCard = btn.closest('[data-testid="HoverCard"]');
const userCell = btn.closest('[data-testid="UserCell"]');
const placement = btn.closest('[data-testid="placementTracking"]');
const isProfile = placement && !userCell && !hoverCard;
let screenName;
if (isProfile) {
screenName = getProfileScreenName();
} else {
const container = userCell || hoverCard || btn.parentElement;
screenName = extractScreenName(container);
}
if (!screenName || screenName === me) return;
let targetRow = null;
let startNode = isProfile ? placement.parentElement : btn.parentElement;
for (let i = 0; i < 4; i++) {
if (!startNode) break;
const cs = getComputedStyle(startNode);
if (cs.display === 'flex' && cs.flexDirection === 'row') {
targetRow = startNode;
break;
}
startNode = startNode.parentElement;
}
if (!targetRow || targetRow.querySelector('.twblock-btn-container')) return;
const cssClass = isProfile ? 'twblock-profile' : 'twblock-sidebar';
const buttons = createButtons(screenName, null);
if (!buttons) return;
buttons.classList.add(cssClass);
let followChild = isProfile ? placement : null;
if (!followChild) {
for (const child of targetRow.children) {
if (child.contains(btn)) { followChild = child; break; }
}
}
if (!followChild) return;
if (isProfile) {
// プロフィールではFollowボタンをreparentするとReactが壊れるため
// twblockボタンのみをFollowの前に挿入する
targetRow.insertBefore(buttons, followChild);
} else {
// sidebar / UserCellではラッパーでまとめてgapで間隔を確保
const wrapper = document.createElement('div');
wrapper.className = 'twblock-follow-wrapper';
targetRow.insertBefore(wrapper, followChild);
wrapper.appendChild(buttons);
wrapper.appendChild(followChild);
}
});
}
// ---- 設定変更のリアルタイム反映 ----
function applyButtonVisibility() {
document.querySelectorAll('.twblock-block').forEach(btn => {
btn.style.display = showBlock ? '' : 'none';
});
document.querySelectorAll('.twblock-mute').forEach(btn => {
btn.style.display = showMute ? '' : 'none';
});
document.querySelectorAll('.twblock-btn-container').forEach(container => {
const hasVisible = container.querySelector('.twblock-btn:not([style*="display: none"])');
container.style.display = hasVisible ? '' : 'none';
});
}
// ---- ボタン挿入: 検索候補(typeahead)のユーザー ----
function processTypeahead() {
const me = getMyScreenName();
const items = document.querySelectorAll(
'[data-testid="typeaheadRecentSearchesItem"]:not([' + PROCESSED + ']), [data-testid="typeaheadResult"]:not([' + PROCESSED + '])'
);
items.forEach((item) => {
if (!item.querySelector('img')) return; // ユーザー項目のみ(検索クエリは除外)
item.setAttribute(PROCESSED, '1');
const screenName = extractScreenName(item);
if (!screenName || screenName === me) return;
// item > div > div(flex/row) > div(textArea) > div(flex/row): [名前] [Xボタン]
const container = item.children[0]?.children[0];
if (!container) return;
const textArea = container.children[1];
if (!textArea) return;
const row = textArea.children[0];
if (!row || row.querySelector('.twblock-btn-container')) return;
const buttons = createButtons(screenName, null);
if (!buttons) return;
buttons.classList.add('twblock-typeahead');
// Xボタン(最後の子)の前に挿入
const xBtn = row.querySelector('button');
if (xBtn) {
row.insertBefore(buttons, xBtn);
} else {
row.appendChild(buttons);
}
});
}
// ---- メイン処理 ----
function processAll() {
processTweets();
processFollowButtons();
processTypeahead();
}
let rafScheduled = false;
let trailingTimer = null;
const observer = new MutationObserver(() => {
// 次の描画フレームで即処理(ツイートと同フレームにボタン表示)
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
processAll();
});
}
// rAF時点で未完成だった要素を拾うフォールバック
if (trailingTimer) clearTimeout(trailingTimer);
trailingTimer = setTimeout(processAll, 200);
});
let lastUrl = location.href;
function checkUrlChange() {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(processAll, 500);
}
}
// ---- CSS注入 ----
function injectCSS() {
const style = document.createElement('style');
style.textContent = "/* ========== Ultimate Twitter Block ========== */\r\n\r\n/* Followボタン + twblockボタンのラッパー */\r\n.twblock-follow-wrapper {\r\n display: flex;\r\n align-items: center;\r\n gap: 4px;\r\n flex-shrink: 0;\r\n}\r\n\r\n/* ラッパー内のFollowボタン親のmargin-leftをリセット */\r\n.twblock-follow-wrapper > :not(.twblock-btn-container) {\r\n margin-left: 0 !important;\r\n}\r\n\r\n/* ボタンコンテナ(共通) */\r\n.twblock-btn-container {\r\n display: flex;\r\n align-items: center;\r\n gap: 0;\r\n flex-shrink: 0;\r\n}\r\n\r\n/* ツイートヘッダー: Grok/caret行内に配置 (Grok/caretと同サイズ) */\r\n.twblock-btn-container.twblock-tweet {\r\n flex: 0 0 auto;\r\n gap: 8px;\r\n}\r\n\r\n.twblock-btn-container.twblock-tweet .twblock-btn {\r\n width: 20px;\r\n height: 20px;\r\n position: relative;\r\n overflow: visible;\r\n}\r\n\r\n.twblock-btn-container.twblock-tweet .twblock-btn::before {\r\n content: '';\r\n position: absolute;\r\n top: 50%;\r\n left: 50%;\r\n width: 34px;\r\n height: 34px;\r\n margin: -17px;\r\n border-radius: 50%;\r\n transition: background-color 0.15s ease;\r\n}\r\n\r\n.twblock-btn-container.twblock-tweet .twblock-btn svg {\r\n width: 18.75px;\r\n height: 18.75px;\r\n position: relative;\r\n}\r\n\r\n/* ツイートボタン: ホバー背景は::beforeで表示、ボタン自体は透明 */\r\n.twblock-btn-container.twblock-tweet .twblock-block:hover:not(:disabled),\r\n.twblock-btn-container.twblock-tweet .twblock-mute:hover:not(:disabled) {\r\n background-color: transparent;\r\n}\r\n\r\n.twblock-btn-container.twblock-tweet .twblock-block:hover:not(:disabled)::before {\r\n background-color: rgba(244, 33, 46, 0.1);\r\n}\r\n\r\n.twblock-btn-container.twblock-tweet .twblock-mute:hover:not(:disabled)::before {\r\n background-color: rgba(255, 173, 31, 0.1);\r\n}\r\n\r\n.twblock-btn-container.twblock-tweet .twblock-success:hover {\r\n background-color: transparent !important;\r\n}\r\n\r\n.twblock-btn-container.twblock-tweet .twblock-success:hover::before {\r\n background-color: rgba(244, 33, 46, 0.1);\r\n}\r\n\r\n/* RT(\"reposted\")行のpadding-top:12pxを上下に分散 */\r\n.twblock-repost-row .r-ttdzmv {\r\n padding-top: 6px;\r\n padding-bottom: 6px;\r\n}\r\n\r\n/* RT(\"reposted\")行の親をflex-rowに変更して横並びにする */\r\n.twblock-repost-row {\n flex-direction: row !important;\n align-items: center;\n gap: 4px;\n}\n\n.twblock-attribution-row {\n display: flex;\n align-items: center;\n gap: 4px;\n flex-wrap: wrap;\n}\n\r\n/* RT(\"reposted\")行: テキスト(16px/20px line-height)とアイコンの中心を揃える */\r\n.twblock-btn-container.twblock-repost {\r\n gap: 4px;\r\n margin-top: -2px;\r\n margin-bottom: -2px;\r\n}\r\n\r\n.twblock-btn-container.twblock-repost .twblock-btn::before {\r\n display: none;\r\n}\r\n\r\n/* プロフィール: Followボタンと同じ高さ(36px)の丸ボタン */\r\n.twblock-btn-container.twblock-profile {\r\n gap: 8px;\r\n align-self: flex-start;\r\n margin-right: 8px;\r\n}\r\n\r\n.twblock-btn-container.twblock-profile .twblock-btn {\r\n width: 36px;\r\n height: 36px;\r\n border-radius: 50%;\r\n border: 1px solid light-dark(rgb(207, 217, 222), rgb(83, 100, 113));\r\n color: light-dark(rgb(15, 20, 26), rgb(230, 233, 234));\r\n}\r\n\r\n.twblock-btn-container.twblock-profile .twblock-btn svg {\r\n width: 20px;\r\n height: 20px;\r\n}\r\n\r\n/* 検索候補(typeahead): Xボタンの左に配置 */\r\n.twblock-btn-container.twblock-typeahead {\r\n gap: 4px;\r\n flex-shrink: 0;\r\n margin-left: auto;\r\n}\r\n\r\n.twblock-btn-container.twblock-typeahead .twblock-btn {\r\n width: 20px;\r\n height: 20px;\r\n}\r\n\r\n.twblock-btn-container.twblock-typeahead .twblock-btn svg {\r\n width: 18px;\r\n height: 18px;\r\n}\r\n\r\n/* サイドバー / フォロー一覧: 32px丸ボタン */\r\n.twblock-btn-container.twblock-sidebar {\r\n gap: 4px;\r\n flex-shrink: 0;\r\n}\r\n\r\n.twblock-btn-container.twblock-sidebar .twblock-btn {\r\n width: 32px;\r\n height: 32px;\r\n border-radius: 50%;\r\n border: 1px solid light-dark(rgb(207, 217, 222), rgb(83, 100, 113));\r\n color: light-dark(rgb(15, 20, 26), rgb(230, 233, 234));\r\n}\r\n\r\n.twblock-btn-container.twblock-sidebar .twblock-btn svg {\r\n width: 18px;\r\n height: 18px;\r\n}\r\n\r\n/* ホバーカード */\r\n.twblock-btn-container.twblock-hovercard {\r\n margin-right: 8px;\r\n}\r\n\r\n\r\n/* 個別ボタン(デフォルト: 34x34, アイコン20x20) */\r\n.twblock-btn {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 34px;\r\n height: 34px;\r\n border-radius: 50%;\r\n border: none;\r\n background: transparent;\r\n cursor: pointer;\r\n padding: 0;\r\n transition: background-color 0.15s ease, color 0.15s ease;\r\n color: light-dark(rgb(83, 100, 113), rgb(113, 118, 123));\r\n outline: none;\r\n}\r\n\r\n.twblock-btn:focus-visible {\r\n box-shadow: 0 0 0 2px rgb(29, 155, 240);\r\n}\r\n\r\n.twblock-btn svg {\r\n width: 20px;\r\n height: 20px;\r\n fill: currentColor;\r\n pointer-events: none;\r\n}\r\n\r\n/* ブロックボタン: ホバーで赤 */\r\n.twblock-block:hover:not(:disabled) {\r\n background-color: rgba(244, 33, 46, 0.1);\r\n color: rgb(244, 33, 46);\r\n}\r\n\r\n/* ミュートボタン: ホバーでオレンジ */\r\n.twblock-mute:hover:not(:disabled) {\r\n background-color: rgba(255, 173, 31, 0.1);\r\n color: rgb(255, 173, 31);\r\n}\r\n\r\n/* ローディング状態 */\r\n.twblock-loading {\r\n opacity: 0.5;\r\n pointer-events: none;\r\n}\r\n\r\n.twblock-loading svg {\r\n animation: twblock-spin 0.8s linear infinite;\r\n}\r\n\r\n@keyframes twblock-spin {\r\n from { transform: rotate(0deg); }\r\n to { transform: rotate(360deg); }\r\n}\r\n\r\n/* 成功状態: 緑 (クリックで解除可能) */\r\n.twblock-success {\r\n color: rgb(0, 186, 124) !important;\r\n}\r\n\r\n.twblock-success:hover {\r\n background-color: rgba(244, 33, 46, 0.1) !important;\r\n color: rgb(244, 33, 46) !important;\r\n}\r\n\r\n/* エラー状態 */\r\n.twblock-error {\r\n color: rgb(244, 33, 46) !important;\r\n animation: twblock-shake 0.3s ease;\r\n}\r\n\r\n@keyframes twblock-shake {\r\n 0%, 100% { transform: translateX(0); }\r\n 25% { transform: translateX(-3px); }\r\n 75% { transform: translateX(3px); }\r\n}\r\n\r\n/* ---- ブロック/ミュート後の非表示バー ---- */\r\n.twblock-hidden-bar {\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 12px;\r\n padding: 12px 16px;\r\n border-bottom: 1px solid light-dark(rgb(239, 243, 244), rgb(47, 51, 54));\r\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\r\n}\r\n\r\n.twblock-hidden-label {\r\n color: rgb(113, 118, 123);\r\n font-size: 14px;\r\n}\r\n\r\n.twblock-show-btn {\r\n background: none;\r\n border: 1px solid light-dark(rgb(207, 217, 222), rgb(83, 100, 113));\r\n border-radius: 16px;\r\n color: light-dark(rgb(15, 20, 26), rgb(239, 243, 244));\r\n font-size: 13px;\r\n padding: 4px 14px;\r\n cursor: pointer;\r\n transition: background-color 0.15s ease;\r\n}\r\n\r\n.twblock-show-btn:hover {\r\n background-color: light-dark(rgba(15, 20, 25, 0.1), rgba(239, 243, 244, 0.1));\r\n}\r\n\r\n/* ---- トースト通知 ---- */\r\n.twblock-toast {\r\n position: fixed;\r\n bottom: 40px;\r\n left: 50%;\r\n transform: translateX(-50%);\r\n background: rgb(29, 155, 240);\r\n color: rgb(255, 255, 255);\r\n padding: 12px 24px;\r\n border-radius: 4px;\r\n font-size: 15px;\r\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\r\n z-index: 10000;\r\n animation: twblock-toast-in 0.3s ease;\r\n}\r\n\r\n.twblock-toast-hide {\r\n opacity: 0;\r\n transition: opacity 0.3s ease;\r\n}\r\n\r\n@keyframes twblock-toast-in {\r\n from { opacity: 0; transform: translateX(-50%) translateY(10px); }\r\n to { opacity: 1; transform: translateX(-50%) translateY(0); }\r\n}\r\n\r\n";
document.head.appendChild(style);
}
// ---- 初期化 ----
async function init() {
injectCSS();
cacheI18n();
loadIconDebugFlag();
installIconDebugHooks();
injectPageScript();
await loadStoredIcons();
await loadSettings();
await loadStoredAccentColor();
await loadBlockedUsers();
setTimeout(processAll, 300);
observer.observe(document.body, { childList: true, subtree: true });
setInterval(checkUrlChange, 1000);
observeLayers();
// ストレージに未保存ならアクティブ取得(非表示で一瞬)
if (!iconsExtracted) {
setTimeout(extractIconsOnce, 2000);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();