// ==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
// @downloadURL https://update.greasyfork.icu/scripts/556794/X-Posed%3A%20Account%20Location%20%20Device%20Info.user.js
// @updateURL https://update.greasyfork.icu/scripts/556794/X-Posed%3A%20Account%20Location%20%20Device%20Info.meta.js
// ==/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 ``;
}
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 += `${flag}`;
// Add VPN/Proxy indicator if location is not accurate
if (info.locationAccurate === false) {
content += `๐`;
}
}
const device = info.device;
if (device) {
const emoji = this.getDeviceEmoji(device);
content += `${emoji}`;
}
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 = `