// ==UserScript==
// @name X FollowFlow
// @name:zh-CN X FollowFlow - 推荐流关注/取关助手
// @namespace https://github.com/haorui-lab/x-follow-flow
// @version 0.5.6
// @description Add minimalist native-style Follow / Unfollow icon button directly to the right of the Share button on X timelines with hover-to-confirm unfollow and instant state sync.
// @description:zh-CN 在 X (Twitter) 时间线分享按钮右侧增加无缝原生风格关注/取关 (+ / •) 按钮,悬停显示 − 后单击即可取关,并支持多卡状态同步。
// @author haorui
// @homepageURL https://github.com/haorui-lab/x-follow-flow
// @supportURL https://github.com/haorui-lab/x-follow-flow/issues
// @downloadURL https://raw.githubusercontent.com/haorui-lab/x-follow-flow/main/x-follow-flow.user.js
// @updateURL https://raw.githubusercontent.com/haorui-lab/x-follow-flow/main/x-follow-flow.user.js
// @match https://x.com/*
// @match https://twitter.com/*
// @icon https://abs.twimg.com/favicons/twitter.3.ico
// @run-at document-start
// @grant none
// ==/UserScript==
(function () {
'use strict';
// ==========================================
// Configuration
// ==========================================
const CONFIG = {
scanDebounceMs: 50,
maxCaretWaitMs: 800
};
const RESERVED_ROUTES = new Set([
'home',
'explore',
'notifications',
'messages',
'i',
'settings',
'search',
'compose',
'login',
'logout',
'signup',
'tos',
'privacy',
'about',
'jobs',
'download',
'hashtag',
'intent',
'share',
'account',
'oauth',
'welcome',
'who_to_follow',
'connect_people',
'topics',
'trends',
'x',
'help',
'support',
'developer',
'live',
'lists',
'communities',
'spaces',
'premium',
'verified',
'verified-choose',
'creator'
]);
function isProfilePage(urlOrPath) {
const path = (urlOrPath || (typeof window !== 'undefined' ? window.location.pathname : ''))
.replace(/^https?:\/\/[^\/]+/, '')
.split('?')[0]
.split('#')[0];
const segments = path.split('/').filter(Boolean);
if (segments.length === 0) return false;
const firstSegment = segments[0].toLowerCase();
if (RESERVED_ROUTES.has(firstSegment)) return false;
// Twitter username format: 1-15 characters, alphanumeric + underscore
if (!/^[a-z0-9_]{1,15}$/.test(firstSegment)) return false;
// A user profile page is /${username} or /${username}/${profileTab}
// E.g., /elonmusk, /elonmusk/with_replies, /elonmusk/highlights, /elonmusk/media, etc.
// Tweet status pages (/status/...) or deeper paths are excluded.
if (segments.length === 1) {
return true;
}
if (segments.length === 2 && segments[1].toLowerCase() !== 'status') {
return true;
}
return false;
}
function removeAllFollowIcons() {
const containers = document.querySelectorAll('.x-followflow-container');
for (const container of containers) {
if (container._unsubscribe) {
try {
container._unsubscribe();
} catch (e) {}
}
container.remove();
}
}
// High-Recognition Crisp SVG Icons (+ / • / −) without outer borders
const ICONS = {
// Pure, sleek Plus (+) for Follow
follow: `
`,
// Subtle, minimalist micro-dot (•) for Following (soft and low-distraction)
following: `
`,
// Pure, sleek Minus (−) for Confirming Unfollow
unfollowConfirm: `
`,
// Native-like Spinner
loading: `
`,
// Error indicator
failed: `
`,
// Pending clock
pending: `
`
};
// ==========================================
// 1. Follow State Manager (Single Source of Truth)
// ==========================================
class FollowStateManager {
constructor() {
this.cache = new Map();
this.listeners = new Set();
}
get(username) {
if (!username) return null;
return this.cache.get(username.toLowerCase()) || null;
}
set(username, state) {
if (!username) return;
const lower = username.toLowerCase();
const prev = this.cache.get(lower) || {};
const updated = {
...prev,
...state,
username: lower,
updatedAt: Date.now()
};
this.cache.set(lower, updated);
this.notify(lower, updated);
return updated;
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
notify(username, state) {
for (const listener of this.listeners) {
try {
listener(username, state);
} catch (err) {
console.warn('[X-FollowFlow] Error in listener:', err);
}
}
}
}
const stateManager = new FollowStateManager();
// ==========================================
// 2. Comprehensive Network Interceptor (Fetch + XHR)
// ==========================================
function extractUsersFromData(data) {
if (!data || typeof data !== 'object') return;
const isUserObj = data.__typename === 'User' || (data.rest_id && (data.legacy || data.relationship_perspectives));
if (isUserObj) {
const legacy = data.legacy || {};
const screenName = (legacy.screen_name || data.screen_name || '').toLowerCase();
if (screenName) {
const existing = stateManager.get(screenName) || {};
let followingState = existing.following;
if (data.relationship_perspectives && data.relationship_perspectives.following !== undefined) {
followingState = Boolean(data.relationship_perspectives.following);
} else if (legacy.following !== undefined) {
followingState = Boolean(legacy.following);
} else if (data.following !== undefined) {
followingState = Boolean(data.following);
} else if (data.is_following !== undefined) {
followingState = Boolean(data.is_following);
}
let pendingState = existing.pending;
if (data.relationship_perspectives && data.relationship_perspectives.following_requested !== undefined) {
pendingState = Boolean(data.relationship_perspectives.following_requested);
} else if (legacy.following_requested !== undefined) {
pendingState = Boolean(legacy.following_requested);
} else if (data.following_requested !== undefined) {
pendingState = Boolean(data.following_requested);
}
stateManager.set(screenName, {
username: screenName,
restId: String(data.rest_id || legacy.id_str || existing.restId || ''),
following: followingState !== undefined ? Boolean(followingState) : false,
pending: pendingState !== undefined ? Boolean(pendingState) : false,
followedBy: legacy.followed_by !== undefined ? Boolean(legacy.followed_by) : (existing.followedBy || false)
});
}
} else if (data.__typename === 'UserWithVisibilityResults' && data.user) {
extractUsersFromData(data.user);
}
if (Array.isArray(data)) {
for (const item of data) {
extractUsersFromData(item);
}
} else {
for (const key of Object.keys(data)) {
if (typeof data[key] === 'object' && data[key] !== null) {
extractUsersFromData(data[key]);
}
}
}
}
function initNetworkInterceptor() {
const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
if (!win || win.__x_follow_network_hooked) return;
win.__x_follow_network_hooked = true;
if (win.__INITIAL_STATE__) {
try {
extractUsersFromData(win.__INITIAL_STATE__);
} catch (e) {}
}
// 1. Hook Fetch
const originalFetch = win.fetch;
if (originalFetch) {
win.fetch = async function (...args) {
const response = await originalFetch.apply(this, args);
try {
const url = String(args[0]?.url || args[0] || '');
if (url.includes('/graphql') || url.includes('/friendships') || url.includes('/i/api/')) {
const clone = response.clone();
clone.json().then(data => {
if (data) extractUsersFromData(data);
}).catch(() => {});
}
} catch (err) {}
return response;
};
}
// 2. Hook XMLHttpRequest
const originalOpen = win.XMLHttpRequest?.prototype?.open;
const originalSend = win.XMLHttpRequest?.prototype?.send;
if (originalOpen && originalSend) {
win.XMLHttpRequest.prototype.open = function (...args) {
this._x_url = String(args[1] || '');
return originalOpen.apply(this, args);
};
win.XMLHttpRequest.prototype.send = function (...args) {
this.addEventListener('load', function () {
try {
if (this._x_url && (this._x_url.includes('/graphql') || this._x_url.includes('/friendships') || this._x_url.includes('/i/api/'))) {
const data = JSON.parse(this.responseText);
if (data) extractUsersFromData(data);
}
} catch (e) {}
});
return originalSend.apply(this, args);
};
}
}
// ==========================================
// 3. React Tree & DOM Inspection (Target-Specific)
// ==========================================
let cachedCurrentUser = null;
function getCurrentUser() {
if (cachedCurrentUser) return cachedCurrentUser;
const profileLink = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
if (profileLink) {
const href = profileLink.getAttribute('href');
if (href && href.startsWith('/')) {
const user = href.slice(1).split('/')[0].split('?')[0].toLowerCase();
if (user && user !== 'profile') {
cachedCurrentUser = user;
return user;
}
}
}
const switcher = document.querySelector('div[data-testid="SideNav_AccountSwitcher_Button"]');
if (switcher) {
const text = switcher.textContent || '';
const match = text.match(/@([A-Za-z0-9_]{1,15})/);
if (match) {
cachedCurrentUser = match[1].toLowerCase();
return cachedCurrentUser;
}
}
return null;
}
function isFollowingTabActive() {
const activeTab = document.querySelector('div[role="tablist"] [role="tab"][aria-selected="true"]');
if (activeTab) {
const text = activeTab.textContent.trim().toLowerCase();
if (text.includes('following') || text.includes('正在关注')) {
return true;
}
}
return false;
}
/**
* Retweets / liked / followed-you contexts surface another user's tweet.
* The timeline author is NOT necessarily someone you follow — even on Following tab.
*/
function hasTweetSocialContext(tweetArticle) {
if (!tweetArticle || typeof tweetArticle.querySelector !== 'function') return false;
if (tweetArticle.querySelector('[data-testid="socialContext"]')) return true;
// Fallback when testid is missing: scan near the author header only (avoid post body)
const userName = tweetArticle.querySelector('[data-testid="User-Name"]');
const scanRoot = userName?.parentElement?.parentElement || userName?.parentElement;
if (!scanRoot) return false;
const text = (scanRoot.textContent || '').toLowerCase();
const markers = [
'reposted', 'retweeted', 'liked',
'转推了', '已转帖', '转帖了', '喜欢了',
'reposteado', 'a retweeté', 'がリポスト'
];
return markers.some((m) => text.includes(m));
}
/**
* Following-tab heuristic is UI-only and never authoritative.
* Skip entirely for social-context tweets (repost of unfollowed author, etc.).
*/
function shouldApplyFollowingTabHint(tweetArticle, followingTabActive) {
const onFollowingTab =
typeof followingTabActive === 'boolean' ? followingTabActive : isFollowingTabActive();
return onFollowingTab && !hasTweetSocialContext(tweetArticle);
}
function extractUsernameFromTweet(tweetArticle) {
const userNameEl = tweetArticle.querySelector('div[data-testid="User-Name"]');
if (!userNameEl) return null;
const links = userNameEl.querySelectorAll('a[role="link"][href^="/"]');
for (const link of links) {
const href = link.getAttribute('href') || '';
const path = href.replace(/^https?:\/\/[^\/]+/, '').split('?')[0].split('#')[0];
const match = path.match(/^\/([A-Za-z0-9_]{1,15})$/);
if (match) {
const candidate = match[1].toLowerCase();
if (!RESERVED_ROUTES.has(candidate)) {
return candidate;
}
}
}
const text = userNameEl.textContent || '';
const handleMatch = text.match(/@([A-Za-z0-9_]{1,15})/);
if (handleMatch) {
return handleMatch[1].toLowerCase();
}
return null;
}
function searchReactTreeForUser(obj, targetAuthor, depth = 0, visited = new WeakSet()) {
if (!obj || typeof obj !== 'object' || depth > 10) return null;
if (visited.has(obj)) return null;
visited.add(obj);
const target = (targetAuthor || '').toLowerCase();
const legacy = obj.legacy;
const rel = obj.relationship_perspectives;
const screenName = (legacy?.screen_name || obj.screen_name || '').toLowerCase();
if (screenName && screenName === target) {
let following = undefined;
if (rel && rel.following !== undefined) {
following = Boolean(rel.following);
} else if (legacy && legacy.following !== undefined) {
following = Boolean(legacy.following);
} else if (obj.following !== undefined) {
following = Boolean(obj.following);
} else if (obj.is_following !== undefined) {
following = Boolean(obj.is_following);
}
if (following !== undefined) {
return {
username: screenName,
following,
pending: Boolean(rel?.following_requested || legacy?.following_requested || obj.following_requested),
restId: String(obj.rest_id || legacy?.id_str || '')
};
}
}
if (Array.isArray(obj)) {
for (const item of obj) {
if (item && typeof item === 'object') {
const text = String(item.text || item.title || item.label || '').toLowerCase();
if (text.includes(`@${target}`)) {
if (text.includes('unfollow') || text.includes('取消关注')) {
return { username: target, following: true };
}
if (text.includes('follow') || text.includes('关注')) {
return { username: target, following: false };
}
}
}
}
}
for (const key of Object.keys(obj)) {
if (key === 'children' && depth > 3) continue;
if (typeof obj[key] === 'object' && obj[key] !== null) {
const found = searchReactTreeForUser(obj[key], target, depth + 1, visited);
if (found) return found;
}
}
return null;
}
function inspectFollowState(tweetArticle, author) {
if (!tweetArticle || !author) return null;
const authorLinks = tweetArticle.querySelectorAll(`a[href="/${author}" i], a[href^="/${author}?" i]`);
const caret = tweetArticle.querySelector('button[data-testid="caret"]');
const avatarEl = tweetArticle.querySelector('div[data-testid="Tweet-User-Avatar"]');
const userNameEl = tweetArticle.querySelector('div[data-testid="User-Name"]');
const elements = [...authorLinks, caret, avatarEl, userNameEl, tweetArticle].filter(Boolean);
for (const el of elements) {
const propsKey = Object.keys(el).find(k => k.startsWith('__reactProps$'));
if (propsKey && el[propsKey]) {
const found = searchReactTreeForUser(el[propsKey], author);
if (found) return found;
}
const fiberKey = Object.keys(el).find(k => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'));
if (fiberKey && el[fiberKey]) {
let fiber = el[fiberKey];
let depth = 0;
while (fiber && depth < 25) {
if (fiber.memoizedProps) {
const found = searchReactTreeForUser(fiber.memoizedProps, author);
if (found) return found;
}
if (fiber.memoizedState) {
const found = searchReactTreeForUser(fiber.memoizedState, author);
if (found) return found;
}
fiber = fiber.return;
depth++;
}
}
}
return null;
}
/**
* Resolve follow state from a HoverCard.
* Prefer data-testid buttons. Never use whole-card text — labels like
* "Following" / "正在关注" (follower stats) caused false "already following".
*/
function resolveHoverCardFollowState(hoverCard) {
if (!hoverCard) return null;
const unfollowBtn = hoverCard.querySelector('[data-testid$="-unfollow"]');
if (unfollowBtn) return true;
// "-follow" does not match "-unfollow" (suffix check), but guard anyway
const followBtns = hoverCard.querySelectorAll('[data-testid]');
for (const el of followBtns) {
const id = el.getAttribute('data-testid') || '';
if (id.endsWith('-follow') && !id.endsWith('-unfollow')) {
return false;
}
}
// Fallback: exact button label only (not stats links / whole card text)
const FOLLOWING_LABELS = new Set([
'following', '正在关注', 'unfollow', '取消关注',
'seguindo', 'abonné', 'フォロー中'
]);
const NOT_FOLLOWING_LABELS = new Set([
'follow', '关注', 'follow back', '回关',
'seguir', 'suivre', 'フォロー'
]);
const buttons = hoverCard.querySelectorAll('[role="button"], button');
for (const btn of buttons) {
const label = (btn.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase();
if (!label) continue;
if (FOLLOWING_LABELS.has(label)) return true;
if (NOT_FOLLOWING_LABELS.has(label)) return false;
}
return null;
}
function initHoverCardObserver() {
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
const hoverCard = node.matches && (node.matches('[data-testid="HoverCard"]') ? node : node.querySelector('[data-testid="HoverCard"]'));
if (hoverCard) {
const userLink = hoverCard.querySelector('a[href^="/"][role="link"]');
const href = userLink?.getAttribute('href') || '';
const match = href.match(/^\/([A-Za-z0-9_]{1,15})/);
if (match) {
const username = match[1].toLowerCase();
const following = resolveHoverCardFollowState(hoverCard);
if (following !== null) {
stateManager.set(username, { following, pending: false });
}
}
}
}
}
}
});
observer.observe(document.body || document.documentElement, {
childList: true,
subtree: true
});
}
async function peekCaretFollowState(tweetElement, username) {
const caret = tweetElement.querySelector('button[data-testid="caret"]');
if (!caret) return null;
document.body.classList.add('x-follow-silent-mode');
try {
triggerClick(caret);
let menu = null;
const start = Date.now();
while (Date.now() - start < 200) {
menu = document.querySelector('div[role="menu"], div[data-testid="Dropdown"]');
if (menu) break;
await sleep(20);
}
let result = null;
if (menu) {
const text = (menu.textContent || '').toLowerCase();
if (text.includes(`@${username}`)) {
if (text.includes('unfollow') || text.includes('取消关注')) {
result = true;
} else if (text.includes('follow') || text.includes('关注')) {
result = false;
}
}
triggerClick(caret);
}
document.body.classList.remove('x-follow-silent-mode');
return result;
} catch (e) {
document.body.classList.remove('x-follow-silent-mode');
return null;
}
}
// ==========================================
// 4. Action Executor (Native Caret + API Fallback)
// ==========================================
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function getCookie(name) {
const match = document.cookie.match(new RegExp('(^|;\\s*)(' + name + ')=([^;]*)'));
return match ? decodeURIComponent(match[3]) : null;
}
function triggerClick(element) {
if (!element) return;
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
element.click();
}
async function executeActionViaCaret(tweetElement, username, isFollow) {
const caret = tweetElement.querySelector('button[data-testid="caret"]');
if (!caret) return false;
document.body.classList.add('x-follow-silent-mode');
try {
triggerClick(caret);
let menu = null;
const start = Date.now();
while (Date.now() - start < CONFIG.maxCaretWaitMs) {
menu = document.querySelector('div[role="menu"], div[data-testid="Dropdown"]');
if (menu) break;
await sleep(40);
}
if (!menu) {
document.body.classList.remove('x-follow-silent-mode');
return false;
}
const menuItems = Array.from(menu.querySelectorAll('[role="menuitem"], div[tabindex="0"]'));
let targetItem = null;
for (const item of menuItems) {
const text = (item.textContent || '').toLowerCase();
const containsHandle = text.includes(`@${username}`);
if (isFollow) {
const isFollowText = text.includes('follow') || text.includes('关注') || text.includes('seguir') || text.includes('suivre') || text.includes('フォロー');
const isExcluded = text.includes('unfollow') || text.includes('取消关注') || text.includes('mute') || text.includes('block') || text.includes('静音') || text.includes('屏蔽') || text.includes('list');
if ((containsHandle && isFollowText && !isExcluded) || (isFollowText && !isExcluded)) {
targetItem = item;
break;
}
} else {
const isUnfollowText = text.includes('unfollow') || text.includes('取消关注') || text.includes('dejar de seguir') || text.includes('ne plus suivre') || text.includes('フォロー解除');
if (isUnfollowText) {
targetItem = item;
break;
}
}
}
if (!targetItem) {
triggerClick(caret);
document.body.classList.remove('x-follow-silent-mode');
return false;
}
triggerClick(targetItem);
if (!isFollow) {
const confirmStart = Date.now();
while (Date.now() - confirmStart < CONFIG.maxCaretWaitMs) {
const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
if (confirmBtn) {
triggerClick(confirmBtn);
break;
}
await sleep(40);
}
}
await sleep(100);
document.body.classList.remove('x-follow-silent-mode');
return true;
} catch (err) {
document.body.classList.remove('x-follow-silent-mode');
return false;
}
}
async function executeActionViaAPI(username, isFollow, restId) {
const ct0 = getCookie('ct0');
if (!ct0) return false;
const endpoint = isFollow
? 'https://x.com/i/api/1.1/friendships/create.json'
: 'https://x.com/i/api/1.1/friendships/destroy.json';
const params = new URLSearchParams();
if (restId) {
params.append('user_id', restId);
}
params.append('screen_name', username);
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
'x-csrf-token': ct0,
'authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
'x-twitter-active-user': 'yes',
'x-twitter-auth-type': 'OAuth2Session'
},
body: params.toString(),
credentials: 'include'
});
return response.ok;
} catch (e) {
return false;
}
}
async function executeFollowToggle(tweetElement, username, targetFollowState) {
const isFollow = targetFollowState === true;
const cached = stateManager.get(username);
const restId = cached?.restId || '';
const caretSuccess = await executeActionViaCaret(tweetElement, username, isFollow);
if (caretSuccess) {
stateManager.set(username, { following: isFollow, pending: false });
return true;
}
const apiSuccess = await executeActionViaAPI(username, isFollow, restId);
if (apiSuccess) {
stateManager.set(username, { following: isFollow, pending: false });
return true;
}
return false;
}
// ==========================================
// 5. UI Component (Border-Free Native Icon Style)
// ==========================================
function injectStyles() {
if (document.getElementById('x-followflow-styles')) return;
const style = document.createElement('style');
style.id = 'x-followflow-styles';
style.textContent = `
body.x-follow-silent-mode div[role="menu"],
body.x-follow-silent-mode div[data-testid="Dropdown"],
body.x-follow-silent-mode div[data-testid="confirmationSheetDialog"],
body.x-follow-silent-mode div[data-testid="sheetDialog"],
body.x-follow-silent-mode div[data-testid="mask"] {
opacity: 0 !important;
pointer-events: auto !important;
transition: none !important;
}
/* Container inside X action bar (div[role="group"]) */
.x-followflow-container {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
user-select: none;
}
/* Base Icon Button: Exactly matching native action bar button (no outer border) */
.x-followflow-icon-btn {
background: transparent;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
outline: none;
display: inline-flex;
align-items: center;
justify-content: center;
color: #71767b;
transition: color 0.15s ease;
position: relative;
}
/* Circular hover background matches Twitter's 34x34px round hover button */
.x-followflow-icon-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9999px;
background: transparent;
border: none;
transition: background-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
}
.x-followflow-icon-wrapper svg {
display: block;
}
/* NOT_FOLLOWING (+): Neutral gray icon, Twitter blue hover circle */
.x-followflow-icon-btn.x-state-follow {
color: #71767b;
}
.x-followflow-icon-btn.x-state-follow:hover .x-followflow-icon-wrapper {
background-color: rgba(29, 155, 240, 0.1);
color: #1d9bf0;
}
.x-followflow-icon-btn.x-state-follow:hover {
color: #1d9bf0;
}
/* FOLLOWING (•): Soft, low-contrast dot that gracefully recedes into the background */
.x-followflow-icon-btn.x-state-following {
color: #71767b;
}
.x-followflow-icon-btn.x-state-following .x-icon-normal {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
opacity: 0.5;
transition: opacity 0.15s ease;
}
.x-followflow-icon-btn.x-state-following .x-icon-hover {
display: none;
align-items: center;
justify-content: center;
line-height: 0;
}
.x-followflow-icon-btn.x-state-following:hover .x-icon-normal {
display: none;
}
.x-followflow-icon-btn.x-state-following:hover .x-icon-hover {
display: inline-flex;
}
.x-followflow-icon-btn.x-state-following:hover .x-followflow-icon-wrapper {
background-color: rgba(244, 33, 46, 0.1);
color: #f4212e;
}
.x-followflow-icon-btn.x-state-following:hover {
color: #f4212e;
}
/* LOADING SPINNER */
.x-followflow-icon-btn.x-state-loading {
cursor: wait;
opacity: 0.7;
pointer-events: none;
}
.x-follow-spinner {
animation: x-spin 0.85s linear infinite;
}
@keyframes x-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* FAILED */
.x-followflow-icon-btn.x-state-failed {
color: #f4212e;
}
.x-followflow-icon-btn.x-state-failed .x-followflow-icon-wrapper {
background-color: rgba(244, 33, 46, 0.15);
}
/* PENDING */
.x-followflow-icon-btn.x-state-pending {
color: #71767b;
}
`;
(document.head || document.documentElement).appendChild(style);
}
function createFollowIconButton(tweetElement, username, initialFollowing, initialPending) {
const container = document.createElement('div');
container.className = 'x-followflow-container';
container.setAttribute('data-x-author', username);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'x-followflow-icon-btn';
const wrapper = document.createElement('div');
wrapper.className = 'x-followflow-icon-wrapper';
btn.appendChild(wrapper);
container.appendChild(btn);
let currentState = initialPending ? 'PENDING' : (initialFollowing ? 'FOLLOWING' : 'NOT_FOLLOWING');
function renderUI() {
btn.className = 'x-followflow-icon-btn';
wrapper.innerHTML = '';
if (currentState === 'NOT_FOLLOWING') {
btn.classList.add('x-state-follow');
wrapper.innerHTML = ICONS.follow;
btn.title = `+ 关注 @${username}`;
} else if (currentState === 'FOLLOWING') {
btn.classList.add('x-state-following');
// Normal: Micro-dot (•); Hover: Minus (−). Click while hovered (−) unfollows immediately.
const normalSpan = document.createElement('span');
normalSpan.className = 'x-icon-normal';
normalSpan.innerHTML = ICONS.following;
const hoverSpan = document.createElement('span');
hoverSpan.className = 'x-icon-hover';
hoverSpan.innerHTML = ICONS.unfollowConfirm;
wrapper.appendChild(normalSpan);
wrapper.appendChild(hoverSpan);
btn.title = `● 已关注 @${username}(悬停显示 −,点击取消关注)`;
} else if (currentState === 'LOADING') {
btn.classList.add('x-state-loading');
wrapper.innerHTML = ICONS.loading;
btn.title = '处理中...';
} else if (currentState === 'FAILED') {
btn.classList.add('x-state-failed');
wrapper.innerHTML = ICONS.failed;
btn.title = '操作失败';
} else if (currentState === 'PENDING') {
btn.classList.add('x-state-pending');
wrapper.innerHTML = ICONS.pending;
btn.title = '已发送关注请求 (等待通过)';
}
}
renderUI();
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
if (currentState === 'NOT_FOLLOWING') {
currentState = 'LOADING';
renderUI();
const success = await executeFollowToggle(tweetElement, username, true);
if (success) {
currentState = 'FOLLOWING';
} else {
currentState = 'FAILED';
renderUI();
setTimeout(() => {
if (currentState === 'FAILED') {
currentState = 'NOT_FOLLOWING';
renderUI();
}
}, 1500);
return;
}
renderUI();
} else if (currentState === 'FOLLOWING') {
// Hover already reveals − as the affordance; one click unfollows immediately.
currentState = 'LOADING';
renderUI();
const success = await executeFollowToggle(tweetElement, username, false);
if (success) {
currentState = 'NOT_FOLLOWING';
} else {
currentState = 'FAILED';
renderUI();
setTimeout(() => {
if (currentState === 'FAILED') {
currentState = 'FOLLOWING';
renderUI();
}
}, 1500);
return;
}
renderUI();
}
});
btn.addEventListener('mousedown', (e) => e.stopPropagation());
btn.addEventListener('mouseup', (e) => e.stopPropagation());
const unsubscribe = stateManager.subscribe((changedUsername, state) => {
if (changedUsername !== username) return;
if (currentState === 'LOADING') return;
if (state.pending) {
currentState = 'PENDING';
} else if (state.following) {
currentState = 'FOLLOWING';
} else {
currentState = 'NOT_FOLLOWING';
}
renderUI();
});
container._unsubscribe = unsubscribe;
container._updateState = (following, pending) => {
if (currentState === 'LOADING') return;
if (pending) currentState = 'PENDING';
else if (following) currentState = 'FOLLOWING';
else currentState = 'NOT_FOLLOWING';
renderUI();
};
return container;
}
// ==========================================
// 6. Placement (Directly to the RIGHT of Share)
// ==========================================
function insertFollowIcon(tweetArticle, buttonContainer) {
// 1. Primary target: div[role="group"] (Bottom action bar) directly to the RIGHT of Share button
const actionBar = tweetArticle.querySelector('div[role="group"]');
if (actionBar) {
// Find Share button
const share = actionBar.querySelector('button[data-testid="share"], button[data-testid*="share" i], [aria-label*="share" i], [aria-label*="分享" i]');
if (share) {
let sWrapper = share;
while (sWrapper.parentElement && sWrapper.parentElement !== actionBar) {
sWrapper = sWrapper.parentElement;
}
if (sWrapper.parentElement === actionBar) {
// Insert directly to the RIGHT of the Share wrapper!
if (sWrapper.nextSibling) {
actionBar.insertBefore(buttonContainer, sWrapper.nextSibling);
} else {
actionBar.appendChild(buttonContainer);
}
return;
}
}
// Fallback 1: check Bookmark button (insert to its right)
const bookmark = actionBar.querySelector('button[data-testid="bookmark"], button[data-testid="removeBookmark"], [aria-label*="bookmark" i], [aria-label*="书签" i]');
if (bookmark) {
let bWrapper = bookmark;
while (bWrapper.parentElement && bWrapper.parentElement !== actionBar) {
bWrapper = bWrapper.parentElement;
}
if (bWrapper.parentElement === actionBar) {
if (bWrapper.nextSibling) {
actionBar.insertBefore(buttonContainer, bWrapper.nextSibling);
} else {
actionBar.appendChild(buttonContainer);
}
return;
}
}
// Fallback 2: check Grok button (insert to its right)
const grokBtn = actionBar.querySelector('button[aria-label*="grok" i], [data-testid*="grok" i]');
if (grokBtn) {
let gWrapper = grokBtn;
while (gWrapper.parentElement && gWrapper.parentElement !== actionBar) {
gWrapper = gWrapper.parentElement;
}
if (gWrapper.parentElement === actionBar) {
if (gWrapper.nextSibling) {
actionBar.insertBefore(buttonContainer, gWrapper.nextSibling);
} else {
actionBar.appendChild(buttonContainer);
}
return;
}
}
// Fallback 3: append to end of action bar (rightmost position)
actionBar.appendChild(buttonContainer);
return;
}
// 2. Fallback to User-Name area if action bar not yet mounted
const userNameEl = tweetArticle.querySelector('div[data-testid="User-Name"]');
if (userNameEl) {
const caret = userNameEl.querySelector('button[data-testid="caret"]');
if (caret) {
let caretWrapper = caret;
while (caretWrapper.parentElement && caretWrapper.parentElement !== userNameEl) {
caretWrapper = caretWrapper.parentElement;
}
if (caretWrapper.parentElement === userNameEl) {
userNameEl.insertBefore(buttonContainer, caretWrapper);
return;
}
}
userNameEl.appendChild(buttonContainer);
}
}
async function processTweet(tweetArticle) {
if (!tweetArticle || !tweetArticle.isConnected) return;
if (isProfilePage()) {
const existing = tweetArticle.querySelector('.x-followflow-container');
if (existing) {
if (existing._unsubscribe) {
try { existing._unsubscribe(); } catch (e) {}
}
existing.remove();
}
return;
}
const author = extractUsernameFromTweet(tweetArticle);
if (!author) return;
const currentUser = getCurrentUser();
if (currentUser && author === currentUser) {
const existing = tweetArticle.querySelector('.x-followflow-container');
if (existing) existing.remove();
return;
}
const existingContainer = tweetArticle.querySelector('.x-followflow-container');
if (existingContainer) {
if (existingContainer.getAttribute('data-x-author') === author) {
return;
}
if (existingContainer._unsubscribe) existingContainer._unsubscribe();
existingContainer.remove();
}
// Follow state detection hierarchy
let following = false;
let pending = false;
let resolved = false;
// A. Check State Manager cache
const cached = stateManager.get(author);
if (cached) {
following = cached.following;
pending = cached.pending;
resolved = true;
}
// B. Check React Tree / Fiber for this exact author
if (!resolved) {
const inspected = inspectFollowState(tweetArticle, author);
if (inspected && inspected.following !== undefined) {
following = inspected.following;
pending = inspected.pending;
resolved = true;
stateManager.set(author, { following, pending, restId: inspected.restId });
}
}
// C. Following tab: weak UI hint ONLY — never mark resolved, never write cache.
// Retweets/social context must not assume the embedded author is followed.
if (!resolved && shouldApplyFollowingTabHint(tweetArticle)) {
following = true;
}
const buttonContainer = createFollowIconButton(tweetArticle, author, following, pending);
insertFollowIcon(tweetArticle, buttonContainer);
// D. If still not resolved with 100% confidence, run silent peek / re-inspect
if (!resolved) {
setTimeout(async () => {
if (!tweetArticle.isConnected) return;
let finalState = stateManager.get(author);
if (!finalState) {
const recheck = inspectFollowState(tweetArticle, author);
if (recheck && recheck.following !== undefined) {
finalState = recheck;
} else {
const peeked = await peekCaretFollowState(tweetArticle, author);
if (peeked !== null) {
finalState = { following: peeked, pending: false };
}
}
}
if (finalState && finalState.following !== undefined) {
stateManager.set(author, finalState);
if (buttonContainer._updateState) {
buttonContainer._updateState(finalState.following, finalState.pending);
}
}
}, 100);
}
}
let scanScheduled = false;
function scheduleScan() {
if (scanScheduled) return;
scanScheduled = true;
setTimeout(() => {
scanScheduled = false;
if (isProfilePage()) {
removeAllFollowIcons();
return;
}
const tweets = document.querySelectorAll('article[data-testid="tweet"]');
for (const tweet of tweets) {
processTweet(tweet);
}
}, CONFIG.scanDebounceMs);
}
function initObserver() {
const observer = new MutationObserver((mutations) => {
let shouldScan = false;
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches && (node.matches('article[data-testid="tweet"]') || node.querySelector('article[data-testid="tweet"]'))) {
shouldScan = true;
break;
}
}
}
}
if (shouldScan) break;
}
if (shouldScan) {
scheduleScan();
}
});
observer.observe(document.body || document.documentElement, {
childList: true,
subtree: true
});
}
// ==========================================
// 7. SPA Navigation Support
// ==========================================
function initSPAHandler() {
const handleUrlChange = () => {
cachedCurrentUser = null;
if (isProfilePage()) {
removeAllFollowIcons();
}
scheduleScan();
};
const originalPushState = history.pushState;
history.pushState = function (...args) {
const res = originalPushState.apply(this, args);
handleUrlChange();
return res;
};
const originalReplaceState = history.replaceState;
history.replaceState = function (...args) {
const res = originalReplaceState.apply(this, args);
handleUrlChange();
return res;
};
window.addEventListener('popstate', handleUrlChange);
}
// ==========================================
// 8. Bootstrap
// ==========================================
function bootstrap() {
initNetworkInterceptor();
initSPAHandler();
const startDOM = () => {
injectStyles();
initHoverCardObserver();
initObserver();
scheduleScan();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startDOM);
} else {
startDOM();
}
}
bootstrap();
})();