See where X users are located and what devices they use. Country flags & device icons next to every username. Optional geo-blocking.
// ==UserScript==
// @name X-Posed: Account Location & Device Info
// @namespace http://tampermonkey.net/
// @version 1.5.1
// @description See where X users are located and what devices they use. Country flags & device icons next to every username. Optional geo-blocking.
// @author Alexander Hagenah (@xaitax)
// @homepage https://github.com/xaitax/x-account-location-device
// @supportURL https://primepage.de
// @supportURL https://www.linkedin.com/in/alexhagenah/
// @license MIT
// @match *://*x.com/*
// @match *://*twitter.com/*
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
/**
* Configuration & Constants
*/
const CONFIG = {
VERSION: '1.5.1',
CACHE_KEY: 'x_location_cache_v3', // v3 includes locationAccurate field
BLOCKED_COUNTRIES_KEY: 'x_blocked_countries',
CACHE_EXPIRY: 48 * 60 * 60 * 1000, // 48 hours (extended from 24)
API: {
QUERY_ID: 'XRqGa7EeokUU5kppkh13EA', // AboutAccountQuery
MIN_INTERVAL: 2000,
MAX_CONCURRENT: 2,
RETRY_DELAY: 5000
},
SELECTORS: {
USERNAME: '[data-testid="UserName"], [data-testid="User-Name"]',
TWEET: 'article[data-testid="tweet"]',
USER_CELL: '[data-testid="UserCell"]',
LINKS: 'a[href^="/"]'
},
STYLES: {
SHIMMER_ID: 'x-flag-shimmer-style',
FLAG_CLASS: 'x-location-flag',
DEVICE_CLASS: 'x-device-indicator'
}
};
/**
* Country & Flag Data
* Optimized for O(1) lookup
*/
const COUNTRY_FLAGS = {
"afghanistan": "🇦🇫", "albania": "🇦🇱", "algeria": "🇩🇿", "andorra": "🇦🇩", "angola": "🇦🇴",
"antigua and barbuda": "🇦🇬", "argentina": "🇦🇷", "armenia": "🇦🇲", "australia": "🇦🇺", "austria": "🇦🇹",
"azerbaijan": "🇦🇿", "bahamas": "🇧🇸", "bahrain": "🇧🇭", "bangladesh": "🇧🇩", "barbados": "🇧🇧",
"belarus": "🇧🇾", "belgium": "🇧🇪", "belize": "🇧🇿", "benin": "🇧🇯", "bhutan": "🇧🇹",
"bolivia": "🇧🇴", "bosnia and herzegovina": "🇧🇦", "bosnia": "🇧🇦", "botswana": "🇧🇼", "brazil": "🇧🇷",
"brunei": "🇧🇳", "bulgaria": "🇧🇬", "burkina faso": "🇧🇫", "burundi": "🇧🇮", "cambodia": "🇰🇭",
"cameroon": "🇨🇲", "canada": "🇨🇦", "cape verde": "🇨🇻", "central african republic": "🇨🇫", "chad": "🇹🇩",
"chile": "🇨🇱", "china": "🇨🇳", "colombia": "🇨🇴", "comoros": "🇰🇲", "congo": "🇨🇬",
"costa rica": "🇨🇷", "croatia": "🇭🇷", "cuba": "🇨🇺", "cyprus": "🇨🇾", "czech republic": "🇨🇿",
"czechia": "🇨🇿", "democratic republic of the congo": "🇨🇩", "denmark": "🇩🇰", "djibouti": "🇩🇯", "dominica": "🇩🇲",
"dominican republic": "🇩🇴", "east timor": "🇹🇱", "ecuador": "🇪🇨", "egypt": "🇪🇬", "el salvador": "🇸🇻",
"england": "🏴", "equatorial guinea": "🇬🇶", "eritrea": "🇪🇷", "estonia": "🇪🇪", "eswatini": "🇸🇿",
"ethiopia": "🇪🇹", "europe": "🇪🇺", "european union": "🇪🇺", "fiji": "🇫🇯", "finland": "🇫🇮",
"france": "🇫🇷", "gabon": "🇬🇦", "gambia": "🇬🇲", "georgia": "🇬🇪", "germany": "🇩🇪",
"ghana": "🇬🇭", "greece": "🇬🇷", "grenada": "🇬🇩", "guatemala": "🇬🇹", "guinea": "🇬🇳",
"guinea-bissau": "🇬🇼", "guyana": "🇬🇾", "haiti": "🇭🇹", "honduras": "🇭🇳", "hong kong": "🇭🇰",
"hungary": "🇭🇺", "iceland": "🇮🇸", "india": "🇮🇳", "indonesia": "🇮🇩", "iran": "🇮🇷",
"iraq": "🇮🇶", "ireland": "🇮🇪", "israel": "🇮🇱", "italy": "🇮🇹", "ivory coast": "🇨🇮",
"jamaica": "🇯🇲", "japan": "🇯🇵", "jordan": "🇯🇴", "kazakhstan": "🇰🇿", "kenya": "🇰🇪",
"kiribati": "🇰🇮", "korea": "🇰🇷", "kosovo": "🇽🇰", "kuwait": "🇰🇼", "kyrgyzstan": "🇰🇬",
"laos": "🇱🇦", "latvia": "🇱🇻", "lebanon": "🇱🇧", "lesotho": "🇱🇸", "liberia": "🇱🇷",
"libya": "🇱🇾", "liechtenstein": "🇱🇮", "lithuania": "🇱🇹", "luxembourg": "🇱🇺", "macao": "🇲🇴",
"macau": "🇲🇴", "madagascar": "🇲🇬", "malawi": "🇲🇼", "malaysia": "🇲🇾", "maldives": "🇲🇻",
"mali": "🇲🇱", "malta": "🇲🇹", "marshall islands": "🇲🇭", "mauritania": "🇲🇷", "mauritius": "🇲🇺",
"mexico": "🇲🇽", "micronesia": "🇫🇲", "moldova": "🇲🇩", "monaco": "🇲🇨", "mongolia": "🇲🇳",
"montenegro": "🇲🇪", "morocco": "🇲🇦", "mozambique": "🇲🇿", "myanmar": "🇲🇲", "burma": "🇲🇲",
"namibia": "🇳🇦", "nauru": "🇳🇷", "nepal": "🇳🇵", "netherlands": "🇳🇱", "new zealand": "🇳🇿",
"nicaragua": "🇳🇮", "niger": "🇳🇪", "nigeria": "🇳🇬", "north korea": "🇰🇵", "north macedonia": "🇲🇰",
"macedonia": "🇲🇰", "norway": "🇳🇴", "oman": "🇴🇲", "pakistan": "🇵🇰", "palau": "🇵🇼",
"palestine": "🇵🇸", "panama": "🇵🇦", "papua new guinea": "🇵🇬", "paraguay": "🇵🇾", "peru": "🇵🇪",
"philippines": "🇵🇭", "poland": "🇵🇱", "portugal": "🇵🇹", "puerto rico": "🇵🇷", "qatar": "🇶🇦",
"romania": "🇷🇴", "russia": "🇷🇺", "russian federation": "🇷🇺", "rwanda": "🇷🇼", "saint kitts and nevis": "🇰🇳",
"saint lucia": "🇱🇨", "saint vincent and the grenadines": "🇻🇨", "samoa": "🇼🇸", "san marino": "🇸🇲", "sao tome and principe": "🇸🇹",
"saudi arabia": "🇸🇦", "scotland": "🏴", "senegal": "🇸🇳", "serbia": "🇷🇸", "seychelles": "🇸🇨",
"sierra leone": "🇸🇱", "singapore": "🇸🇬", "slovakia": "🇸🇰", "slovenia": "🇸🇮", "solomon islands": "🇸🇧",
"somalia": "🇸🇴", "south africa": "🇿🇦", "south korea": "🇰🇷", "south sudan": "🇸🇸", "spain": "🇪🇸",
"sri lanka": "🇱🇰", "sudan": "🇸🇩", "suriname": "🇸🇷", "sweden": "🇸🇪", "switzerland": "🇨🇭",
"syria": "🇸🇾", "taiwan": "🇹🇼", "tajikistan": "🇹🇯", "tanzania": "🇹🇿", "thailand": "🇹🇭",
"timor-leste": "🇹🇱", "togo": "🇹🇬", "tonga": "🇹🇴", "trinidad and tobago": "🇹🇹", "tunisia": "🇹🇳",
"turkey": "🇹🇷", "türkiye": "🇹🇷", "turkmenistan": "🇹🇲", "tuvalu": "🇹🇻", "uganda": "🇺🇬",
"ukraine": "🇺🇦", "united arab emirates": "🇦🇪", "uae": "🇦🇪", "united kingdom": "🇬🇧", "uk": "🇬🇧",
"great britain": "🇬🇧", "britain": "🇬🇧", "united states": "🇺🇸", "usa": "🇺🇸", "us": "🇺🇸",
"uruguay": "🇺🇾", "uzbekistan": "🇺🇿", "vanuatu": "🇻🇺", "vatican city": "🇻🇦", "venezuela": "🇻🇪",
"vietnam": "🇻🇳", "wales": "🏴", "yemen": "🇾🇪", "zambia": "🇿🇲", "zimbabwe": "🇿🇼"
};
/**
* Core Application Class
*/
class XLocationPatcher {
constructor() {
this.cache = new Map();
this.requestQueue = [];
this.activeRequests = 0;
this.lastRequestTime = 0;
this.rateLimitReset = 0;
this.headers = null;
this.processingSet = new Set();
this.fetchPromises = new Map(); // Track active promises
this.observer = null;
this.isEnabled = true;
this.blockedCountries = new Set();
this.init();
}
init() {
console.log(`🚀 X Account Location v${CONFIG.VERSION} initializing...`);
console.log(`📦 Cache expiry: ${CONFIG.CACHE_EXPIRY / 1000 / 60 / 60} hours`);
this.loadSettings();
this.loadCache();
this.loadBlockedCountries();
this.setupInterceptors();
this.exposeAPI();
// Inject styles and start observing when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
this.injectStyles();
this.injectSidebarLink();
this.startObserver();
});
} else {
this.injectStyles();
this.injectSidebarLink();
this.startObserver();
}
// Periodic cache save
setInterval(() => this.saveCache(), 30000);
}
/**
* Network Interception & Header Capture
*/
setupInterceptors() {
const self = this;
// Intercept Fetch
const originalFetch = window.fetch;
window.fetch = function(url, options) {
if (typeof url === 'string' && url.includes('x.com/i/api/graphql') && options?.headers) {
self.captureHeaders(options.headers);
}
return originalFetch.apply(this, arguments);
};
// Intercept XHR
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
const originalSetHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function(method, url) {
this._url = url;
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.setRequestHeader = function(header, value) {
if (!this._headers) this._headers = {};
this._headers[header] = value;
return originalSetHeader.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function() {
if (this._url?.includes('x.com/i/api/graphql') && this._headers) {
self.captureHeaders(this._headers);
}
return originalSend.apply(this, arguments);
};
}
captureHeaders(headers) {
if (this.headers) return; // Already captured
const headerObj = headers instanceof Headers ? Object.fromEntries(headers.entries()) : headers;
// Validate we have auth headers
if (headerObj.authorization || headerObj['authorization']) {
this.headers = headerObj;
console.log('✅ X API Headers captured successfully');
}
}
getFallbackHeaders() {
const cookies = document.cookie.split('; ').reduce((acc, cookie) => {
const [key, value] = cookie.split('=');
acc[key] = value;
return acc;
}, {});
if (!cookies.ct0) return null;
return {
'authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
'x-csrf-token': cookies.ct0,
'x-twitter-active-user': 'yes',
'x-twitter-auth-type': 'OAuthSession',
'content-type': 'application/json'
};
}
/**
* Data Management
*/
loadSettings() {
try {
const stored = localStorage.getItem('x_location_enabled');
this.isEnabled = stored !== null ? JSON.parse(stored) : true;
} catch (e) {
console.error('Failed to load settings', e);
}
}
loadBlockedCountries() {
try {
const stored = localStorage.getItem(CONFIG.BLOCKED_COUNTRIES_KEY);
if (stored) {
this.blockedCountries = new Set(JSON.parse(stored));
console.log(`🚫 Loaded ${this.blockedCountries.size} blocked countries`);
}
} catch (e) {
console.error('Failed to load blocked countries', e);
this.blockedCountries = new Set();
}
}
saveBlockedCountries() {
try {
const array = Array.from(this.blockedCountries);
localStorage.setItem(CONFIG.BLOCKED_COUNTRIES_KEY, JSON.stringify(array));
console.log(`💾 Saved ${array.length} blocked countries`);
} catch (e) {
console.error('Failed to save blocked countries', e);
}
}
loadCache() {
try {
const raw = localStorage.getItem(CONFIG.CACHE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
const now = Date.now();
let count = 0;
Object.entries(parsed).forEach(([key, data]) => {
if (data.expiry > now) {
this.cache.set(key, data.value);
count++;
}
});
console.log(`📦 Loaded ${count} cached entries`);
} catch (e) {
console.error('Cache load failed', e);
localStorage.removeItem(CONFIG.CACHE_KEY);
}
}
saveCache() {
try {
const now = Date.now();
const expiry = now + CONFIG.CACHE_EXPIRY;
const exportData = {};
this.cache.forEach((value, key) => {
exportData[key] = { value, expiry };
});
localStorage.setItem(CONFIG.CACHE_KEY, JSON.stringify(exportData));
} catch (e) {
console.error('Cache save failed', e);
}
}
/**
* API Interaction
*/
async fetchUserInfo(screenName) {
// 1. Check cache
if (this.cache.has(screenName)) {
return this.cache.get(screenName);
}
// 2. Check active promises (deduplication)
if (this.fetchPromises.has(screenName)) {
return this.fetchPromises.get(screenName);
}
// 3. Create new promise and queue request
const promise = new Promise((resolve, reject) => {
this.requestQueue.push({ screenName, resolve, reject });
this.processQueue();
}).then(result => {
this.fetchPromises.delete(screenName);
return result;
}).catch(error => {
this.fetchPromises.delete(screenName);
throw error;
});
this.fetchPromises.set(screenName, promise);
return promise;
}
async processQueue() {
if (this.activeRequests >= CONFIG.API.MAX_CONCURRENT || this.requestQueue.length === 0) return;
// Rate limit check
const now = Date.now();
if (this.rateLimitReset > now) {
const wait = this.rateLimitReset - now;
setTimeout(() => this.processQueue(), Math.min(wait, 60000));
return;
}
const timeSinceLast = now - this.lastRequestTime;
if (timeSinceLast < CONFIG.API.MIN_INTERVAL) {
setTimeout(() => this.processQueue(), CONFIG.API.MIN_INTERVAL - timeSinceLast);
return;
}
// Execute request
const request = this.requestQueue.shift();
this.activeRequests++;
this.lastRequestTime = Date.now();
try {
console.debug(`📡 API Request for: ${request.screenName}`);
const result = await this.executeApiCall(request.screenName);
this.cache.set(request.screenName, result);
request.resolve(result);
} catch (error) {
console.warn(`❌ API Error for ${request.screenName}:`, error.message);
request.reject(error);
} finally {
this.activeRequests--;
this.processQueue();
}
}
async executeApiCall(screenName) {
let headers = this.headers;
if (!headers) {
// Try fallback
headers = this.getFallbackHeaders();
if (!headers) {
// Wait for headers
await new Promise(r => setTimeout(r, 2000));
headers = this.headers || this.getFallbackHeaders();
if (!headers) throw new Error('No API headers captured');
} else {
console.log('⚠️ Using fallback headers');
}
}
const variables = encodeURIComponent(JSON.stringify({ screenName }));
const url = `https://x.com/i/api/graphql/${CONFIG.API.QUERY_ID}/AboutAccountQuery?variables=${variables}`;
const requestHeaders = { ...headers };
// Force English for consistent country names
requestHeaders['accept-language'] = 'en-US,en;q=0.9';
const response = await fetch(url, {
headers: requestHeaders,
method: 'GET',
mode: 'cors',
credentials: 'include'
});
if (!response.ok) {
if (response.status === 429) {
const reset = response.headers.get('x-rate-limit-reset');
this.rateLimitReset = reset ? parseInt(reset) * 1000 : Date.now() + 60000;
const waitMinutes = Math.ceil((this.rateLimitReset - Date.now()) / 60000);
console.warn(`⚠️ X API rate limit reached. Waiting ${waitMinutes} minute(s) before retrying...`);
throw new Error('Rate limited');
}
throw new Error(`API Error: ${response.status}`);
}
const data = await response.json();
const profile = data?.data?.user_result_by_screen_name?.result?.about_profile;
return {
location: profile?.account_based_in || null,
device: profile?.source || null,
locationAccurate: profile?.location_accurate !== false // Default to true if not present
};
}
/**
* UI & DOM Manipulation
*/
injectStyles() {
if (document.getElementById(CONFIG.STYLES.SHIMMER_ID)) return;
const style = document.createElement('style');
style.id = CONFIG.STYLES.SHIMMER_ID;
style.textContent = `
@keyframes x-shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
.x-flag-shimmer {
display: inline-block; width: 20px; height: 16px; margin: 0 4px; vertical-align: middle;
border-radius: 2px;
background: linear-gradient(90deg, rgba(113,118,123,0.2) 25%, rgba(113,118,123,0.4) 50%, rgba(113,118,123,0.2) 75%);
background-size: 200% 100%;
animation: x-shimmer 1.5s infinite;
}
.x-info-badge {
margin: 0 4px 0 8px; display: inline-flex; align-items: center; vertical-align: middle; gap: 4px;
font-family: "Twemoji Mozilla", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", "EmojiOne Color", "Android Emoji", sans-serif;
font-size: 14px; opacity: 0.8; transition: all 0.2s; cursor: help;
}
.x-info-badge:hover { opacity: 1; transform: scale(1.1); }
/* Country Blocker Modal Styles */
.x-blocker-modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.4); z-index: 999999;
display: flex; align-items: center; justify-content: center;
}
.x-blocker-modal {
background: rgb(0, 0, 0); border-radius: 16px;
max-width: 600px; width: 90%; max-height: 90vh;
overflow: hidden; display: flex; flex-direction: column;
box-shadow: 0 0 40px rgba(255,255,255,0.1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.x-blocker-header {
padding: 16px 20px; border-bottom: 1px solid rgb(47, 51, 54);
display: flex; align-items: center; justify-content: space-between;
}
.x-blocker-title {
font-size: 20px; font-weight: 700; color: rgb(231, 233, 234);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.x-blocker-close {
background: none; border: none; color: rgb(231, 233, 234);
cursor: pointer; padding: 8px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
transition: background 0.2s;
}
.x-blocker-close:hover { background: rgba(231, 233, 234, 0.1); }
.x-blocker-body {
padding: 20px; overflow-y: auto; flex: 1;
}
.x-blocker-info {
color: rgb(113, 118, 123); font-size: 14px; margin-bottom: 16px;
line-height: 1.5;
}
.x-blocker-search {
width: 100%; padding: 12px 16px; border-radius: 24px;
background: rgb(32, 35, 39); border: 1px solid rgb(47, 51, 54);
color: rgb(231, 233, 234); font-size: 15px; margin-bottom: 16px;
outline: none; box-sizing: border-box;
}
.x-blocker-search:focus { border-color: rgb(29, 155, 240); }
.x-blocker-countries {
display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 8px;
}
.x-country-item {
padding: 12px 16px; border-radius: 8px;
background: rgb(22, 24, 28); border: 1px solid rgb(47, 51, 54);
cursor: pointer; display: flex; align-items: center; gap: 12px;
transition: all 0.2s;
}
.x-country-item:hover { background: rgb(32, 35, 39); border-color: rgb(113, 118, 123); }
.x-country-item.blocked {
background: rgba(244, 33, 46, 0.1); border-color: rgb(244, 33, 46);
}
.x-country-flag {
font-size: 24px; line-height: 1;
font-family: "Twemoji Mozilla", "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
}
.x-country-name {
flex: 1; color: rgb(231, 233, 234); font-size: 15px;
}
.x-country-status {
font-size: 12px; color: rgb(244, 33, 46); font-weight: 600;
}
.x-blocker-footer {
padding: 16px 20px; border-top: 1px solid rgb(47, 51, 54);
display: flex; gap: 12px; justify-content: space-between;
align-items: center;
}
.x-blocker-stats {
color: rgb(113, 118, 123); font-size: 14px;
}
.x-blocker-btn {
padding: 10px 20px; border-radius: 24px; font-size: 15px;
font-weight: 600; cursor: pointer; transition: all 0.2s;
border: none;
}
.x-blocker-btn-primary {
background: rgb(29, 155, 240); color: white;
}
.x-blocker-btn-primary:hover { background: rgb(26, 140, 216); }
.x-blocker-btn-secondary {
background: transparent; color: rgb(239, 243, 244);
border: 1px solid rgb(83, 100, 113);
}
.x-blocker-btn-secondary:hover { background: rgba(239, 243, 244, 0.1); }
.x-tweet-blocked {
display: none !important;
}
`;
document.head.appendChild(style);
}
getFlagEmoji(countryName) {
if (!countryName) return null;
const emoji = COUNTRY_FLAGS[countryName.trim().toLowerCase()] || '🌍';
// Check if we are on Windows (which doesn't support flag emojis)
const isWindows = navigator.platform.indexOf('Win') > -1;
if (isWindows && emoji !== '🌍') {
// Convert emoji to Twemoji URL
const codePoints = Array.from(emoji)
.map(c => c.codePointAt(0).toString(16))
.join('-');
return `<img src="https://abs-0.twimg.com/emoji/v2/svg/${codePoints}.svg"
class="x-flag-emoji"
alt="${emoji}"
style="height: 1.2em; vertical-align: -0.2em;">`;
}
return emoji;
}
getDeviceEmoji(deviceString) {
if (!deviceString) return null;
const d = deviceString.toLowerCase();
// App stores are always mobile
if (d.includes('app store')) return '📱';
// Explicit mobile devices
if (d.includes('android') || d.includes('iphone') || d.includes('mobile')) return '📱';
// Tablets treated as computers
if (d.includes('ipad')) return '💻';
// Desktop OS
if (d.includes('mac') || d.includes('linux') || d.includes('windows')) return '💻';
// Web clients
if (d.includes('web')) return '🌐';
// Unknown = assume mobile (more common than desktop for unknown strings)
return '📱';
}
async processElement(element) {
// Skip if already processed
if (element.dataset.xProcessed) return;
const screenName = this.extractUsername(element);
if (!screenName) return;
// Mark as processed immediately to prevent duplicates
element.dataset.xProcessed = 'true';
// Store username for later reference
element.dataset.xScreenName = screenName;
try {
const info = await this.fetchUserInfo(screenName);
// Check if country is blocked FIRST before adding any UI
if (info && info.location) {
const countryLower = info.location.trim().toLowerCase();
if (this.blockedCountries.has(countryLower)) {
this.hideTweet(element);
return; // Exit early - don't add any badges/shimmers
}
}
// Only add UI elements if NOT blocked
const shimmer = document.createElement('span');
shimmer.className = 'x-flag-shimmer';
const insertionPoint = this.findInsertionPoint(element, screenName);
if (insertionPoint) insertionPoint.target.insertBefore(shimmer, insertionPoint.ref);
// Small delay for shimmer effect
await new Promise(resolve => setTimeout(resolve, 100));
shimmer.remove();
if (info && (info.location || info.device)) {
const badge = document.createElement('span');
badge.className = 'x-info-badge';
let content = '';
if (info.location) {
const flag = this.getFlagEmoji(info.location);
if (flag) content += `<span title="${info.location}">${flag}</span>`;
// Add VPN/Proxy indicator if location is not accurate
if (info.locationAccurate === false) {
content += `<span title="Location may not be accurate (VPN/Proxy detected)">🔒</span>`;
}
}
const device = info.device;
if (device) {
const emoji = this.getDeviceEmoji(device);
content += `<span title="Connected via: ${device}">${emoji}</span>`;
}
badge.innerHTML = content;
// Re-find insertion point as DOM might have changed
const finalPoint = this.findInsertionPoint(element, screenName);
if (finalPoint) finalPoint.target.insertBefore(badge, finalPoint.ref);
}
} catch (e) {
console.debug(`Failed to process ${screenName}`, e);
}
}
hideTweet(element) {
// Find the tweet article container
const tweet = element.closest('article[data-testid="tweet"]');
if (tweet) {
tweet.classList.add('x-tweet-blocked');
}
}
extractUsername(element) {
// 1. Try to find the username link (Timeline/Feed)
const link = element.querySelector('a[href^="/"]');
if (link) {
const href = link.getAttribute('href');
const match = href.match(/^\/([^/]+)$/);
if (match) {
const username = match[1];
const invalid = ['home', 'explore', 'notifications', 'messages', 'search', 'settings'];
if (!invalid.includes(username)) return username;
}
}
// 2. Profile Header Case (Username is text, not a link)
// Look for text starting with @
const textNodes = Array.from(element.querySelectorAll('span, div[dir="ltr"]'));
for (const node of textNodes) {
const text = node.textContent.trim();
if (text.startsWith('@') && text.length > 1) {
const username = text.substring(1);
// Basic validation to ensure it's a username and not just random text
if (/^[a-zA-Z0-9_]+$/.test(username)) {
return username;
}
}
}
return null;
}
findInsertionPoint(container, screenName) {
// 1. Profile Header Specific Logic
// The profile header has a specific structure where the name and handle are in separate rows
// We want to target the first row (Display Name)
// Check if this is likely a profile header (no timestamp link, large text)
const isProfileHeader = !container.querySelector('time') && container.querySelector('[data-testid="userFollowIndicator"]') !== null ||
(container.getAttribute('data-testid') === 'UserName' && container.className.includes('r-14gqq1x'));
if (isProfileHeader) {
// Find the display name container (first div[dir="ltr"])
const nameContainer = container.querySelector('div[dir="ltr"]');
if (nameContainer) {
// We want to append to this container, so the flag sits inline with the name/badge
// But we need to be careful not to break the flex layout if it exists
// The name container usually has spans inside. We want to insert after the last span.
const lastSpan = nameContainer.querySelector('span:last-child');
if (lastSpan) {
return { target: lastSpan.parentNode, ref: null }; // Append to end of name container
}
return { target: nameContainer, ref: null };
}
}
// 2. Timeline/Feed Case
// Look for the handle (@username)
const links = Array.from(container.querySelectorAll('a'));
const handleLink = links.find(l => l.textContent.trim().toLowerCase() === `@${screenName.toLowerCase()}`);
if (handleLink) {
// Insert after the handle
return { target: handleLink.parentNode.parentNode, ref: handleLink.parentNode.nextSibling };
}
// 3. Fallback: Try to find the name container via href
const nameLink = container.querySelector(`a[href="/${screenName}"]`);
if (nameLink) {
return { target: nameLink.parentNode, ref: nameLink.nextSibling };
}
return null;
}
startObserver() {
this.observer = new MutationObserver((mutations) => {
if (!this.isEnabled) return;
for (const m of mutations) {
m.addedNodes.forEach(node => {
if (node.nodeType === 1) { // Element
// Check if the node itself is a username
if (node.matches && node.matches(CONFIG.SELECTORS.USERNAME)) {
this.processElement(node);
}
// Check descendants
const elements = node.querySelectorAll(CONFIG.SELECTORS.USERNAME);
elements.forEach(el => this.processElement(el));
}
});
}
});
this.observer.observe(document.body, { childList: true, subtree: true });
this.scanPage(); // Initial scan
}
scanPage() {
const elements = document.querySelectorAll(CONFIG.SELECTORS.USERNAME);
elements.forEach(el => this.processElement(el));
}
/**
* Sidebar & Modal UI
*/
injectSidebarLink() {
// Wait for sidebar to load
const checkSidebar = setInterval(() => {
// Try multiple selectors to be language-agnostic
let nav = document.querySelector('nav[aria-label="Primary"]'); // English
// Fallback: look for nav with role="navigation" that contains profile link
if (!nav) {
const allNavs = document.querySelectorAll('nav[role="navigation"]');
for (const n of allNavs) {
if (n.querySelector('[data-testid="AppTabBar_Profile_Link"]')) {
nav = n;
break;
}
}
}
// Additional fallback: look for header > div > nav structure
if (!nav) {
const headers = document.querySelectorAll('header');
for (const header of headers) {
const n = header.querySelector('nav');
if (n && n.querySelector('[data-testid="AppTabBar_Profile_Link"]')) {
nav = n;
break;
}
}
}
if (nav) {
clearInterval(checkSidebar);
console.log('✅ Sidebar navigation found, adding Block Countries link');
this.addBlockerLink(nav);
} else {
console.debug('⏳ Waiting for sidebar navigation...');
}
}, 500);
// Stop after 10 seconds if not found
setTimeout(() => {
clearInterval(checkSidebar);
console.warn('⚠️ Sidebar navigation not found after 10 seconds');
}, 10000);
}
addBlockerLink(nav) {
// Check if already added
if (document.getElementById('x-country-blocker-link')) return;
// Find the Profile link to insert after it
const profileLink = nav.querySelector('[data-testid="AppTabBar_Profile_Link"]');
if (!profileLink) return;
const link = document.createElement('a');
link.id = 'x-country-blocker-link';
link.href = '#';
link.setAttribute('role', 'link');
link.className = profileLink.className;
link.setAttribute('aria-label', 'Block Countries');
link.innerHTML = `
<div class="css-175oi2r r-sdzlij r-dnmrzs r-1awozwy r-18u37iz r-1777fci r-xyw6el r-o7ynqc r-6416eg">
<div class="css-175oi2r">
<svg viewBox="0 0 24 24" aria-hidden="true" class="r-4qtqp9 r-yyyyoo r-dnmrzs r-bnwqim r-lrvibr r-m6rgpd r-1nao33i r-lwhw9o r-cnnz9e">
<g><path d="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3zm6 9.09c0 4-2.55 7.7-6 8.83-3.45-1.13-6-4.82-6-8.83V6.31l6-2.12 6 2.12v4.78z"></path></g>
</svg>
</div>
<div dir="ltr" class="css-146c3p1 r-dnmrzs r-1udh08x r-1udbk01 r-3s2u2q r-bcqeeo r-1ttztb7 r-qvutc0 r-37j5jr r-adyw6z r-135wba7 r-16dba41 r-dlybji r-nazi8o" style="color: rgb(231, 233, 234);">
<span class="css-1jxf684 r-bcqeeo r-1ttztb7 r-qvutc0 r-poiln3">Block Countries</span>
<span class="css-1jxf684 r-bcqeeo r-1ttztb7 r-qvutc0 r-poiln3"> </span>
</div>
</div>
`;
link.addEventListener('click', (e) => {
e.preventDefault();
this.showBlockerModal();
});
// Insert after profile link
profileLink.parentElement.insertBefore(link, profileLink.nextSibling);
}
showBlockerModal() {
// Create overlay
const overlay = document.createElement('div');
overlay.className = 'x-blocker-modal-overlay';
// Create modal
const modal = document.createElement('div');
modal.className = 'x-blocker-modal';
// Create header
const header = document.createElement('div');
header.className = 'x-blocker-header';
header.innerHTML = `
<h2 class="x-blocker-title">
<svg viewBox="0 0 24 24" width="24" height="24" style="display: inline-block; vertical-align: middle; margin-right: 8px;">
<g><path fill="currentColor" d="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3zm6 9.09c0 4-2.55 7.7-6 8.83-3.45-1.13-6-4.82-6-8.83V6.31l6-2.12 6 2.12v4.78zm-9-1.04l-1.41 1.41L10.5 14.5l6-6-1.41-1.41-4.59 4.58z"></path></g>
</svg>
Block Countries
</h2>
<button class="x-blocker-close" aria-label="Close">
<svg viewBox="0 0 24 24" width="20" height="20">
<g><path fill="currentColor" d="M10.59 12L4.54 5.96l1.42-1.42L12 10.59l6.04-6.05 1.42 1.42L13.41 12l6.05 6.04-1.42 1.42L12 13.41l-6.04 6.05-1.42-1.42L10.59 12z"></path></g>
</svg>
</button>
`;
// Create body
const body = document.createElement('div');
body.className = 'x-blocker-body';
const info = document.createElement('div');
info.className = 'x-blocker-info';
info.textContent = 'Select countries to block. Tweets from users in these countries will be hidden from your feed.';
const search = document.createElement('input');
search.type = 'text';
search.className = 'x-blocker-search';
search.placeholder = 'Search countries...';
const countriesContainer = document.createElement('div');
countriesContainer.className = 'x-blocker-countries';
body.appendChild(info);
body.appendChild(search);
body.appendChild(countriesContainer);
// Create footer
const footer = document.createElement('div');
footer.className = 'x-blocker-footer';
const stats = document.createElement('div');
stats.className = 'x-blocker-stats';
const updateStats = () => {
stats.textContent = `${this.blockedCountries.size} countries blocked`;
};
updateStats();
const btnContainer = document.createElement('div');
btnContainer.style.display = 'flex';
btnContainer.style.gap = '12px';
const clearBtn = document.createElement('button');
clearBtn.className = 'x-blocker-btn x-blocker-btn-secondary';
clearBtn.textContent = 'Clear All';
clearBtn.addEventListener('click', () => {
this.blockedCountries.clear();
this.saveBlockedCountries();
renderCountries();
updateStats();
// Smart clear: only update already-processed tweets (no new API calls)
document.querySelectorAll('[data-x-processed][data-x-screen-name]').forEach(el => {
const screenName = el.dataset.xScreenName;
const cachedInfo = this.cache.get(screenName);
if (!cachedInfo) return;
const tweet = el.closest('article[data-testid="tweet"]');
// Unhide tweet if it was blocked
if (tweet && tweet.classList.contains('x-tweet-blocked')) {
tweet.classList.remove('x-tweet-blocked');
}
// Re-add badge if it's missing and user has location/device
if (!el.querySelector('.x-info-badge') && (cachedInfo.location || cachedInfo.device)) {
const badge = document.createElement('span');
badge.className = 'x-info-badge';
let content = '';
if (cachedInfo.location) {
const flag = this.getFlagEmoji(cachedInfo.location);
if (flag) content += `<span title="${cachedInfo.location}">${flag}</span>`;
// Add VPN/Proxy indicator if location is not accurate
if (cachedInfo.locationAccurate === false) {
content += `<span title="Location may not be accurate (VPN/Proxy detected)">🔒</span>`;
}
}
if (cachedInfo.device) {
const emoji = this.getDeviceEmoji(cachedInfo.device);
content += `<span title="Connected via: ${cachedInfo.device}">${emoji}</span>`;
}
badge.innerHTML = content;
const insertionPoint = this.findInsertionPoint(el, screenName);
if (insertionPoint) insertionPoint.target.insertBefore(badge, insertionPoint.ref);
}
});
});
const doneBtn = document.createElement('button');
doneBtn.className = 'x-blocker-btn x-blocker-btn-primary';
doneBtn.textContent = 'Done';
doneBtn.addEventListener('click', () => {
overlay.remove();
});
btnContainer.appendChild(clearBtn);
btnContainer.appendChild(doneBtn);
footer.appendChild(stats);
footer.appendChild(btnContainer);
// Assemble modal
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
overlay.appendChild(modal);
// Render countries list
const renderCountries = (filter = '') => {
countriesContainer.innerHTML = '';
const countries = Object.keys(COUNTRY_FLAGS)
.filter(country => country.includes(filter.toLowerCase()))
.sort();
countries.forEach(country => {
const item = document.createElement('div');
item.className = 'x-country-item';
const isBlocked = this.blockedCountries.has(country);
if (isBlocked) item.classList.add('blocked');
const flag = this.getFlagEmoji(country);
const flagSpan = document.createElement('span');
flagSpan.className = 'x-country-flag';
if (typeof flag === 'string' && flag.startsWith('<img')) {
flagSpan.innerHTML = flag;
} else {
flagSpan.textContent = flag || '🌍';
}
const name = document.createElement('span');
name.className = 'x-country-name';
// Proper title case: capitalize each word
name.textContent = country.split(' ').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
const status = document.createElement('span');
status.className = 'x-country-status';
status.textContent = isBlocked ? 'BLOCKED' : '';
item.appendChild(flagSpan);
item.appendChild(name);
item.appendChild(status);
item.addEventListener('click', () => {
const wasBlocked = this.blockedCountries.has(country);
if (wasBlocked) {
this.blockedCountries.delete(country);
} else {
this.blockedCountries.add(country);
}
this.saveBlockedCountries();
renderCountries(filter);
updateStats();
// Smart update: only process cached tweets (NO API CALLS)
document.querySelectorAll('[data-x-processed][data-x-screen-name]').forEach(el => {
const screenName = el.dataset.xScreenName;
const cachedInfo = this.cache.get(screenName);
if (!cachedInfo || !cachedInfo.location) return;
const countryLower = cachedInfo.location.trim().toLowerCase();
const tweet = el.closest('article[data-testid="tweet"]');
if (countryLower === country) {
// This tweet's country was toggled
if (wasBlocked) {
// Unblocking: show tweet and add badge
if (tweet) tweet.classList.remove('x-tweet-blocked');
// Add badge if not present (using cached data only)
if (!el.querySelector('.x-info-badge')) {
const badge = document.createElement('span');
badge.className = 'x-info-badge';
let content = '';
if (cachedInfo.location) {
const flag = this.getFlagEmoji(cachedInfo.location);
if (flag) content += `<span title="${cachedInfo.location}">${flag}</span>`;
// Add VPN/Proxy indicator if location is not accurate
if (cachedInfo.locationAccurate === false) {
content += `<span title="Location may not be accurate (VPN/Proxy detected)">🔒</span>`;
}
}
if (cachedInfo.device) {
const emoji = this.getDeviceEmoji(cachedInfo.device);
content += `<span title="Connected via: ${cachedInfo.device}">${emoji}</span>`;
}
badge.innerHTML = content;
const insertionPoint = this.findInsertionPoint(el, screenName);
if (insertionPoint) insertionPoint.target.insertBefore(badge, insertionPoint.ref);
}
} else {
// Blocking: hide tweet and remove badge
if (tweet) tweet.classList.add('x-tweet-blocked');
const badge = el.querySelector('.x-info-badge');
if (badge) badge.remove();
}
}
});
});
countriesContainer.appendChild(item);
});
};
renderCountries();
// Search functionality
search.addEventListener('input', (e) => {
renderCountries(e.target.value);
});
// Close button
header.querySelector('.x-blocker-close').addEventListener('click', () => {
overlay.remove();
});
// Close on overlay click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.remove();
}
});
// Add to page
document.body.appendChild(overlay);
}
/**
* Public API
*/
getCacheInfo() {
const entries = Array.from(this.cache.entries()).map(([key, value]) => ({
key,
value
}));
return { size: this.cache.size, entries };
}
exposeAPI() {
const api = {
clearCache: () => {
this.cache.clear();
localStorage.removeItem(CONFIG.CACHE_KEY);
console.log('🧹 Cache cleared');
},
getCacheInfo: () => {
const info = this.getCacheInfo();
console.log('Cache info:', info);
return info;
},
toggle: () => {
this.isEnabled = !this.isEnabled;
localStorage.setItem('x_location_enabled', this.isEnabled);
console.log(`Extension ${this.isEnabled ? 'enabled' : 'disabled'}`);
},
debug: () => {
console.log('Cache Size:', this.cache.size);
console.log('Queue Length:', this.requestQueue.length);
console.log('Active Requests:', this.activeRequests);
console.log('Blocked Countries:', Array.from(this.blockedCountries));
},
openBlocker: () => {
this.showBlockerModal();
},
getBlockedCountries: () => {
return Array.from(this.blockedCountries);
}
};
if (typeof cloneInto === 'function') {
unsafeWindow.XFlagScript = cloneInto(api, unsafeWindow, { cloneFunctions: true });
} else {
unsafeWindow.XFlagScript = api;
}
}
}
// Instantiate
new XLocationPatcher();
})();