From ac786aa7030019b2d151e1bccab7534afb2a34ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yury=20Vashchylau=20=F0=9F=94=A8=F0=9F=A7=91=E2=80=8D?= =?UTF-8?q?=F0=9F=92=BB=20Tech=20at=20yuryv=2Einfo?= Date: Fri, 3 Apr 2026 18:18:59 +0200 Subject: [PATCH] Add Node build pipeline for Chromium and Firefox extensions --- .github/workflows/build-extensions.yml | 35 ++ README.md | 35 ++ blink/README.md | 4 + blink/manifest.json | 78 +++ blink/scripts/twitter-backspace-back.js | 156 +++++ blink/scripts/twitter-delete-hotkey.js | 202 +++++++ .../scripts/twitter-inline-follower-count.js | 565 ++++++++++++++++++ .../scripts/twitter-post-activity-hotkeys.js | 228 +++++++ blink/scripts/twitter-profile-hotkey.js | 195 ++++++ blink/scripts/twitter-quote-hotkey.js | 221 +++++++ blink/scripts/twitter-usercell-hotkeys.js | 252 ++++++++ moz/README.md | 4 + moz/manifest.json | 84 +++ moz/scripts/twitter-backspace-back.js | 156 +++++ moz/scripts/twitter-delete-hotkey.js | 202 +++++++ moz/scripts/twitter-inline-follower-count.js | 565 ++++++++++++++++++ moz/scripts/twitter-post-activity-hotkeys.js | 228 +++++++ moz/scripts/twitter-profile-hotkey.js | 195 ++++++ moz/scripts/twitter-quote-hotkey.js | 221 +++++++ moz/scripts/twitter-usercell-hotkeys.js | 252 ++++++++ package.json | 11 + tools/build-extensions.mjs | 170 ++++++ 22 files changed, 4059 insertions(+) create mode 100644 .github/workflows/build-extensions.yml create mode 100644 blink/README.md create mode 100644 blink/manifest.json create mode 100644 blink/scripts/twitter-backspace-back.js create mode 100644 blink/scripts/twitter-delete-hotkey.js create mode 100644 blink/scripts/twitter-inline-follower-count.js create mode 100644 blink/scripts/twitter-post-activity-hotkeys.js create mode 100644 blink/scripts/twitter-profile-hotkey.js create mode 100644 blink/scripts/twitter-quote-hotkey.js create mode 100644 blink/scripts/twitter-usercell-hotkeys.js create mode 100644 moz/README.md create mode 100644 moz/manifest.json create mode 100644 moz/scripts/twitter-backspace-back.js create mode 100644 moz/scripts/twitter-delete-hotkey.js create mode 100644 moz/scripts/twitter-inline-follower-count.js create mode 100644 moz/scripts/twitter-post-activity-hotkeys.js create mode 100644 moz/scripts/twitter-profile-hotkey.js create mode 100644 moz/scripts/twitter-quote-hotkey.js create mode 100644 moz/scripts/twitter-usercell-hotkeys.js create mode 100644 package.json create mode 100644 tools/build-extensions.mjs diff --git a/.github/workflows/build-extensions.yml b/.github/workflows/build-extensions.yml new file mode 100644 index 0000000..26c313d --- /dev/null +++ b/.github/workflows/build-extensions.yml @@ -0,0 +1,35 @@ +name: Build extension bundles + +on: + push: + branches: [ main ] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Build extension outputs + run: npm run build:extensions + + - name: Archive blink build + run: cd blink && zip -r ../twitter-userscripts-blink.zip . + + - name: Archive moz build + run: cd moz && zip -r ../twitter-userscripts-moz.zip . + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: extension-builds + path: | + twitter-userscripts-blink.zip + twitter-userscripts-moz.zip diff --git a/README.md b/README.md index d281cec..0384d8e 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,41 @@ A collection of userscripts that add keyboard shortcuts, inline information, and ## Installation +## Build as Browser Extensions (Chromium + Firefox) + +This repo can also package the existing `.user.js` sources into two standalone extension folders: + +- `blink/` for Chrome/Chromium (Manifest V3) +- `moz/` for Firefox (Manifest V3) + +The original userscript source files are not moved or renamed, so existing `raw.githubusercontent.com` install links continue to work. + +### Prerequisite + +- Node.js 20+ (recommended for local and CI builds) + +### Build + +```bash +npm run build:extensions +``` + +This command: + +- Scans all `*.user.js` files in the repository root. +- Reads userscript metadata such as `@match` and `@run-at`. +- Inlines `@require` dependencies that point to this repository's raw GitHub URLs. +- Writes extension-ready output to `blink/` and `moz/`. + +### Load unpacked extension + +- Chromium: go to `chrome://extensions`, enable **Developer mode**, click **Load unpacked**, select `blink/`. +- Firefox: go to `about:debugging#/runtime/this-firefox`, click **Load Temporary Add-on**, select `moz/manifest.json`. + +### CI/CD + +A GitHub Actions workflow at `.github/workflows/build-extensions.yml` runs the build, creates ZIP archives for both browser targets, and uploads them as workflow artifacts. + ### Desktop Browsers You need a userscript manager extension. Recommended options: diff --git a/blink/README.md b/blink/README.md new file mode 100644 index 0000000..5330e40 --- /dev/null +++ b/blink/README.md @@ -0,0 +1,4 @@ +# Chromium build output + +Generated by `npm run build:extensions`. +Do not edit files in this folder by hand. diff --git a/blink/manifest.json b/blink/manifest.json new file mode 100644 index 0000000..b810982 --- /dev/null +++ b/blink/manifest.json @@ -0,0 +1,78 @@ +{ + "manifest_version": 3, + "name": "Twitter Userscripts (Chromium)", + "version": "1.7.1", + "description": "Built from userscripts in this repository. Source files stay in place for userscript-manager installs.", + "content_scripts": [ + { + "matches": [ + "https://twitter.com/*", + "https://x.com/*" + ], + "js": [ + "scripts/twitter-backspace-back.js" + ], + "run_at": "document_idle" + }, + { + "matches": [ + "https://twitter.com/*", + "https://x.com/*" + ], + "js": [ + "scripts/twitter-delete-hotkey.js" + ], + "run_at": "document_idle" + }, + { + "matches": [ + "https://twitter.com/*", + "https://x.com/*" + ], + "js": [ + "scripts/twitter-inline-follower-count.js" + ], + "run_at": "document_start" + }, + { + "matches": [ + "https://twitter.com/*", + "https://x.com/*" + ], + "js": [ + "scripts/twitter-post-activity-hotkeys.js" + ], + "run_at": "document_idle" + }, + { + "matches": [ + "https://twitter.com/*", + "https://x.com/*" + ], + "js": [ + "scripts/twitter-profile-hotkey.js" + ], + "run_at": "document_idle" + }, + { + "matches": [ + "https://twitter.com/*", + "https://x.com/*" + ], + "js": [ + "scripts/twitter-quote-hotkey.js" + ], + "run_at": "document_idle" + }, + { + "matches": [ + "https://twitter.com/*", + "https://x.com/*" + ], + "js": [ + "scripts/twitter-usercell-hotkeys.js" + ], + "run_at": "document_idle" + } + ] +} diff --git a/blink/scripts/twitter-backspace-back.js b/blink/scripts/twitter-backspace-back.js new file mode 100644 index 0000000..e7973c0 --- /dev/null +++ b/blink/scripts/twitter-backspace-back.js @@ -0,0 +1,156 @@ +// twitter-custom-keys.lib.js — Shared library for registering custom keyboard shortcuts +// @require'd by individual hotkey userscripts. Injects a "Custom" section into Twitter's +// built-in keyboard shortcuts dialog (opened with ?). +// +// Usage: window.__twitterCustomKeys.register(key, description) + +(function () { + 'use strict'; + + // Singleton guard — only the first script to load initializes + if (window.__twitterCustomKeys) return; + + const entries = []; + const SECTION_ID = 'tm-custom-keys-section'; + + window.__twitterCustomKeys = { + register(key, description) { + entries.push({ key, description }); + } + }; + + // Find the container holding all shortcut sections. + // Desktop: [role="dialog"] > ... > [data-viewportview="true"] > sections + // Mobile:
> ... > scrollable div > sections + // Identified by the h2#modal-header heading near [role="table"] elements. + // Uses structural markers (id, roles) instead of text to support all languages. + function findSectionsContainer() { + const header = document.getElementById('modal-header'); + if (!header) return null; + // Walk up from the header to find the ancestor containing [role="table"] sections + let el = header.parentElement; + while (el) { + if (el.querySelector('[role="table"]')) return el; + el = el.parentElement; + } + return null; + } + + function renderSection(container) { + // Remove previous render if any + const old = document.getElementById(SECTION_ID); + if (old) old.remove(); + + if (entries.length === 0) return; + + // Find an existing section to clone structure from. + // Each section wraps a heading + [role="table"]. The section is the + // table's parent (which may have varying CSS classes across views). + const existingTable = container.querySelector('[role="table"]'); + if (!existingTable) return; + const existingRow = existingTable.querySelector('[role="row"]'); + if (!existingRow) return; + const existingSection = existingTable.parentElement; + if (!existingSection) return; + + // Clone the entire section as our template + const section = existingSection.cloneNode(true); + section.id = SECTION_ID; + + // Update the heading text to "Custom" + const headingSpan = section.querySelector('h2[role="heading"] span'); + if (headingSpan) { + headingSpan.textContent = 'Custom'; + } + + // Get reference to the table, clear its rows, and rebuild + const table = section.querySelector('[role="table"]'); + table.innerHTML = ''; + + for (const { key, description } of entries) { + // Clone a row from the original dialog for correct classes + const row = existingRow.cloneNode(true); + + // First cell = description + const cells = row.querySelectorAll('[role="cell"]'); + const descCell = cells[0]; + const keyCell = cells[1]; + + // Set description text + const descSpan = descCell.querySelector('span'); + if (descSpan) { + descSpan.textContent = description; + } else { + descCell.textContent = description; + } + + // Set key — clear existing content and rebuild from a single-key template + keyCell.innerHTML = ''; + const existingKeyDiv = existingRow.querySelector('[role="cell"]:last-child > div'); + if (existingKeyDiv) { + const keyDiv = existingKeyDiv.cloneNode(true); + keyDiv.textContent = key; + keyCell.appendChild(keyDiv); + } else { + keyCell.textContent = key; + } + + table.appendChild(row); + } + + // Force onto its own row in the desktop flex layout (which assumes 3 columns) + section.style.flexBasis = '100%'; + + // Append after the last existing section + existingSection.parentElement.appendChild(section); + } + + function checkForShortcutsView() { + // Already injected + if (document.getElementById(SECTION_ID)) return; + + const container = findSectionsContainer(); + if (container) { + renderSection(container); + } + } + + // Watch for shortcuts view appearance (dialog on desktop, page on mobile) + const observer = new MutationObserver(checkForShortcutsView); + + function startObserver() { + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + startObserver(); + } else { + document.addEventListener('DOMContentLoaded', startObserver); + } + + console.log('[CustomKeys] Shared library loaded'); +})(); + + + +(function () { + 'use strict'; + + window.__twitterCustomKeys?.register('Backspace', 'Go back'); + + document.addEventListener('keydown', function (e) { + if (e.key !== 'Backspace') return; + + const tag = document.activeElement.tagName; + const isEditable = document.activeElement.isContentEditable; + if (tag === 'INPUT' || tag === 'TEXTAREA' || isEditable) return; + + // Only act if Twitter's back button is visible + const backBtn = document.querySelector('[data-testid="app-bar-back"]'); + if (!backBtn || backBtn.offsetParent === null) return; + + e.preventDefault(); + backBtn.click(); + }); +})(); + diff --git a/blink/scripts/twitter-delete-hotkey.js b/blink/scripts/twitter-delete-hotkey.js new file mode 100644 index 0000000..d076541 --- /dev/null +++ b/blink/scripts/twitter-delete-hotkey.js @@ -0,0 +1,202 @@ +// twitter-custom-keys.lib.js — Shared library for registering custom keyboard shortcuts +// @require'd by individual hotkey userscripts. Injects a "Custom" section into Twitter's +// built-in keyboard shortcuts dialog (opened with ?). +// +// Usage: window.__twitterCustomKeys.register(key, description) + +(function () { + 'use strict'; + + // Singleton guard — only the first script to load initializes + if (window.__twitterCustomKeys) return; + + const entries = []; + const SECTION_ID = 'tm-custom-keys-section'; + + window.__twitterCustomKeys = { + register(key, description) { + entries.push({ key, description }); + } + }; + + // Find the container holding all shortcut sections. + // Desktop: [role="dialog"] > ... > [data-viewportview="true"] > sections + // Mobile:
> ... > scrollable div > sections + // Identified by the h2#modal-header heading near [role="table"] elements. + // Uses structural markers (id, roles) instead of text to support all languages. + function findSectionsContainer() { + const header = document.getElementById('modal-header'); + if (!header) return null; + // Walk up from the header to find the ancestor containing [role="table"] sections + let el = header.parentElement; + while (el) { + if (el.querySelector('[role="table"]')) return el; + el = el.parentElement; + } + return null; + } + + function renderSection(container) { + // Remove previous render if any + const old = document.getElementById(SECTION_ID); + if (old) old.remove(); + + if (entries.length === 0) return; + + // Find an existing section to clone structure from. + // Each section wraps a heading + [role="table"]. The section is the + // table's parent (which may have varying CSS classes across views). + const existingTable = container.querySelector('[role="table"]'); + if (!existingTable) return; + const existingRow = existingTable.querySelector('[role="row"]'); + if (!existingRow) return; + const existingSection = existingTable.parentElement; + if (!existingSection) return; + + // Clone the entire section as our template + const section = existingSection.cloneNode(true); + section.id = SECTION_ID; + + // Update the heading text to "Custom" + const headingSpan = section.querySelector('h2[role="heading"] span'); + if (headingSpan) { + headingSpan.textContent = 'Custom'; + } + + // Get reference to the table, clear its rows, and rebuild + const table = section.querySelector('[role="table"]'); + table.innerHTML = ''; + + for (const { key, description } of entries) { + // Clone a row from the original dialog for correct classes + const row = existingRow.cloneNode(true); + + // First cell = description + const cells = row.querySelectorAll('[role="cell"]'); + const descCell = cells[0]; + const keyCell = cells[1]; + + // Set description text + const descSpan = descCell.querySelector('span'); + if (descSpan) { + descSpan.textContent = description; + } else { + descCell.textContent = description; + } + + // Set key — clear existing content and rebuild from a single-key template + keyCell.innerHTML = ''; + const existingKeyDiv = existingRow.querySelector('[role="cell"]:last-child > div'); + if (existingKeyDiv) { + const keyDiv = existingKeyDiv.cloneNode(true); + keyDiv.textContent = key; + keyCell.appendChild(keyDiv); + } else { + keyCell.textContent = key; + } + + table.appendChild(row); + } + + // Force onto its own row in the desktop flex layout (which assumes 3 columns) + section.style.flexBasis = '100%'; + + // Append after the last existing section + existingSection.parentElement.appendChild(section); + } + + function checkForShortcutsView() { + // Already injected + if (document.getElementById(SECTION_ID)) return; + + const container = findSectionsContainer(); + if (container) { + renderSection(container); + } + } + + // Watch for shortcuts view appearance (dialog on desktop, page on mobile) + const observer = new MutationObserver(checkForShortcutsView); + + function startObserver() { + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + startObserver(); + } else { + document.addEventListener('DOMContentLoaded', startObserver); + } + + console.log('[CustomKeys] Shared library loaded'); +})(); + + + +(function () { + 'use strict'; + + window.__twitterCustomKeys?.register('d', 'Delete focused tweet'); + + function isTyping() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return true; + if (el.getAttribute('contenteditable') === 'true') return true; + if (el.closest('[contenteditable="true"]')) return true; + return false; + } + + function getFocusedTweet() { + let el = document.activeElement; + while (el) { + if (el.matches && el.matches('article[data-testid="tweet"]')) return el; + el = el.parentElement; + } + return null; + } + + async function deleteTweet() { + const article = getFocusedTweet(); + if (!article) return; + + const caret = article.querySelector('[data-testid="caret"]'); + if (!caret) return; + + caret.click(); + + let menu = null; + for (let i = 0; i < 10; i++) { + await new Promise(r => setTimeout(r, 50)); + menu = document.querySelector('[role="menu"]'); + if (menu) break; + } + if (!menu) return; + + const items = menu.querySelectorAll('[role="menuitem"]'); + for (const item of items) { + if (item.textContent.includes('Delete')) { + item.click(); + return; + } + } + + // Not found (not tweet author) — close menu + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + } + + document.addEventListener('keydown', function (e) { + if (isTyping()) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + + if (e.key === 'd') { + e.preventDefault(); + e.stopPropagation(); + deleteTweet(); + } + }); + + console.log('[DeleteHotkey] Loaded: d=delete tweet'); +})(); + diff --git a/blink/scripts/twitter-inline-follower-count.js b/blink/scripts/twitter-inline-follower-count.js new file mode 100644 index 0000000..84d1e12 --- /dev/null +++ b/blink/scripts/twitter-inline-follower-count.js @@ -0,0 +1,565 @@ + +(function () { + 'use strict'; + + const CACHE_KEY = 'tm-follower-cache'; + const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days + const RATE_WINDOW = 15 * 60 * 1000; // 15 minutes + const RATE_MAX = 1000; // max requests per window + const RATE_PAUSE = 15 * 60 * 1000; // pause duration on 429 + const userCache = new Map(); // handle -> { followers, bio, ts } + let requestTimestamps = []; // timestamps of recent API calls + let ratePausedUntil = 0; // if > Date.now(), we're paused + + // Load persisted cache from localStorage + try { + const stored = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}'); + const now = Date.now(); + for (const [handle, entry] of Object.entries(stored)) { + if (entry.ts && (now - entry.ts) < CACHE_TTL) { + userCache.set(handle, entry); + } + } + console.log('[FollowerCount] Loaded', userCache.size, 'cached users from localStorage'); + } catch {} + + let saveTimer = null; + function persistCache() { + if (saveTimer) return; + saveTimer = setTimeout(() => { + saveTimer = null; + try { + const obj = Object.fromEntries(userCache); + localStorage.setItem(CACHE_KEY, JSON.stringify(obj)); + } catch {} + }, 1000); + } + + function formatCount(n) { + if (n >= 1e6) { + const val = n / 1e6; + return (val >= 10 ? Math.round(val) : val.toFixed(1).replace(/\.0$/, '')) + 'M'; + } + if (n >= 1e3) { + const val = n / 1e3; + return (val >= 10 ? Math.round(val) : val.toFixed(1).replace(/\.0$/, '')) + 'K'; + } + return String(n); + } + + let reprocessTimer = null; + + function scheduleReprocess() { + if (reprocessTimer) return; + reprocessTimer = setTimeout(() => { + reprocessTimer = null; + processAll(); + }, 100); + } + + // Dynamic GraphQL API fetch — discovers query ID from Twitter's own JS bundles + const BEARER = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'; + const fetchQueued = new Set(); + const failedHandles = new Set(); + let fetchQueue = []; + let fetchRunning = false; + let queueGeneration = 0; // incremented on cancellation + let consecutiveRequests = 0; // for escalating delay + let discoveredQueryId = null; + let discoveryPromise = null; + let capturedFeatures = null; + + function getCsrfToken() { + const match = document.cookie.match(/(?:^|;\s*)ct0=([^;]+)/); + return match ? match[1] : ''; + } + + function randomDelay(min, max) { + return min + Math.random() * (max - min); + } + + // Discover the UserByScreenName query ID from Twitter's loaded JS chunks + const QUERY_ID_PATTERNS = [ + /queryId:"([^"]+)",operationName:"UserByScreenName"/, + /operationName:"UserByScreenName",queryId:"([^"]+)"/, + /queryId:"([^"]+)"[^}]{0,100}operationName:"UserByScreenName"/, + /operationName:"UserByScreenName"[^}]{0,100}queryId:"([^"]+)"/, + ]; + + async function discoverQueryId() { + if (discoveredQueryId) return discoveredQueryId; + console.log('[FollowerCount] Starting query ID discovery...'); + const scripts = document.querySelectorAll('script[src]'); + console.log('[FollowerCount] Found', scripts.length, 'script tags to scan'); + let scanned = 0, failed = 0; + for (const script of scripts) { + try { + const resp = await origFetch(script.src); + if (!resp.ok) { failed++; continue; } + const text = await resp.text(); + scanned++; + for (const pattern of QUERY_ID_PATTERNS) { + const match = text.match(pattern); + if (match) { + discoveredQueryId = match[1]; + console.log('[FollowerCount] Discovered queryId:', discoveredQueryId, 'from', script.src); + // Extract featureSwitches near the UserByScreenName definition + const idx = match.index; + const nearby = text.substring(idx, idx + 2000); + const featMatch = nearby.match(/featureSwitches:\[([^\]]+)\]/); + if (featMatch) { + const switches = featMatch[1].match(/"([^"]+)"/g).map(s => s.slice(1, -1)); + const featObj = {}; + switches.forEach(s => { featObj[s] = true; }); + capturedFeatures = JSON.stringify(featObj); + console.log('[FollowerCount] Extracted', switches.length, 'features from bundle'); + } + return discoveredQueryId; + } + } + } catch (e) { + failed++; + console.debug('[FollowerCount] Failed to fetch script:', script.src, e.message); + } + } + console.warn('[FollowerCount] Query ID not found. Scanned:', scanned, 'Failed:', failed); + return null; + } + + // Retry discovery with delay (scripts may load late) + async function discoverQueryIdWithRetry() { + for (let attempt = 0; attempt < 3; attempt++) { + const id = await discoverQueryId(); + if (id) return id; + console.log('[FollowerCount] Discovery attempt', attempt + 1, 'failed, retrying in', (attempt + 1) * 2, 's...'); + await new Promise(r => setTimeout(r, (attempt + 1) * 2000)); + discoveryPromise = null; // allow re-run + } + return null; + } + + function cancelQueue() { + queueGeneration++; + fetchQueue = []; + fetchQueued.clear(); + // Don't reset consecutiveRequests — the escalation must persist across queue rebuilds + } + + function queueUserFetch(handle) { + if (fetchQueued.has(handle)) return; + // Skip if we already have a fresh cache entry + const cached = userCache.get(handle); + if (cached && cached.ts && (Date.now() - cached.ts) < CACHE_TTL) return; + fetchQueued.add(handle); + fetchQueue.push(handle); + if (!fetchRunning) drainFetchQueue(); + } + + function isRateLimited() { + const now = Date.now(); + if (now < ratePausedUntil) return true; + requestTimestamps = requestTimestamps.filter(t => (now - t) < RATE_WINDOW); + return requestTimestamps.length >= RATE_MAX; + } + + function drainFetchQueue() { + const gen = queueGeneration; + if (fetchRunning || fetchQueue.length === 0) return; + if (isRateLimited()) { + const retryIn = ratePausedUntil > Date.now() + ? ratePausedUntil - Date.now() + : 30000; + console.log('[FollowerCount] Rate limited, retrying in', Math.round(retryIn / 1000), 's (' + fetchQueue.length, 'queued)'); + setTimeout(() => { if (queueGeneration === gen) drainFetchQueue(); }, retryIn); + return; + } + fetchRunning = true; + const handle = fetchQueue.shift(); + requestTimestamps.push(Date.now()); + fetchUserByScreenName(handle).finally(() => { + fetchRunning = false; + if (queueGeneration !== gen) return; + // Escalating delay: 1-3s, 4-6s, 7-9s, 10-12s, ... no cap, no decay + const delay = randomDelay(1000 + consecutiveRequests * 3000, 3000 + consecutiveRequests * 3000); + consecutiveRequests++; + console.log('[FollowerCount] Next fetch in', Math.round(delay / 1000), 's (step', consecutiveRequests, ')'); + setTimeout(() => { if (queueGeneration === gen) drainFetchQueue(); }, delay); + }); + } + + async function fetchUserByScreenName(screenName) { + try { + // Ensure we have the query ID (shared single discovery with retry) + if (!discoveredQueryId) { + if (!discoveryPromise) discoveryPromise = discoverQueryIdWithRetry(); + await discoveryPromise; + } + if (!discoveredQueryId) { + console.warn('[FollowerCount] No queryId available, skipping fetch for', screenName); + fetchQueued.delete(screenName.toLowerCase()); // allow retry later + return; + } + + if (!capturedFeatures) { + console.warn('[FollowerCount] No features available, skipping fetch for', screenName); + fetchQueued.delete(screenName.toLowerCase()); // allow retry later + return; + } + + const variables = JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true }); + const params = new URLSearchParams({ variables, features: capturedFeatures, fieldToggles: '{}' }); + const url = `https://x.com/i/api/graphql/${discoveredQueryId}/UserByScreenName?${params}`; + + const resp = await origFetch(url, { + headers: { + 'authorization': `Bearer ${decodeURIComponent(BEARER)}`, + 'x-csrf-token': getCsrfToken(), + 'x-twitter-active-user': 'yes', + 'x-twitter-auth-type': 'OAuth2Session', + }, + credentials: 'include', + }); + if (resp.status === 429) { + ratePausedUntil = Date.now() + RATE_PAUSE; + console.warn('[FollowerCount] Rate limited (429)! Pausing for 15 minutes.'); + fetchQueued.delete(screenName.toLowerCase()); // allow retry later + fetchQueue.unshift(screenName); // put it back at the front + return; + } + if (!resp.ok) { + console.warn('[FollowerCount] API error for', screenName, resp.status); + failedHandles.add(screenName.toLowerCase()); + scheduleReprocess(); + return; + } + const json = await resp.json(); + // Direct extraction — we already know the screenName, just find followers_count + bio + const result = json?.data?.user?.result; + const fc = result?.legacy?.followers_count + ?? result?.followers_count + ?? findFollowersCount(result); + const bio = result?.legacy?.description + ?? result?.profile_bio?.description + ?? findStringField(result, 'description'); + if (typeof fc === 'number') { + cacheUser(screenName, fc, bio || ''); + } else { + console.warn('[FollowerCount] Could not find followers_count for', screenName); + failedHandles.add(screenName.toLowerCase()); + scheduleReprocess(); + } + // Also run generic extraction for any other user data in the response + extractUsers(json, 0); + } catch (e) { + console.warn('[FollowerCount] Fetch failed for', screenName, e); + failedHandles.add(screenName.toLowerCase()); + scheduleReprocess(); + } + } + + // Deep search for followers_count in an object + function findFollowersCount(obj, depth = 0) { + if (!obj || typeof obj !== 'object' || depth > 20) return null; + if (typeof obj.followers_count === 'number') return obj.followers_count; + for (const key of Object.keys(obj)) { + const val = obj[key]; + if (val && typeof val === 'object') { + const found = findFollowersCount(val, depth + 1); + if (found !== null) return found; + } + } + return null; + } + + // Deep search for a string field by name + function findStringField(obj, fieldName, depth = 0) { + if (!obj || typeof obj !== 'object' || depth > 10) return null; + if (typeof obj[fieldName] === 'string' && obj[fieldName].length > 0) return obj[fieldName]; + for (const key of Object.keys(obj)) { + const val = obj[key]; + if (val && typeof val === 'object' && !Array.isArray(val)) { + const found = findStringField(val, fieldName, depth + 1); + if (found) return found; + } + } + return null; + } + + function cacheUser(screenName, followersCount, bio) { + const handle = screenName.toLowerCase(); + const prev = userCache.get(handle); + const entry = { followers: followersCount, bio: bio ?? prev?.bio ?? '', ts: Date.now() }; + userCache.set(handle, entry); + persistCache(); + if (!prev) { + console.log('[FollowerCount] Cached:', handle, formatCount(followersCount)); + scheduleReprocess(); + } + } + + function extractUsers(obj, depth) { + if (!obj || typeof obj !== 'object') return; + if (depth > 50) return; + // Standard legacy structure + if (obj.legacy && typeof obj.legacy.followers_count === 'number' && obj.legacy.screen_name) { + cacheUser(obj.legacy.screen_name, obj.legacy.followers_count); + } + // Alternative: screen_name and followers_count at the same level + if (typeof obj.screen_name === 'string' && typeof obj.followers_count === 'number') { + cacheUser(obj.screen_name, obj.followers_count); + } + for (const key of Object.keys(obj)) { + const val = obj[key]; + if (Array.isArray(val)) { + val.forEach(item => extractUsers(item, depth + 1)); + } else if (val && typeof val === 'object') { + extractUsers(val, depth + 1); + } + } + } + + console.log('[FollowerCount] Script loaded, intercepting fetch/XHR'); + + const origFetch = window.fetch; + window.fetch = async function (...args) { + const resp = await origFetch.apply(this, args); + try { + const url = typeof args[0] === 'string' ? args[0] : args[0]?.url; + if (url && url.includes('/graphql/')) { + // Capture query IDs from Twitter's own requests + const qidMatch = url.match(/\/graphql\/([^/?]+)\/UserByScreenName/); + if (qidMatch && !discoveredQueryId) { + discoveredQueryId = qidMatch[1]; + console.log('[FollowerCount] Captured UserByScreenName queryId from traffic:', discoveredQueryId); + } + } + if (url && (url.includes('/graphql/') || url.includes('/i/api/'))) { + const clone = resp.clone(); + clone.json().then(json => { + try { extractUsers(json, 0); } catch (e) { console.warn('[FollowerCount] fetch parse error:', e); } + }).catch(() => {}); + } + } catch {} + return resp; + }; + + const origOpen = XMLHttpRequest.prototype.open; + const origSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (method, url, ...rest) { + this._tmUrl = url; + return origOpen.call(this, method, url, ...rest); + }; + XMLHttpRequest.prototype.send = function (...args) { + if (this._tmUrl && (this._tmUrl.includes('/graphql/') || this._tmUrl.includes('/i/api/'))) { + this.addEventListener('load', function () { + try { + const json = JSON.parse(this.responseText); + extractUsers(json, 0); + } catch {} + }); + } + return origSend.apply(this, args); + }; + + const BADGE_ATTR = 'data-follower-badge'; + const STYLE_ID = 'tm-follower-count-style'; + + function injectStyles() { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` + .tm-follower-badge { + color: rgb(113, 118, 123); + font-size: 13px; + font-weight: 400; + white-space: nowrap; + display: inline-flex; + align-items: center; + } + .tm-follower-badge::before { + content: "·"; + margin: 0 4px; + } + .tm-bio-line { + color: rgb(113, 118, 123); + font-size: 13px; + font-weight: 400; + line-height: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + padding: 0 0 2px 0; + } + .tm-f-label { + font-style: italic; + opacity: 0.6; + } + .tm-usercell-badge { + color: rgb(113, 118, 123); + font-size: 13px; + font-weight: 400; + white-space: nowrap; + } + .tm-usercell-badge::before { + content: " · "; + } + `; + (document.head || document.documentElement).appendChild(style); + } + + // Extract @handle from an element's descendant spans. + // Twitter handle spans contain exactly "@username" (1-15 word chars). + // We must skip display-name text that happens to contain "@" (e.g. + // "Yury @ yuryv.info" or "@user@mastodon.social"). + const HANDLE_RE = /^@(\w{1,15})$/; + function findHandle(container) { + if (!container) return null; + const spans = container.querySelectorAll('span'); + for (const span of spans) { + if (span.children.length !== 0) continue; + const m = span.textContent.trim().match(HANDLE_RE); + if (m) return m[1].toLowerCase(); + } + return null; + } + + // Extract handle from avatar data-testid (programmatic, immune to display name tricks) + function findHandleFromAvatar(container) { + const avatar = container.querySelector('[data-testid^="UserAvatar-Container-"]'); + if (!avatar) return null; + const handle = avatar.getAttribute('data-testid').replace('UserAvatar-Container-', ''); + return handle ? handle.toLowerCase() : null; + } + + function processTweets() { + const articles = document.querySelectorAll('article[data-testid="tweet"]'); + for (const article of articles) { + const prevState = article.getAttribute(BADGE_ATTR); + + const userNameContainer = article.querySelector('[data-testid="User-Name"]'); + // Prefer avatar data-testid (reliable), fall back to span scanning + let handle = findHandleFromAvatar(article) || findHandle(userNameContainer); + if (!handle) continue; + + const cached = userCache.get(handle); + const failed = failedHandles.has(handle); + + // Determine desired state + let state, badgeContent, bioText; + if (cached) { + state = 'loaded'; + badgeContent = 'f\u200a' + formatCount(cached.followers); + bioText = cached.bio; + } else if (failed) { + state = 'error'; + badgeContent = '!'; + } else { + state = 'loading'; + badgeContent = '\u2026'; // … + queueUserFetch(handle); + } + + // Skip if already in this state + if (prevState === state + ':' + handle) continue; + + // Remove old badge/bio if upgrading state + if (prevState) { + article.querySelectorAll('.tm-follower-badge').forEach(el => el.remove()); + article.querySelectorAll('.tm-bio-line').forEach(el => el.remove()); + } + + const timeEl = article.querySelector('time'); + if (!timeEl) continue; + const timeLink = timeEl.closest('a'); + const container = timeLink ? timeLink.parentElement : timeEl.parentElement; + if (!container) continue; + + article.setAttribute(BADGE_ATTR, state + ':' + handle); + + const badge = document.createElement('span'); + badge.className = 'tm-follower-badge'; + badge.innerHTML = badgeContent; + container.appendChild(badge); + + if (state === 'loaded' && bioText && userNameContainer) { + const bioEl = document.createElement('div'); + bioEl.className = 'tm-bio-line'; + bioEl.textContent = bioText.replace(/\n/g, ' '); + userNameContainer.parentElement.insertBefore(bioEl, userNameContainer.nextSibling); + } + } + } + + function processUserCells() { + const cells = document.querySelectorAll('[data-testid="UserCell"]'); + for (const cell of cells) { + const prevState = cell.getAttribute(BADGE_ATTR); + + // Prefer avatar data-testid (reliable), fall back to span scanning + let handle = findHandleFromAvatar(cell) || findHandle(cell); + if (!handle) continue; + + const cached = userCache.get(handle); + const failed = failedHandles.has(handle); + + let state, badgeContent; + if (cached) { + state = 'loaded'; + badgeContent = 'f\u200a' + formatCount(cached.followers); + } else if (failed) { + state = 'error'; + badgeContent = '!'; + } else { + state = 'loading'; + badgeContent = '\u2026'; + queueUserFetch(handle); + } + + if (prevState === state + ':' + handle) continue; + + if (prevState) { + cell.querySelectorAll('.tm-usercell-badge').forEach(el => el.remove()); + } + + cell.setAttribute(BADGE_ATTR, state + ':' + handle); + + const spans = cell.querySelectorAll('span'); + let handleSpan = null; + for (const span of spans) { + if (span.textContent.trim().toLowerCase() === '@' + handle && span.children.length === 0) { + handleSpan = span; + break; + } + } + + if (handleSpan) { + const badge = document.createElement('span'); + badge.className = 'tm-usercell-badge'; + badge.innerHTML = badgeContent; + handleSpan.parentElement.appendChild(badge); + } + } + } + + function processAll() { + injectStyles(); + cancelQueue(); + processTweets(); + processUserCells(); + } + + function startObserver() { + processAll(); + const observer = new MutationObserver(() => processAll()); + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + startObserver(); + } else { + document.addEventListener('DOMContentLoaded', startObserver); + } +})(); + diff --git a/blink/scripts/twitter-post-activity-hotkeys.js b/blink/scripts/twitter-post-activity-hotkeys.js new file mode 100644 index 0000000..beed73f --- /dev/null +++ b/blink/scripts/twitter-post-activity-hotkeys.js @@ -0,0 +1,228 @@ +// twitter-custom-keys.lib.js — Shared library for registering custom keyboard shortcuts +// @require'd by individual hotkey userscripts. Injects a "Custom" section into Twitter's +// built-in keyboard shortcuts dialog (opened with ?). +// +// Usage: window.__twitterCustomKeys.register(key, description) + +(function () { + 'use strict'; + + // Singleton guard — only the first script to load initializes + if (window.__twitterCustomKeys) return; + + const entries = []; + const SECTION_ID = 'tm-custom-keys-section'; + + window.__twitterCustomKeys = { + register(key, description) { + entries.push({ key, description }); + } + }; + + // Find the container holding all shortcut sections. + // Desktop: [role="dialog"] > ... > [data-viewportview="true"] > sections + // Mobile:
> ... > scrollable div > sections + // Identified by the h2#modal-header heading near [role="table"] elements. + // Uses structural markers (id, roles) instead of text to support all languages. + function findSectionsContainer() { + const header = document.getElementById('modal-header'); + if (!header) return null; + // Walk up from the header to find the ancestor containing [role="table"] sections + let el = header.parentElement; + while (el) { + if (el.querySelector('[role="table"]')) return el; + el = el.parentElement; + } + return null; + } + + function renderSection(container) { + // Remove previous render if any + const old = document.getElementById(SECTION_ID); + if (old) old.remove(); + + if (entries.length === 0) return; + + // Find an existing section to clone structure from. + // Each section wraps a heading + [role="table"]. The section is the + // table's parent (which may have varying CSS classes across views). + const existingTable = container.querySelector('[role="table"]'); + if (!existingTable) return; + const existingRow = existingTable.querySelector('[role="row"]'); + if (!existingRow) return; + const existingSection = existingTable.parentElement; + if (!existingSection) return; + + // Clone the entire section as our template + const section = existingSection.cloneNode(true); + section.id = SECTION_ID; + + // Update the heading text to "Custom" + const headingSpan = section.querySelector('h2[role="heading"] span'); + if (headingSpan) { + headingSpan.textContent = 'Custom'; + } + + // Get reference to the table, clear its rows, and rebuild + const table = section.querySelector('[role="table"]'); + table.innerHTML = ''; + + for (const { key, description } of entries) { + // Clone a row from the original dialog for correct classes + const row = existingRow.cloneNode(true); + + // First cell = description + const cells = row.querySelectorAll('[role="cell"]'); + const descCell = cells[0]; + const keyCell = cells[1]; + + // Set description text + const descSpan = descCell.querySelector('span'); + if (descSpan) { + descSpan.textContent = description; + } else { + descCell.textContent = description; + } + + // Set key — clear existing content and rebuild from a single-key template + keyCell.innerHTML = ''; + const existingKeyDiv = existingRow.querySelector('[role="cell"]:last-child > div'); + if (existingKeyDiv) { + const keyDiv = existingKeyDiv.cloneNode(true); + keyDiv.textContent = key; + keyCell.appendChild(keyDiv); + } else { + keyCell.textContent = key; + } + + table.appendChild(row); + } + + // Force onto its own row in the desktop flex layout (which assumes 3 columns) + section.style.flexBasis = '100%'; + + // Append after the last existing section + existingSection.parentElement.appendChild(section); + } + + function checkForShortcutsView() { + // Already injected + if (document.getElementById(SECTION_ID)) return; + + const container = findSectionsContainer(); + if (container) { + renderSection(container); + } + } + + // Watch for shortcuts view appearance (dialog on desktop, page on mobile) + const observer = new MutationObserver(checkForShortcutsView); + + function startObserver() { + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + startObserver(); + } else { + document.addEventListener('DOMContentLoaded', startObserver); + } + + console.log('[CustomKeys] Shared library loaded'); +})(); + + + +(function () { + 'use strict'; + + window.__twitterCustomKeys?.register('v', 'View post activity'); + window.__twitterCustomKeys?.register('q', 'Quotes tab'); + window.__twitterCustomKeys?.register('t', 'Reposts tab'); + window.__twitterCustomKeys?.register('l', 'Likes tab'); + + function isTyping() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return true; + if (el.getAttribute('contenteditable') === 'true') return true; + if (el.closest('[contenteditable="true"]')) return true; + return false; + } + + function getFocusedTweet() { + let el = document.activeElement; + while (el) { + if (el.matches && el.matches('article[data-testid="tweet"]')) return el; + el = el.parentElement; + } + return null; + } + + function clickTab(label) { + const tabs = document.querySelectorAll('[role="tab"]'); + for (const tab of tabs) { + if (tab.textContent.toLowerCase().includes(label.toLowerCase())) { + tab.click(); + return true; + } + } + return false; + } + + async function viewPostActivity() { + const article = getFocusedTweet(); + if (!article) return; + + const caret = article.querySelector('[data-testid="caret"]'); + if (!caret) return; + + caret.click(); + + // Wait for dropdown menu to appear + let menu = null; + for (let i = 0; i < 10; i++) { + await new Promise(r => setTimeout(r, 50)); + menu = document.querySelector('[role="menu"]'); + if (menu) break; + } + if (!menu) return; + + const items = menu.querySelectorAll('[role="menuitem"]'); + for (const item of items) { + if (item.textContent.includes('View post activity')) { + item.click(); + return; + } + } + + // Not found (not tweet author) — close menu + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + } + + document.addEventListener('keydown', function (e) { + if (isTyping()) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + + switch (e.key) { + case 'v': + e.preventDefault(); + e.stopPropagation(); + viewPostActivity(); + break; + case 'q': + if (clickTab('Quotes')) { e.preventDefault(); e.stopPropagation(); } + break; + case 't': + if (clickTab('Reposts')) { e.preventDefault(); e.stopPropagation(); } + break; + case 'l': + if (clickTab('Likes')) { e.preventDefault(); e.stopPropagation(); } + break; + } + }); + + console.log('[PostActivityHotkeys] Loaded: v=view activity, q=quotes, t=reposts, l=likes'); +})(); + diff --git a/blink/scripts/twitter-profile-hotkey.js b/blink/scripts/twitter-profile-hotkey.js new file mode 100644 index 0000000..df2c428 --- /dev/null +++ b/blink/scripts/twitter-profile-hotkey.js @@ -0,0 +1,195 @@ +// twitter-custom-keys.lib.js — Shared library for registering custom keyboard shortcuts +// @require'd by individual hotkey userscripts. Injects a "Custom" section into Twitter's +// built-in keyboard shortcuts dialog (opened with ?). +// +// Usage: window.__twitterCustomKeys.register(key, description) + +(function () { + 'use strict'; + + // Singleton guard — only the first script to load initializes + if (window.__twitterCustomKeys) return; + + const entries = []; + const SECTION_ID = 'tm-custom-keys-section'; + + window.__twitterCustomKeys = { + register(key, description) { + entries.push({ key, description }); + } + }; + + // Find the container holding all shortcut sections. + // Desktop: [role="dialog"] > ... > [data-viewportview="true"] > sections + // Mobile:
> ... > scrollable div > sections + // Identified by the h2#modal-header heading near [role="table"] elements. + // Uses structural markers (id, roles) instead of text to support all languages. + function findSectionsContainer() { + const header = document.getElementById('modal-header'); + if (!header) return null; + // Walk up from the header to find the ancestor containing [role="table"] sections + let el = header.parentElement; + while (el) { + if (el.querySelector('[role="table"]')) return el; + el = el.parentElement; + } + return null; + } + + function renderSection(container) { + // Remove previous render if any + const old = document.getElementById(SECTION_ID); + if (old) old.remove(); + + if (entries.length === 0) return; + + // Find an existing section to clone structure from. + // Each section wraps a heading + [role="table"]. The section is the + // table's parent (which may have varying CSS classes across views). + const existingTable = container.querySelector('[role="table"]'); + if (!existingTable) return; + const existingRow = existingTable.querySelector('[role="row"]'); + if (!existingRow) return; + const existingSection = existingTable.parentElement; + if (!existingSection) return; + + // Clone the entire section as our template + const section = existingSection.cloneNode(true); + section.id = SECTION_ID; + + // Update the heading text to "Custom" + const headingSpan = section.querySelector('h2[role="heading"] span'); + if (headingSpan) { + headingSpan.textContent = 'Custom'; + } + + // Get reference to the table, clear its rows, and rebuild + const table = section.querySelector('[role="table"]'); + table.innerHTML = ''; + + for (const { key, description } of entries) { + // Clone a row from the original dialog for correct classes + const row = existingRow.cloneNode(true); + + // First cell = description + const cells = row.querySelectorAll('[role="cell"]'); + const descCell = cells[0]; + const keyCell = cells[1]; + + // Set description text + const descSpan = descCell.querySelector('span'); + if (descSpan) { + descSpan.textContent = description; + } else { + descCell.textContent = description; + } + + // Set key — clear existing content and rebuild from a single-key template + keyCell.innerHTML = ''; + const existingKeyDiv = existingRow.querySelector('[role="cell"]:last-child > div'); + if (existingKeyDiv) { + const keyDiv = existingKeyDiv.cloneNode(true); + keyDiv.textContent = key; + keyCell.appendChild(keyDiv); + } else { + keyCell.textContent = key; + } + + table.appendChild(row); + } + + // Force onto its own row in the desktop flex layout (which assumes 3 columns) + section.style.flexBasis = '100%'; + + // Append after the last existing section + existingSection.parentElement.appendChild(section); + } + + function checkForShortcutsView() { + // Already injected + if (document.getElementById(SECTION_ID)) return; + + const container = findSectionsContainer(); + if (container) { + renderSection(container); + } + } + + // Watch for shortcuts view appearance (dialog on desktop, page on mobile) + const observer = new MutationObserver(checkForShortcutsView); + + function startObserver() { + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + startObserver(); + } else { + document.addEventListener('DOMContentLoaded', startObserver); + } + + console.log('[CustomKeys] Shared library loaded'); +})(); + + + +(function () { + 'use strict'; + + window.__twitterCustomKeys?.register('f', "Open author's profile"); + + function isTyping() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return true; + if (el.getAttribute('contenteditable') === 'true') return true; + if (el.closest('[contenteditable="true"]')) return true; + return false; + } + + function getFocusedTweet() { + let el = document.activeElement; + while (el) { + if (el.matches && el.matches('article[data-testid="tweet"]')) return el; + el = el.parentElement; + } + return null; + } + + function openProfile() { + const article = getFocusedTweet(); + if (!article) return; + + // Find the avatar link — it always points to the author's profile + const avatar = article.querySelector('[data-testid^="UserAvatar-Container-"] a[href]'); + if (avatar) { + avatar.click(); + return; + } + + // Fallback: find the @handle and navigate + const userNameContainer = article.querySelector('[data-testid="User-Name"]'); + if (userNameContainer) { + const link = userNameContainer.querySelector('a[href^="/"]'); + if (link) { + link.click(); + return; + } + } + } + + document.addEventListener('keydown', function (e) { + if (isTyping()) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + + if (e.key === 'f') { + e.preventDefault(); + e.stopPropagation(); + openProfile(); + } + }); + + console.log('[ProfileHotkey] Loaded: f=open profile'); +})(); + diff --git a/blink/scripts/twitter-quote-hotkey.js b/blink/scripts/twitter-quote-hotkey.js new file mode 100644 index 0000000..e1416a4 --- /dev/null +++ b/blink/scripts/twitter-quote-hotkey.js @@ -0,0 +1,221 @@ +// twitter-custom-keys.lib.js — Shared library for registering custom keyboard shortcuts +// @require'd by individual hotkey userscripts. Injects a "Custom" section into Twitter's +// built-in keyboard shortcuts dialog (opened with ?). +// +// Usage: window.__twitterCustomKeys.register(key, description) + +(function () { + 'use strict'; + + // Singleton guard — only the first script to load initializes + if (window.__twitterCustomKeys) return; + + const entries = []; + const SECTION_ID = 'tm-custom-keys-section'; + + window.__twitterCustomKeys = { + register(key, description) { + entries.push({ key, description }); + } + }; + + // Find the container holding all shortcut sections. + // Desktop: [role="dialog"] > ... > [data-viewportview="true"] > sections + // Mobile:
> ... > scrollable div > sections + // Identified by the h2#modal-header heading near [role="table"] elements. + // Uses structural markers (id, roles) instead of text to support all languages. + function findSectionsContainer() { + const header = document.getElementById('modal-header'); + if (!header) return null; + // Walk up from the header to find the ancestor containing [role="table"] sections + let el = header.parentElement; + while (el) { + if (el.querySelector('[role="table"]')) return el; + el = el.parentElement; + } + return null; + } + + function renderSection(container) { + // Remove previous render if any + const old = document.getElementById(SECTION_ID); + if (old) old.remove(); + + if (entries.length === 0) return; + + // Find an existing section to clone structure from. + // Each section wraps a heading + [role="table"]. The section is the + // table's parent (which may have varying CSS classes across views). + const existingTable = container.querySelector('[role="table"]'); + if (!existingTable) return; + const existingRow = existingTable.querySelector('[role="row"]'); + if (!existingRow) return; + const existingSection = existingTable.parentElement; + if (!existingSection) return; + + // Clone the entire section as our template + const section = existingSection.cloneNode(true); + section.id = SECTION_ID; + + // Update the heading text to "Custom" + const headingSpan = section.querySelector('h2[role="heading"] span'); + if (headingSpan) { + headingSpan.textContent = 'Custom'; + } + + // Get reference to the table, clear its rows, and rebuild + const table = section.querySelector('[role="table"]'); + table.innerHTML = ''; + + for (const { key, description } of entries) { + // Clone a row from the original dialog for correct classes + const row = existingRow.cloneNode(true); + + // First cell = description + const cells = row.querySelectorAll('[role="cell"]'); + const descCell = cells[0]; + const keyCell = cells[1]; + + // Set description text + const descSpan = descCell.querySelector('span'); + if (descSpan) { + descSpan.textContent = description; + } else { + descCell.textContent = description; + } + + // Set key — clear existing content and rebuild from a single-key template + keyCell.innerHTML = ''; + const existingKeyDiv = existingRow.querySelector('[role="cell"]:last-child > div'); + if (existingKeyDiv) { + const keyDiv = existingKeyDiv.cloneNode(true); + keyDiv.textContent = key; + keyCell.appendChild(keyDiv); + } else { + keyCell.textContent = key; + } + + table.appendChild(row); + } + + // Force onto its own row in the desktop flex layout (which assumes 3 columns) + section.style.flexBasis = '100%'; + + // Append after the last existing section + existingSection.parentElement.appendChild(section); + } + + function checkForShortcutsView() { + // Already injected + if (document.getElementById(SECTION_ID)) return; + + const container = findSectionsContainer(); + if (container) { + renderSection(container); + } + } + + // Watch for shortcuts view appearance (dialog on desktop, page on mobile) + const observer = new MutationObserver(checkForShortcutsView); + + function startObserver() { + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + startObserver(); + } else { + document.addEventListener('DOMContentLoaded', startObserver); + } + + console.log('[CustomKeys] Shared library loaded'); +})(); + + + +(function () { + 'use strict'; + + window.__twitterCustomKeys?.register('p', 'Open quote tweet'); + + // Track chord prefixes (e.g. g+p = go to profile) + const CHORD_PREFIXES = ['g']; + const CHORD_TIMEOUT = 1000; + let chordPending = false; + let chordTimer = null; + + document.addEventListener('keydown', function (e) { + if (CHORD_PREFIXES.includes(e.key)) { + chordPending = true; + clearTimeout(chordTimer); + chordTimer = setTimeout(() => { chordPending = false; }, CHORD_TIMEOUT); + } + }, true); + + function isTyping() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return true; + if (el.getAttribute('contenteditable') === 'true') return true; + if (el.closest('[contenteditable="true"]')) return true; + return false; + } + + function getFocusedTweet() { + let el = document.activeElement; + while (el) { + if (el.matches && el.matches('article[data-testid="tweet"]')) return el; + el = el.parentElement; + } + return null; + } + + function openQuoteTweet() { + const article = getFocusedTweet(); + if (!article) return; + + // Strategy 1: data-testid for quote tweet + const quote = article.querySelector('[data-testid="quoteTweet"]'); + if (quote) { + const link = quote.querySelector('a[href*="/status/"]'); + if (link) { link.click(); return; } + quote.click(); + return; + } + + // Strategy 2: Find the tweet's own permalink, then look for a different /status/ link + // that isn't in the action bar area (views/analytics) + const timeLink = article.querySelector('time')?.closest('a[href*="/status/"]'); + if (!timeLink) return; + const tweetHref = timeLink.getAttribute('href'); + + const links = article.querySelectorAll('a[href*="/status/"]'); + for (const link of links) { + const href = link.getAttribute('href'); + // Skip the tweet's own permalink and analytics/views links + if (href === tweetHref) continue; + if (href.includes('/analytics')) continue; + // Must be a different tweet's /status/ URL — likely the quote tweet + if (/\/status\/\d+/.test(href)) { + link.click(); + return; + } + } + } + + document.addEventListener('keydown', function (e) { + if (isTyping()) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + + if (e.key === 'p') { + if (chordPending) { chordPending = false; return; } + e.preventDefault(); + e.stopPropagation(); + openQuoteTweet(); + } + }); + + console.log('[QuoteHotkey] Loaded: p=open quote tweet'); +})(); + diff --git a/blink/scripts/twitter-usercell-hotkeys.js b/blink/scripts/twitter-usercell-hotkeys.js new file mode 100644 index 0000000..24f4ae7 --- /dev/null +++ b/blink/scripts/twitter-usercell-hotkeys.js @@ -0,0 +1,252 @@ +// twitter-custom-keys.lib.js — Shared library for registering custom keyboard shortcuts +// @require'd by individual hotkey userscripts. Injects a "Custom" section into Twitter's +// built-in keyboard shortcuts dialog (opened with ?). +// +// Usage: window.__twitterCustomKeys.register(key, description) + +(function () { + 'use strict'; + + // Singleton guard — only the first script to load initializes + if (window.__twitterCustomKeys) return; + + const entries = []; + const SECTION_ID = 'tm-custom-keys-section'; + + window.__twitterCustomKeys = { + register(key, description) { + entries.push({ key, description }); + } + }; + + // Find the container holding all shortcut sections. + // Desktop: [role="dialog"] > ... > [data-viewportview="true"] > sections + // Mobile:
> ... > scrollable div > sections + // Identified by the h2#modal-header heading near [role="table"] elements. + // Uses structural markers (id, roles) instead of text to support all languages. + function findSectionsContainer() { + const header = document.getElementById('modal-header'); + if (!header) return null; + // Walk up from the header to find the ancestor containing [role="table"] sections + let el = header.parentElement; + while (el) { + if (el.querySelector('[role="table"]')) return el; + el = el.parentElement; + } + return null; + } + + function renderSection(container) { + // Remove previous render if any + const old = document.getElementById(SECTION_ID); + if (old) old.remove(); + + if (entries.length === 0) return; + + // Find an existing section to clone structure from. + // Each section wraps a heading + [role="table"]. The section is the + // table's parent (which may have varying CSS classes across views). + const existingTable = container.querySelector('[role="table"]'); + if (!existingTable) return; + const existingRow = existingTable.querySelector('[role="row"]'); + if (!existingRow) return; + const existingSection = existingTable.parentElement; + if (!existingSection) return; + + // Clone the entire section as our template + const section = existingSection.cloneNode(true); + section.id = SECTION_ID; + + // Update the heading text to "Custom" + const headingSpan = section.querySelector('h2[role="heading"] span'); + if (headingSpan) { + headingSpan.textContent = 'Custom'; + } + + // Get reference to the table, clear its rows, and rebuild + const table = section.querySelector('[role="table"]'); + table.innerHTML = ''; + + for (const { key, description } of entries) { + // Clone a row from the original dialog for correct classes + const row = existingRow.cloneNode(true); + + // First cell = description + const cells = row.querySelectorAll('[role="cell"]'); + const descCell = cells[0]; + const keyCell = cells[1]; + + // Set description text + const descSpan = descCell.querySelector('span'); + if (descSpan) { + descSpan.textContent = description; + } else { + descCell.textContent = description; + } + + // Set key — clear existing content and rebuild from a single-key template + keyCell.innerHTML = ''; + const existingKeyDiv = existingRow.querySelector('[role="cell"]:last-child > div'); + if (existingKeyDiv) { + const keyDiv = existingKeyDiv.cloneNode(true); + keyDiv.textContent = key; + keyCell.appendChild(keyDiv); + } else { + keyCell.textContent = key; + } + + table.appendChild(row); + } + + // Force onto its own row in the desktop flex layout (which assumes 3 columns) + section.style.flexBasis = '100%'; + + // Append after the last existing section + existingSection.parentElement.appendChild(section); + } + + function checkForShortcutsView() { + // Already injected + if (document.getElementById(SECTION_ID)) return; + + const container = findSectionsContainer(); + if (container) { + renderSection(container); + } + } + + // Watch for shortcuts view appearance (dialog on desktop, page on mobile) + const observer = new MutationObserver(checkForShortcutsView); + + function startObserver() { + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + startObserver(); + } else { + document.addEventListener('DOMContentLoaded', startObserver); + } + + console.log('[CustomKeys] Shared library loaded'); +})(); + + + +(function () { + 'use strict'; + + window.__twitterCustomKeys?.register('x', 'Block user (user cell)'); + window.__twitterCustomKeys?.register('f', 'Open profile (user cell)'); + window.__twitterCustomKeys?.register('u', 'Mute user (user cell)'); + window.__twitterCustomKeys?.register('w', 'Follow user (user cell)'); + + function isTyping() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return true; + if (el.getAttribute('contenteditable') === 'true') return true; + if (el.closest('[contenteditable="true"]')) return true; + return false; + } + + function getFocusedUserCell() { + let el = document.activeElement; + while (el) { + if (el.matches && el.matches('[data-testid="UserCell"]')) return el; + el = el.parentElement; + } + return null; + } + + function openProfile(cell) { + const link = cell.querySelector('a[href^="/"][role="link"]'); + if (link) { link.click(); return; } + const links = cell.querySelectorAll('a[href^="/"]'); + for (const l of links) { + const href = l.getAttribute('href'); + if (href && /^\/[a-zA-Z0-9_]+$/.test(href)) { + l.click(); + return; + } + } + } + + function followUser(cell) { + const btn = cell.querySelector('[data-testid$="-follow"]'); + if (btn) { btn.click(); return; } + const buttons = cell.querySelectorAll('[role="button"]'); + for (const b of buttons) { + if (b.textContent.trim() === 'Follow') { b.click(); return; } + } + } + + async function clickMenuItem(cell, label) { + // Find the three-dot / more button in the cell + const caret = cell.querySelector('[data-testid="caret"]') + || cell.querySelector('[aria-label="More"]') + || cell.querySelector('[data-testid^="UserCell"] [role="button"]:last-of-type'); + + // Some user cells have inline action buttons instead of a caret menu + // Try finding a button with matching aria-label + const inlineBtn = cell.querySelector(`[aria-label*="${label}" i]`); + if (inlineBtn) { inlineBtn.click(); return; } + + if (!caret) return; + caret.click(); + + let menu = null; + for (let i = 0; i < 10; i++) { + await new Promise(r => setTimeout(r, 50)); + menu = document.querySelector('[role="menu"]'); + if (menu) break; + } + if (!menu) return; + + const items = menu.querySelectorAll('[role="menuitem"]'); + for (const item of items) { + if (item.textContent.includes(label)) { + item.click(); + return; + } + } + + // Not found — close menu + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + } + + document.addEventListener('keydown', function (e) { + if (isTyping()) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + + const cell = getFocusedUserCell(); + if (!cell) return; + + switch (e.key) { + case 'x': + e.preventDefault(); + e.stopPropagation(); + clickMenuItem(cell, 'Block'); + break; + case 'f': + e.preventDefault(); + e.stopPropagation(); + openProfile(cell); + break; + case 'u': + e.preventDefault(); + e.stopPropagation(); + clickMenuItem(cell, 'Mute'); + break; + case 'w': + e.preventDefault(); + e.stopPropagation(); + followUser(cell); + break; + } + }); + + console.log('[UserCellHotkeys] Loaded: x=block, f=profile, u=mute, w=follow'); +})(); + diff --git a/moz/README.md b/moz/README.md new file mode 100644 index 0000000..73570c2 --- /dev/null +++ b/moz/README.md @@ -0,0 +1,4 @@ +# Firefox build output + +Generated by `npm run build:extensions`. +Do not edit files in this folder by hand. diff --git a/moz/manifest.json b/moz/manifest.json new file mode 100644 index 0000000..813ead2 --- /dev/null +++ b/moz/manifest.json @@ -0,0 +1,84 @@ +{ + "manifest_version": 3, + "name": "Twitter Userscripts (Firefox)", + "version": "1.7.1", + "description": "Built from userscripts in this repository. Source files stay in place for userscript-manager installs.", + "content_scripts": [ + { + "matches": [ + "https://twitter.com/*", + "https://x.com/*" + ], + "js": [ + "scripts/twitter-backspace-back.js" + ], + "run_at": "document_idle" + }, + { + "matches": [ + "https://twitter.com/*", + "https://x.com/*" + ], + "js": [ + "scripts/twitter-delete-hotkey.js" + ], + "run_at": "document_idle" + }, + { + "matches": [ + "https://twitter.com/*", + "https://x.com/*" + ], + "js": [ + "scripts/twitter-inline-follower-count.js" + ], + "run_at": "document_start" + }, + { + "matches": [ + "https://twitter.com/*", + "https://x.com/*" + ], + "js": [ + "scripts/twitter-post-activity-hotkeys.js" + ], + "run_at": "document_idle" + }, + { + "matches": [ + "https://twitter.com/*", + "https://x.com/*" + ], + "js": [ + "scripts/twitter-profile-hotkey.js" + ], + "run_at": "document_idle" + }, + { + "matches": [ + "https://twitter.com/*", + "https://x.com/*" + ], + "js": [ + "scripts/twitter-quote-hotkey.js" + ], + "run_at": "document_idle" + }, + { + "matches": [ + "https://twitter.com/*", + "https://x.com/*" + ], + "js": [ + "scripts/twitter-usercell-hotkeys.js" + ], + "run_at": "document_idle" + } + ], + "browser_specific_settings": { + "gecko": { + "id": "twitter-userscripts@digitalby.github", + "strict_min_version": "109.0" + } + } +} diff --git a/moz/scripts/twitter-backspace-back.js b/moz/scripts/twitter-backspace-back.js new file mode 100644 index 0000000..e7973c0 --- /dev/null +++ b/moz/scripts/twitter-backspace-back.js @@ -0,0 +1,156 @@ +// twitter-custom-keys.lib.js — Shared library for registering custom keyboard shortcuts +// @require'd by individual hotkey userscripts. Injects a "Custom" section into Twitter's +// built-in keyboard shortcuts dialog (opened with ?). +// +// Usage: window.__twitterCustomKeys.register(key, description) + +(function () { + 'use strict'; + + // Singleton guard — only the first script to load initializes + if (window.__twitterCustomKeys) return; + + const entries = []; + const SECTION_ID = 'tm-custom-keys-section'; + + window.__twitterCustomKeys = { + register(key, description) { + entries.push({ key, description }); + } + }; + + // Find the container holding all shortcut sections. + // Desktop: [role="dialog"] > ... > [data-viewportview="true"] > sections + // Mobile:
> ... > scrollable div > sections + // Identified by the h2#modal-header heading near [role="table"] elements. + // Uses structural markers (id, roles) instead of text to support all languages. + function findSectionsContainer() { + const header = document.getElementById('modal-header'); + if (!header) return null; + // Walk up from the header to find the ancestor containing [role="table"] sections + let el = header.parentElement; + while (el) { + if (el.querySelector('[role="table"]')) return el; + el = el.parentElement; + } + return null; + } + + function renderSection(container) { + // Remove previous render if any + const old = document.getElementById(SECTION_ID); + if (old) old.remove(); + + if (entries.length === 0) return; + + // Find an existing section to clone structure from. + // Each section wraps a heading + [role="table"]. The section is the + // table's parent (which may have varying CSS classes across views). + const existingTable = container.querySelector('[role="table"]'); + if (!existingTable) return; + const existingRow = existingTable.querySelector('[role="row"]'); + if (!existingRow) return; + const existingSection = existingTable.parentElement; + if (!existingSection) return; + + // Clone the entire section as our template + const section = existingSection.cloneNode(true); + section.id = SECTION_ID; + + // Update the heading text to "Custom" + const headingSpan = section.querySelector('h2[role="heading"] span'); + if (headingSpan) { + headingSpan.textContent = 'Custom'; + } + + // Get reference to the table, clear its rows, and rebuild + const table = section.querySelector('[role="table"]'); + table.innerHTML = ''; + + for (const { key, description } of entries) { + // Clone a row from the original dialog for correct classes + const row = existingRow.cloneNode(true); + + // First cell = description + const cells = row.querySelectorAll('[role="cell"]'); + const descCell = cells[0]; + const keyCell = cells[1]; + + // Set description text + const descSpan = descCell.querySelector('span'); + if (descSpan) { + descSpan.textContent = description; + } else { + descCell.textContent = description; + } + + // Set key — clear existing content and rebuild from a single-key template + keyCell.innerHTML = ''; + const existingKeyDiv = existingRow.querySelector('[role="cell"]:last-child > div'); + if (existingKeyDiv) { + const keyDiv = existingKeyDiv.cloneNode(true); + keyDiv.textContent = key; + keyCell.appendChild(keyDiv); + } else { + keyCell.textContent = key; + } + + table.appendChild(row); + } + + // Force onto its own row in the desktop flex layout (which assumes 3 columns) + section.style.flexBasis = '100%'; + + // Append after the last existing section + existingSection.parentElement.appendChild(section); + } + + function checkForShortcutsView() { + // Already injected + if (document.getElementById(SECTION_ID)) return; + + const container = findSectionsContainer(); + if (container) { + renderSection(container); + } + } + + // Watch for shortcuts view appearance (dialog on desktop, page on mobile) + const observer = new MutationObserver(checkForShortcutsView); + + function startObserver() { + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + startObserver(); + } else { + document.addEventListener('DOMContentLoaded', startObserver); + } + + console.log('[CustomKeys] Shared library loaded'); +})(); + + + +(function () { + 'use strict'; + + window.__twitterCustomKeys?.register('Backspace', 'Go back'); + + document.addEventListener('keydown', function (e) { + if (e.key !== 'Backspace') return; + + const tag = document.activeElement.tagName; + const isEditable = document.activeElement.isContentEditable; + if (tag === 'INPUT' || tag === 'TEXTAREA' || isEditable) return; + + // Only act if Twitter's back button is visible + const backBtn = document.querySelector('[data-testid="app-bar-back"]'); + if (!backBtn || backBtn.offsetParent === null) return; + + e.preventDefault(); + backBtn.click(); + }); +})(); + diff --git a/moz/scripts/twitter-delete-hotkey.js b/moz/scripts/twitter-delete-hotkey.js new file mode 100644 index 0000000..d076541 --- /dev/null +++ b/moz/scripts/twitter-delete-hotkey.js @@ -0,0 +1,202 @@ +// twitter-custom-keys.lib.js — Shared library for registering custom keyboard shortcuts +// @require'd by individual hotkey userscripts. Injects a "Custom" section into Twitter's +// built-in keyboard shortcuts dialog (opened with ?). +// +// Usage: window.__twitterCustomKeys.register(key, description) + +(function () { + 'use strict'; + + // Singleton guard — only the first script to load initializes + if (window.__twitterCustomKeys) return; + + const entries = []; + const SECTION_ID = 'tm-custom-keys-section'; + + window.__twitterCustomKeys = { + register(key, description) { + entries.push({ key, description }); + } + }; + + // Find the container holding all shortcut sections. + // Desktop: [role="dialog"] > ... > [data-viewportview="true"] > sections + // Mobile:
> ... > scrollable div > sections + // Identified by the h2#modal-header heading near [role="table"] elements. + // Uses structural markers (id, roles) instead of text to support all languages. + function findSectionsContainer() { + const header = document.getElementById('modal-header'); + if (!header) return null; + // Walk up from the header to find the ancestor containing [role="table"] sections + let el = header.parentElement; + while (el) { + if (el.querySelector('[role="table"]')) return el; + el = el.parentElement; + } + return null; + } + + function renderSection(container) { + // Remove previous render if any + const old = document.getElementById(SECTION_ID); + if (old) old.remove(); + + if (entries.length === 0) return; + + // Find an existing section to clone structure from. + // Each section wraps a heading + [role="table"]. The section is the + // table's parent (which may have varying CSS classes across views). + const existingTable = container.querySelector('[role="table"]'); + if (!existingTable) return; + const existingRow = existingTable.querySelector('[role="row"]'); + if (!existingRow) return; + const existingSection = existingTable.parentElement; + if (!existingSection) return; + + // Clone the entire section as our template + const section = existingSection.cloneNode(true); + section.id = SECTION_ID; + + // Update the heading text to "Custom" + const headingSpan = section.querySelector('h2[role="heading"] span'); + if (headingSpan) { + headingSpan.textContent = 'Custom'; + } + + // Get reference to the table, clear its rows, and rebuild + const table = section.querySelector('[role="table"]'); + table.innerHTML = ''; + + for (const { key, description } of entries) { + // Clone a row from the original dialog for correct classes + const row = existingRow.cloneNode(true); + + // First cell = description + const cells = row.querySelectorAll('[role="cell"]'); + const descCell = cells[0]; + const keyCell = cells[1]; + + // Set description text + const descSpan = descCell.querySelector('span'); + if (descSpan) { + descSpan.textContent = description; + } else { + descCell.textContent = description; + } + + // Set key — clear existing content and rebuild from a single-key template + keyCell.innerHTML = ''; + const existingKeyDiv = existingRow.querySelector('[role="cell"]:last-child > div'); + if (existingKeyDiv) { + const keyDiv = existingKeyDiv.cloneNode(true); + keyDiv.textContent = key; + keyCell.appendChild(keyDiv); + } else { + keyCell.textContent = key; + } + + table.appendChild(row); + } + + // Force onto its own row in the desktop flex layout (which assumes 3 columns) + section.style.flexBasis = '100%'; + + // Append after the last existing section + existingSection.parentElement.appendChild(section); + } + + function checkForShortcutsView() { + // Already injected + if (document.getElementById(SECTION_ID)) return; + + const container = findSectionsContainer(); + if (container) { + renderSection(container); + } + } + + // Watch for shortcuts view appearance (dialog on desktop, page on mobile) + const observer = new MutationObserver(checkForShortcutsView); + + function startObserver() { + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + startObserver(); + } else { + document.addEventListener('DOMContentLoaded', startObserver); + } + + console.log('[CustomKeys] Shared library loaded'); +})(); + + + +(function () { + 'use strict'; + + window.__twitterCustomKeys?.register('d', 'Delete focused tweet'); + + function isTyping() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return true; + if (el.getAttribute('contenteditable') === 'true') return true; + if (el.closest('[contenteditable="true"]')) return true; + return false; + } + + function getFocusedTweet() { + let el = document.activeElement; + while (el) { + if (el.matches && el.matches('article[data-testid="tweet"]')) return el; + el = el.parentElement; + } + return null; + } + + async function deleteTweet() { + const article = getFocusedTweet(); + if (!article) return; + + const caret = article.querySelector('[data-testid="caret"]'); + if (!caret) return; + + caret.click(); + + let menu = null; + for (let i = 0; i < 10; i++) { + await new Promise(r => setTimeout(r, 50)); + menu = document.querySelector('[role="menu"]'); + if (menu) break; + } + if (!menu) return; + + const items = menu.querySelectorAll('[role="menuitem"]'); + for (const item of items) { + if (item.textContent.includes('Delete')) { + item.click(); + return; + } + } + + // Not found (not tweet author) — close menu + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + } + + document.addEventListener('keydown', function (e) { + if (isTyping()) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + + if (e.key === 'd') { + e.preventDefault(); + e.stopPropagation(); + deleteTweet(); + } + }); + + console.log('[DeleteHotkey] Loaded: d=delete tweet'); +})(); + diff --git a/moz/scripts/twitter-inline-follower-count.js b/moz/scripts/twitter-inline-follower-count.js new file mode 100644 index 0000000..84d1e12 --- /dev/null +++ b/moz/scripts/twitter-inline-follower-count.js @@ -0,0 +1,565 @@ + +(function () { + 'use strict'; + + const CACHE_KEY = 'tm-follower-cache'; + const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days + const RATE_WINDOW = 15 * 60 * 1000; // 15 minutes + const RATE_MAX = 1000; // max requests per window + const RATE_PAUSE = 15 * 60 * 1000; // pause duration on 429 + const userCache = new Map(); // handle -> { followers, bio, ts } + let requestTimestamps = []; // timestamps of recent API calls + let ratePausedUntil = 0; // if > Date.now(), we're paused + + // Load persisted cache from localStorage + try { + const stored = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}'); + const now = Date.now(); + for (const [handle, entry] of Object.entries(stored)) { + if (entry.ts && (now - entry.ts) < CACHE_TTL) { + userCache.set(handle, entry); + } + } + console.log('[FollowerCount] Loaded', userCache.size, 'cached users from localStorage'); + } catch {} + + let saveTimer = null; + function persistCache() { + if (saveTimer) return; + saveTimer = setTimeout(() => { + saveTimer = null; + try { + const obj = Object.fromEntries(userCache); + localStorage.setItem(CACHE_KEY, JSON.stringify(obj)); + } catch {} + }, 1000); + } + + function formatCount(n) { + if (n >= 1e6) { + const val = n / 1e6; + return (val >= 10 ? Math.round(val) : val.toFixed(1).replace(/\.0$/, '')) + 'M'; + } + if (n >= 1e3) { + const val = n / 1e3; + return (val >= 10 ? Math.round(val) : val.toFixed(1).replace(/\.0$/, '')) + 'K'; + } + return String(n); + } + + let reprocessTimer = null; + + function scheduleReprocess() { + if (reprocessTimer) return; + reprocessTimer = setTimeout(() => { + reprocessTimer = null; + processAll(); + }, 100); + } + + // Dynamic GraphQL API fetch — discovers query ID from Twitter's own JS bundles + const BEARER = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'; + const fetchQueued = new Set(); + const failedHandles = new Set(); + let fetchQueue = []; + let fetchRunning = false; + let queueGeneration = 0; // incremented on cancellation + let consecutiveRequests = 0; // for escalating delay + let discoveredQueryId = null; + let discoveryPromise = null; + let capturedFeatures = null; + + function getCsrfToken() { + const match = document.cookie.match(/(?:^|;\s*)ct0=([^;]+)/); + return match ? match[1] : ''; + } + + function randomDelay(min, max) { + return min + Math.random() * (max - min); + } + + // Discover the UserByScreenName query ID from Twitter's loaded JS chunks + const QUERY_ID_PATTERNS = [ + /queryId:"([^"]+)",operationName:"UserByScreenName"/, + /operationName:"UserByScreenName",queryId:"([^"]+)"/, + /queryId:"([^"]+)"[^}]{0,100}operationName:"UserByScreenName"/, + /operationName:"UserByScreenName"[^}]{0,100}queryId:"([^"]+)"/, + ]; + + async function discoverQueryId() { + if (discoveredQueryId) return discoveredQueryId; + console.log('[FollowerCount] Starting query ID discovery...'); + const scripts = document.querySelectorAll('script[src]'); + console.log('[FollowerCount] Found', scripts.length, 'script tags to scan'); + let scanned = 0, failed = 0; + for (const script of scripts) { + try { + const resp = await origFetch(script.src); + if (!resp.ok) { failed++; continue; } + const text = await resp.text(); + scanned++; + for (const pattern of QUERY_ID_PATTERNS) { + const match = text.match(pattern); + if (match) { + discoveredQueryId = match[1]; + console.log('[FollowerCount] Discovered queryId:', discoveredQueryId, 'from', script.src); + // Extract featureSwitches near the UserByScreenName definition + const idx = match.index; + const nearby = text.substring(idx, idx + 2000); + const featMatch = nearby.match(/featureSwitches:\[([^\]]+)\]/); + if (featMatch) { + const switches = featMatch[1].match(/"([^"]+)"/g).map(s => s.slice(1, -1)); + const featObj = {}; + switches.forEach(s => { featObj[s] = true; }); + capturedFeatures = JSON.stringify(featObj); + console.log('[FollowerCount] Extracted', switches.length, 'features from bundle'); + } + return discoveredQueryId; + } + } + } catch (e) { + failed++; + console.debug('[FollowerCount] Failed to fetch script:', script.src, e.message); + } + } + console.warn('[FollowerCount] Query ID not found. Scanned:', scanned, 'Failed:', failed); + return null; + } + + // Retry discovery with delay (scripts may load late) + async function discoverQueryIdWithRetry() { + for (let attempt = 0; attempt < 3; attempt++) { + const id = await discoverQueryId(); + if (id) return id; + console.log('[FollowerCount] Discovery attempt', attempt + 1, 'failed, retrying in', (attempt + 1) * 2, 's...'); + await new Promise(r => setTimeout(r, (attempt + 1) * 2000)); + discoveryPromise = null; // allow re-run + } + return null; + } + + function cancelQueue() { + queueGeneration++; + fetchQueue = []; + fetchQueued.clear(); + // Don't reset consecutiveRequests — the escalation must persist across queue rebuilds + } + + function queueUserFetch(handle) { + if (fetchQueued.has(handle)) return; + // Skip if we already have a fresh cache entry + const cached = userCache.get(handle); + if (cached && cached.ts && (Date.now() - cached.ts) < CACHE_TTL) return; + fetchQueued.add(handle); + fetchQueue.push(handle); + if (!fetchRunning) drainFetchQueue(); + } + + function isRateLimited() { + const now = Date.now(); + if (now < ratePausedUntil) return true; + requestTimestamps = requestTimestamps.filter(t => (now - t) < RATE_WINDOW); + return requestTimestamps.length >= RATE_MAX; + } + + function drainFetchQueue() { + const gen = queueGeneration; + if (fetchRunning || fetchQueue.length === 0) return; + if (isRateLimited()) { + const retryIn = ratePausedUntil > Date.now() + ? ratePausedUntil - Date.now() + : 30000; + console.log('[FollowerCount] Rate limited, retrying in', Math.round(retryIn / 1000), 's (' + fetchQueue.length, 'queued)'); + setTimeout(() => { if (queueGeneration === gen) drainFetchQueue(); }, retryIn); + return; + } + fetchRunning = true; + const handle = fetchQueue.shift(); + requestTimestamps.push(Date.now()); + fetchUserByScreenName(handle).finally(() => { + fetchRunning = false; + if (queueGeneration !== gen) return; + // Escalating delay: 1-3s, 4-6s, 7-9s, 10-12s, ... no cap, no decay + const delay = randomDelay(1000 + consecutiveRequests * 3000, 3000 + consecutiveRequests * 3000); + consecutiveRequests++; + console.log('[FollowerCount] Next fetch in', Math.round(delay / 1000), 's (step', consecutiveRequests, ')'); + setTimeout(() => { if (queueGeneration === gen) drainFetchQueue(); }, delay); + }); + } + + async function fetchUserByScreenName(screenName) { + try { + // Ensure we have the query ID (shared single discovery with retry) + if (!discoveredQueryId) { + if (!discoveryPromise) discoveryPromise = discoverQueryIdWithRetry(); + await discoveryPromise; + } + if (!discoveredQueryId) { + console.warn('[FollowerCount] No queryId available, skipping fetch for', screenName); + fetchQueued.delete(screenName.toLowerCase()); // allow retry later + return; + } + + if (!capturedFeatures) { + console.warn('[FollowerCount] No features available, skipping fetch for', screenName); + fetchQueued.delete(screenName.toLowerCase()); // allow retry later + return; + } + + const variables = JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true }); + const params = new URLSearchParams({ variables, features: capturedFeatures, fieldToggles: '{}' }); + const url = `https://x.com/i/api/graphql/${discoveredQueryId}/UserByScreenName?${params}`; + + const resp = await origFetch(url, { + headers: { + 'authorization': `Bearer ${decodeURIComponent(BEARER)}`, + 'x-csrf-token': getCsrfToken(), + 'x-twitter-active-user': 'yes', + 'x-twitter-auth-type': 'OAuth2Session', + }, + credentials: 'include', + }); + if (resp.status === 429) { + ratePausedUntil = Date.now() + RATE_PAUSE; + console.warn('[FollowerCount] Rate limited (429)! Pausing for 15 minutes.'); + fetchQueued.delete(screenName.toLowerCase()); // allow retry later + fetchQueue.unshift(screenName); // put it back at the front + return; + } + if (!resp.ok) { + console.warn('[FollowerCount] API error for', screenName, resp.status); + failedHandles.add(screenName.toLowerCase()); + scheduleReprocess(); + return; + } + const json = await resp.json(); + // Direct extraction — we already know the screenName, just find followers_count + bio + const result = json?.data?.user?.result; + const fc = result?.legacy?.followers_count + ?? result?.followers_count + ?? findFollowersCount(result); + const bio = result?.legacy?.description + ?? result?.profile_bio?.description + ?? findStringField(result, 'description'); + if (typeof fc === 'number') { + cacheUser(screenName, fc, bio || ''); + } else { + console.warn('[FollowerCount] Could not find followers_count for', screenName); + failedHandles.add(screenName.toLowerCase()); + scheduleReprocess(); + } + // Also run generic extraction for any other user data in the response + extractUsers(json, 0); + } catch (e) { + console.warn('[FollowerCount] Fetch failed for', screenName, e); + failedHandles.add(screenName.toLowerCase()); + scheduleReprocess(); + } + } + + // Deep search for followers_count in an object + function findFollowersCount(obj, depth = 0) { + if (!obj || typeof obj !== 'object' || depth > 20) return null; + if (typeof obj.followers_count === 'number') return obj.followers_count; + for (const key of Object.keys(obj)) { + const val = obj[key]; + if (val && typeof val === 'object') { + const found = findFollowersCount(val, depth + 1); + if (found !== null) return found; + } + } + return null; + } + + // Deep search for a string field by name + function findStringField(obj, fieldName, depth = 0) { + if (!obj || typeof obj !== 'object' || depth > 10) return null; + if (typeof obj[fieldName] === 'string' && obj[fieldName].length > 0) return obj[fieldName]; + for (const key of Object.keys(obj)) { + const val = obj[key]; + if (val && typeof val === 'object' && !Array.isArray(val)) { + const found = findStringField(val, fieldName, depth + 1); + if (found) return found; + } + } + return null; + } + + function cacheUser(screenName, followersCount, bio) { + const handle = screenName.toLowerCase(); + const prev = userCache.get(handle); + const entry = { followers: followersCount, bio: bio ?? prev?.bio ?? '', ts: Date.now() }; + userCache.set(handle, entry); + persistCache(); + if (!prev) { + console.log('[FollowerCount] Cached:', handle, formatCount(followersCount)); + scheduleReprocess(); + } + } + + function extractUsers(obj, depth) { + if (!obj || typeof obj !== 'object') return; + if (depth > 50) return; + // Standard legacy structure + if (obj.legacy && typeof obj.legacy.followers_count === 'number' && obj.legacy.screen_name) { + cacheUser(obj.legacy.screen_name, obj.legacy.followers_count); + } + // Alternative: screen_name and followers_count at the same level + if (typeof obj.screen_name === 'string' && typeof obj.followers_count === 'number') { + cacheUser(obj.screen_name, obj.followers_count); + } + for (const key of Object.keys(obj)) { + const val = obj[key]; + if (Array.isArray(val)) { + val.forEach(item => extractUsers(item, depth + 1)); + } else if (val && typeof val === 'object') { + extractUsers(val, depth + 1); + } + } + } + + console.log('[FollowerCount] Script loaded, intercepting fetch/XHR'); + + const origFetch = window.fetch; + window.fetch = async function (...args) { + const resp = await origFetch.apply(this, args); + try { + const url = typeof args[0] === 'string' ? args[0] : args[0]?.url; + if (url && url.includes('/graphql/')) { + // Capture query IDs from Twitter's own requests + const qidMatch = url.match(/\/graphql\/([^/?]+)\/UserByScreenName/); + if (qidMatch && !discoveredQueryId) { + discoveredQueryId = qidMatch[1]; + console.log('[FollowerCount] Captured UserByScreenName queryId from traffic:', discoveredQueryId); + } + } + if (url && (url.includes('/graphql/') || url.includes('/i/api/'))) { + const clone = resp.clone(); + clone.json().then(json => { + try { extractUsers(json, 0); } catch (e) { console.warn('[FollowerCount] fetch parse error:', e); } + }).catch(() => {}); + } + } catch {} + return resp; + }; + + const origOpen = XMLHttpRequest.prototype.open; + const origSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (method, url, ...rest) { + this._tmUrl = url; + return origOpen.call(this, method, url, ...rest); + }; + XMLHttpRequest.prototype.send = function (...args) { + if (this._tmUrl && (this._tmUrl.includes('/graphql/') || this._tmUrl.includes('/i/api/'))) { + this.addEventListener('load', function () { + try { + const json = JSON.parse(this.responseText); + extractUsers(json, 0); + } catch {} + }); + } + return origSend.apply(this, args); + }; + + const BADGE_ATTR = 'data-follower-badge'; + const STYLE_ID = 'tm-follower-count-style'; + + function injectStyles() { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` + .tm-follower-badge { + color: rgb(113, 118, 123); + font-size: 13px; + font-weight: 400; + white-space: nowrap; + display: inline-flex; + align-items: center; + } + .tm-follower-badge::before { + content: "·"; + margin: 0 4px; + } + .tm-bio-line { + color: rgb(113, 118, 123); + font-size: 13px; + font-weight: 400; + line-height: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + padding: 0 0 2px 0; + } + .tm-f-label { + font-style: italic; + opacity: 0.6; + } + .tm-usercell-badge { + color: rgb(113, 118, 123); + font-size: 13px; + font-weight: 400; + white-space: nowrap; + } + .tm-usercell-badge::before { + content: " · "; + } + `; + (document.head || document.documentElement).appendChild(style); + } + + // Extract @handle from an element's descendant spans. + // Twitter handle spans contain exactly "@username" (1-15 word chars). + // We must skip display-name text that happens to contain "@" (e.g. + // "Yury @ yuryv.info" or "@user@mastodon.social"). + const HANDLE_RE = /^@(\w{1,15})$/; + function findHandle(container) { + if (!container) return null; + const spans = container.querySelectorAll('span'); + for (const span of spans) { + if (span.children.length !== 0) continue; + const m = span.textContent.trim().match(HANDLE_RE); + if (m) return m[1].toLowerCase(); + } + return null; + } + + // Extract handle from avatar data-testid (programmatic, immune to display name tricks) + function findHandleFromAvatar(container) { + const avatar = container.querySelector('[data-testid^="UserAvatar-Container-"]'); + if (!avatar) return null; + const handle = avatar.getAttribute('data-testid').replace('UserAvatar-Container-', ''); + return handle ? handle.toLowerCase() : null; + } + + function processTweets() { + const articles = document.querySelectorAll('article[data-testid="tweet"]'); + for (const article of articles) { + const prevState = article.getAttribute(BADGE_ATTR); + + const userNameContainer = article.querySelector('[data-testid="User-Name"]'); + // Prefer avatar data-testid (reliable), fall back to span scanning + let handle = findHandleFromAvatar(article) || findHandle(userNameContainer); + if (!handle) continue; + + const cached = userCache.get(handle); + const failed = failedHandles.has(handle); + + // Determine desired state + let state, badgeContent, bioText; + if (cached) { + state = 'loaded'; + badgeContent = 'f\u200a' + formatCount(cached.followers); + bioText = cached.bio; + } else if (failed) { + state = 'error'; + badgeContent = '!'; + } else { + state = 'loading'; + badgeContent = '\u2026'; // … + queueUserFetch(handle); + } + + // Skip if already in this state + if (prevState === state + ':' + handle) continue; + + // Remove old badge/bio if upgrading state + if (prevState) { + article.querySelectorAll('.tm-follower-badge').forEach(el => el.remove()); + article.querySelectorAll('.tm-bio-line').forEach(el => el.remove()); + } + + const timeEl = article.querySelector('time'); + if (!timeEl) continue; + const timeLink = timeEl.closest('a'); + const container = timeLink ? timeLink.parentElement : timeEl.parentElement; + if (!container) continue; + + article.setAttribute(BADGE_ATTR, state + ':' + handle); + + const badge = document.createElement('span'); + badge.className = 'tm-follower-badge'; + badge.innerHTML = badgeContent; + container.appendChild(badge); + + if (state === 'loaded' && bioText && userNameContainer) { + const bioEl = document.createElement('div'); + bioEl.className = 'tm-bio-line'; + bioEl.textContent = bioText.replace(/\n/g, ' '); + userNameContainer.parentElement.insertBefore(bioEl, userNameContainer.nextSibling); + } + } + } + + function processUserCells() { + const cells = document.querySelectorAll('[data-testid="UserCell"]'); + for (const cell of cells) { + const prevState = cell.getAttribute(BADGE_ATTR); + + // Prefer avatar data-testid (reliable), fall back to span scanning + let handle = findHandleFromAvatar(cell) || findHandle(cell); + if (!handle) continue; + + const cached = userCache.get(handle); + const failed = failedHandles.has(handle); + + let state, badgeContent; + if (cached) { + state = 'loaded'; + badgeContent = 'f\u200a' + formatCount(cached.followers); + } else if (failed) { + state = 'error'; + badgeContent = '!'; + } else { + state = 'loading'; + badgeContent = '\u2026'; + queueUserFetch(handle); + } + + if (prevState === state + ':' + handle) continue; + + if (prevState) { + cell.querySelectorAll('.tm-usercell-badge').forEach(el => el.remove()); + } + + cell.setAttribute(BADGE_ATTR, state + ':' + handle); + + const spans = cell.querySelectorAll('span'); + let handleSpan = null; + for (const span of spans) { + if (span.textContent.trim().toLowerCase() === '@' + handle && span.children.length === 0) { + handleSpan = span; + break; + } + } + + if (handleSpan) { + const badge = document.createElement('span'); + badge.className = 'tm-usercell-badge'; + badge.innerHTML = badgeContent; + handleSpan.parentElement.appendChild(badge); + } + } + } + + function processAll() { + injectStyles(); + cancelQueue(); + processTweets(); + processUserCells(); + } + + function startObserver() { + processAll(); + const observer = new MutationObserver(() => processAll()); + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + startObserver(); + } else { + document.addEventListener('DOMContentLoaded', startObserver); + } +})(); + diff --git a/moz/scripts/twitter-post-activity-hotkeys.js b/moz/scripts/twitter-post-activity-hotkeys.js new file mode 100644 index 0000000..beed73f --- /dev/null +++ b/moz/scripts/twitter-post-activity-hotkeys.js @@ -0,0 +1,228 @@ +// twitter-custom-keys.lib.js — Shared library for registering custom keyboard shortcuts +// @require'd by individual hotkey userscripts. Injects a "Custom" section into Twitter's +// built-in keyboard shortcuts dialog (opened with ?). +// +// Usage: window.__twitterCustomKeys.register(key, description) + +(function () { + 'use strict'; + + // Singleton guard — only the first script to load initializes + if (window.__twitterCustomKeys) return; + + const entries = []; + const SECTION_ID = 'tm-custom-keys-section'; + + window.__twitterCustomKeys = { + register(key, description) { + entries.push({ key, description }); + } + }; + + // Find the container holding all shortcut sections. + // Desktop: [role="dialog"] > ... > [data-viewportview="true"] > sections + // Mobile:
> ... > scrollable div > sections + // Identified by the h2#modal-header heading near [role="table"] elements. + // Uses structural markers (id, roles) instead of text to support all languages. + function findSectionsContainer() { + const header = document.getElementById('modal-header'); + if (!header) return null; + // Walk up from the header to find the ancestor containing [role="table"] sections + let el = header.parentElement; + while (el) { + if (el.querySelector('[role="table"]')) return el; + el = el.parentElement; + } + return null; + } + + function renderSection(container) { + // Remove previous render if any + const old = document.getElementById(SECTION_ID); + if (old) old.remove(); + + if (entries.length === 0) return; + + // Find an existing section to clone structure from. + // Each section wraps a heading + [role="table"]. The section is the + // table's parent (which may have varying CSS classes across views). + const existingTable = container.querySelector('[role="table"]'); + if (!existingTable) return; + const existingRow = existingTable.querySelector('[role="row"]'); + if (!existingRow) return; + const existingSection = existingTable.parentElement; + if (!existingSection) return; + + // Clone the entire section as our template + const section = existingSection.cloneNode(true); + section.id = SECTION_ID; + + // Update the heading text to "Custom" + const headingSpan = section.querySelector('h2[role="heading"] span'); + if (headingSpan) { + headingSpan.textContent = 'Custom'; + } + + // Get reference to the table, clear its rows, and rebuild + const table = section.querySelector('[role="table"]'); + table.innerHTML = ''; + + for (const { key, description } of entries) { + // Clone a row from the original dialog for correct classes + const row = existingRow.cloneNode(true); + + // First cell = description + const cells = row.querySelectorAll('[role="cell"]'); + const descCell = cells[0]; + const keyCell = cells[1]; + + // Set description text + const descSpan = descCell.querySelector('span'); + if (descSpan) { + descSpan.textContent = description; + } else { + descCell.textContent = description; + } + + // Set key — clear existing content and rebuild from a single-key template + keyCell.innerHTML = ''; + const existingKeyDiv = existingRow.querySelector('[role="cell"]:last-child > div'); + if (existingKeyDiv) { + const keyDiv = existingKeyDiv.cloneNode(true); + keyDiv.textContent = key; + keyCell.appendChild(keyDiv); + } else { + keyCell.textContent = key; + } + + table.appendChild(row); + } + + // Force onto its own row in the desktop flex layout (which assumes 3 columns) + section.style.flexBasis = '100%'; + + // Append after the last existing section + existingSection.parentElement.appendChild(section); + } + + function checkForShortcutsView() { + // Already injected + if (document.getElementById(SECTION_ID)) return; + + const container = findSectionsContainer(); + if (container) { + renderSection(container); + } + } + + // Watch for shortcuts view appearance (dialog on desktop, page on mobile) + const observer = new MutationObserver(checkForShortcutsView); + + function startObserver() { + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + startObserver(); + } else { + document.addEventListener('DOMContentLoaded', startObserver); + } + + console.log('[CustomKeys] Shared library loaded'); +})(); + + + +(function () { + 'use strict'; + + window.__twitterCustomKeys?.register('v', 'View post activity'); + window.__twitterCustomKeys?.register('q', 'Quotes tab'); + window.__twitterCustomKeys?.register('t', 'Reposts tab'); + window.__twitterCustomKeys?.register('l', 'Likes tab'); + + function isTyping() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return true; + if (el.getAttribute('contenteditable') === 'true') return true; + if (el.closest('[contenteditable="true"]')) return true; + return false; + } + + function getFocusedTweet() { + let el = document.activeElement; + while (el) { + if (el.matches && el.matches('article[data-testid="tweet"]')) return el; + el = el.parentElement; + } + return null; + } + + function clickTab(label) { + const tabs = document.querySelectorAll('[role="tab"]'); + for (const tab of tabs) { + if (tab.textContent.toLowerCase().includes(label.toLowerCase())) { + tab.click(); + return true; + } + } + return false; + } + + async function viewPostActivity() { + const article = getFocusedTweet(); + if (!article) return; + + const caret = article.querySelector('[data-testid="caret"]'); + if (!caret) return; + + caret.click(); + + // Wait for dropdown menu to appear + let menu = null; + for (let i = 0; i < 10; i++) { + await new Promise(r => setTimeout(r, 50)); + menu = document.querySelector('[role="menu"]'); + if (menu) break; + } + if (!menu) return; + + const items = menu.querySelectorAll('[role="menuitem"]'); + for (const item of items) { + if (item.textContent.includes('View post activity')) { + item.click(); + return; + } + } + + // Not found (not tweet author) — close menu + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + } + + document.addEventListener('keydown', function (e) { + if (isTyping()) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + + switch (e.key) { + case 'v': + e.preventDefault(); + e.stopPropagation(); + viewPostActivity(); + break; + case 'q': + if (clickTab('Quotes')) { e.preventDefault(); e.stopPropagation(); } + break; + case 't': + if (clickTab('Reposts')) { e.preventDefault(); e.stopPropagation(); } + break; + case 'l': + if (clickTab('Likes')) { e.preventDefault(); e.stopPropagation(); } + break; + } + }); + + console.log('[PostActivityHotkeys] Loaded: v=view activity, q=quotes, t=reposts, l=likes'); +})(); + diff --git a/moz/scripts/twitter-profile-hotkey.js b/moz/scripts/twitter-profile-hotkey.js new file mode 100644 index 0000000..df2c428 --- /dev/null +++ b/moz/scripts/twitter-profile-hotkey.js @@ -0,0 +1,195 @@ +// twitter-custom-keys.lib.js — Shared library for registering custom keyboard shortcuts +// @require'd by individual hotkey userscripts. Injects a "Custom" section into Twitter's +// built-in keyboard shortcuts dialog (opened with ?). +// +// Usage: window.__twitterCustomKeys.register(key, description) + +(function () { + 'use strict'; + + // Singleton guard — only the first script to load initializes + if (window.__twitterCustomKeys) return; + + const entries = []; + const SECTION_ID = 'tm-custom-keys-section'; + + window.__twitterCustomKeys = { + register(key, description) { + entries.push({ key, description }); + } + }; + + // Find the container holding all shortcut sections. + // Desktop: [role="dialog"] > ... > [data-viewportview="true"] > sections + // Mobile:
> ... > scrollable div > sections + // Identified by the h2#modal-header heading near [role="table"] elements. + // Uses structural markers (id, roles) instead of text to support all languages. + function findSectionsContainer() { + const header = document.getElementById('modal-header'); + if (!header) return null; + // Walk up from the header to find the ancestor containing [role="table"] sections + let el = header.parentElement; + while (el) { + if (el.querySelector('[role="table"]')) return el; + el = el.parentElement; + } + return null; + } + + function renderSection(container) { + // Remove previous render if any + const old = document.getElementById(SECTION_ID); + if (old) old.remove(); + + if (entries.length === 0) return; + + // Find an existing section to clone structure from. + // Each section wraps a heading + [role="table"]. The section is the + // table's parent (which may have varying CSS classes across views). + const existingTable = container.querySelector('[role="table"]'); + if (!existingTable) return; + const existingRow = existingTable.querySelector('[role="row"]'); + if (!existingRow) return; + const existingSection = existingTable.parentElement; + if (!existingSection) return; + + // Clone the entire section as our template + const section = existingSection.cloneNode(true); + section.id = SECTION_ID; + + // Update the heading text to "Custom" + const headingSpan = section.querySelector('h2[role="heading"] span'); + if (headingSpan) { + headingSpan.textContent = 'Custom'; + } + + // Get reference to the table, clear its rows, and rebuild + const table = section.querySelector('[role="table"]'); + table.innerHTML = ''; + + for (const { key, description } of entries) { + // Clone a row from the original dialog for correct classes + const row = existingRow.cloneNode(true); + + // First cell = description + const cells = row.querySelectorAll('[role="cell"]'); + const descCell = cells[0]; + const keyCell = cells[1]; + + // Set description text + const descSpan = descCell.querySelector('span'); + if (descSpan) { + descSpan.textContent = description; + } else { + descCell.textContent = description; + } + + // Set key — clear existing content and rebuild from a single-key template + keyCell.innerHTML = ''; + const existingKeyDiv = existingRow.querySelector('[role="cell"]:last-child > div'); + if (existingKeyDiv) { + const keyDiv = existingKeyDiv.cloneNode(true); + keyDiv.textContent = key; + keyCell.appendChild(keyDiv); + } else { + keyCell.textContent = key; + } + + table.appendChild(row); + } + + // Force onto its own row in the desktop flex layout (which assumes 3 columns) + section.style.flexBasis = '100%'; + + // Append after the last existing section + existingSection.parentElement.appendChild(section); + } + + function checkForShortcutsView() { + // Already injected + if (document.getElementById(SECTION_ID)) return; + + const container = findSectionsContainer(); + if (container) { + renderSection(container); + } + } + + // Watch for shortcuts view appearance (dialog on desktop, page on mobile) + const observer = new MutationObserver(checkForShortcutsView); + + function startObserver() { + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + startObserver(); + } else { + document.addEventListener('DOMContentLoaded', startObserver); + } + + console.log('[CustomKeys] Shared library loaded'); +})(); + + + +(function () { + 'use strict'; + + window.__twitterCustomKeys?.register('f', "Open author's profile"); + + function isTyping() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return true; + if (el.getAttribute('contenteditable') === 'true') return true; + if (el.closest('[contenteditable="true"]')) return true; + return false; + } + + function getFocusedTweet() { + let el = document.activeElement; + while (el) { + if (el.matches && el.matches('article[data-testid="tweet"]')) return el; + el = el.parentElement; + } + return null; + } + + function openProfile() { + const article = getFocusedTweet(); + if (!article) return; + + // Find the avatar link — it always points to the author's profile + const avatar = article.querySelector('[data-testid^="UserAvatar-Container-"] a[href]'); + if (avatar) { + avatar.click(); + return; + } + + // Fallback: find the @handle and navigate + const userNameContainer = article.querySelector('[data-testid="User-Name"]'); + if (userNameContainer) { + const link = userNameContainer.querySelector('a[href^="/"]'); + if (link) { + link.click(); + return; + } + } + } + + document.addEventListener('keydown', function (e) { + if (isTyping()) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + + if (e.key === 'f') { + e.preventDefault(); + e.stopPropagation(); + openProfile(); + } + }); + + console.log('[ProfileHotkey] Loaded: f=open profile'); +})(); + diff --git a/moz/scripts/twitter-quote-hotkey.js b/moz/scripts/twitter-quote-hotkey.js new file mode 100644 index 0000000..e1416a4 --- /dev/null +++ b/moz/scripts/twitter-quote-hotkey.js @@ -0,0 +1,221 @@ +// twitter-custom-keys.lib.js — Shared library for registering custom keyboard shortcuts +// @require'd by individual hotkey userscripts. Injects a "Custom" section into Twitter's +// built-in keyboard shortcuts dialog (opened with ?). +// +// Usage: window.__twitterCustomKeys.register(key, description) + +(function () { + 'use strict'; + + // Singleton guard — only the first script to load initializes + if (window.__twitterCustomKeys) return; + + const entries = []; + const SECTION_ID = 'tm-custom-keys-section'; + + window.__twitterCustomKeys = { + register(key, description) { + entries.push({ key, description }); + } + }; + + // Find the container holding all shortcut sections. + // Desktop: [role="dialog"] > ... > [data-viewportview="true"] > sections + // Mobile:
> ... > scrollable div > sections + // Identified by the h2#modal-header heading near [role="table"] elements. + // Uses structural markers (id, roles) instead of text to support all languages. + function findSectionsContainer() { + const header = document.getElementById('modal-header'); + if (!header) return null; + // Walk up from the header to find the ancestor containing [role="table"] sections + let el = header.parentElement; + while (el) { + if (el.querySelector('[role="table"]')) return el; + el = el.parentElement; + } + return null; + } + + function renderSection(container) { + // Remove previous render if any + const old = document.getElementById(SECTION_ID); + if (old) old.remove(); + + if (entries.length === 0) return; + + // Find an existing section to clone structure from. + // Each section wraps a heading + [role="table"]. The section is the + // table's parent (which may have varying CSS classes across views). + const existingTable = container.querySelector('[role="table"]'); + if (!existingTable) return; + const existingRow = existingTable.querySelector('[role="row"]'); + if (!existingRow) return; + const existingSection = existingTable.parentElement; + if (!existingSection) return; + + // Clone the entire section as our template + const section = existingSection.cloneNode(true); + section.id = SECTION_ID; + + // Update the heading text to "Custom" + const headingSpan = section.querySelector('h2[role="heading"] span'); + if (headingSpan) { + headingSpan.textContent = 'Custom'; + } + + // Get reference to the table, clear its rows, and rebuild + const table = section.querySelector('[role="table"]'); + table.innerHTML = ''; + + for (const { key, description } of entries) { + // Clone a row from the original dialog for correct classes + const row = existingRow.cloneNode(true); + + // First cell = description + const cells = row.querySelectorAll('[role="cell"]'); + const descCell = cells[0]; + const keyCell = cells[1]; + + // Set description text + const descSpan = descCell.querySelector('span'); + if (descSpan) { + descSpan.textContent = description; + } else { + descCell.textContent = description; + } + + // Set key — clear existing content and rebuild from a single-key template + keyCell.innerHTML = ''; + const existingKeyDiv = existingRow.querySelector('[role="cell"]:last-child > div'); + if (existingKeyDiv) { + const keyDiv = existingKeyDiv.cloneNode(true); + keyDiv.textContent = key; + keyCell.appendChild(keyDiv); + } else { + keyCell.textContent = key; + } + + table.appendChild(row); + } + + // Force onto its own row in the desktop flex layout (which assumes 3 columns) + section.style.flexBasis = '100%'; + + // Append after the last existing section + existingSection.parentElement.appendChild(section); + } + + function checkForShortcutsView() { + // Already injected + if (document.getElementById(SECTION_ID)) return; + + const container = findSectionsContainer(); + if (container) { + renderSection(container); + } + } + + // Watch for shortcuts view appearance (dialog on desktop, page on mobile) + const observer = new MutationObserver(checkForShortcutsView); + + function startObserver() { + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + startObserver(); + } else { + document.addEventListener('DOMContentLoaded', startObserver); + } + + console.log('[CustomKeys] Shared library loaded'); +})(); + + + +(function () { + 'use strict'; + + window.__twitterCustomKeys?.register('p', 'Open quote tweet'); + + // Track chord prefixes (e.g. g+p = go to profile) + const CHORD_PREFIXES = ['g']; + const CHORD_TIMEOUT = 1000; + let chordPending = false; + let chordTimer = null; + + document.addEventListener('keydown', function (e) { + if (CHORD_PREFIXES.includes(e.key)) { + chordPending = true; + clearTimeout(chordTimer); + chordTimer = setTimeout(() => { chordPending = false; }, CHORD_TIMEOUT); + } + }, true); + + function isTyping() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return true; + if (el.getAttribute('contenteditable') === 'true') return true; + if (el.closest('[contenteditable="true"]')) return true; + return false; + } + + function getFocusedTweet() { + let el = document.activeElement; + while (el) { + if (el.matches && el.matches('article[data-testid="tweet"]')) return el; + el = el.parentElement; + } + return null; + } + + function openQuoteTweet() { + const article = getFocusedTweet(); + if (!article) return; + + // Strategy 1: data-testid for quote tweet + const quote = article.querySelector('[data-testid="quoteTweet"]'); + if (quote) { + const link = quote.querySelector('a[href*="/status/"]'); + if (link) { link.click(); return; } + quote.click(); + return; + } + + // Strategy 2: Find the tweet's own permalink, then look for a different /status/ link + // that isn't in the action bar area (views/analytics) + const timeLink = article.querySelector('time')?.closest('a[href*="/status/"]'); + if (!timeLink) return; + const tweetHref = timeLink.getAttribute('href'); + + const links = article.querySelectorAll('a[href*="/status/"]'); + for (const link of links) { + const href = link.getAttribute('href'); + // Skip the tweet's own permalink and analytics/views links + if (href === tweetHref) continue; + if (href.includes('/analytics')) continue; + // Must be a different tweet's /status/ URL — likely the quote tweet + if (/\/status\/\d+/.test(href)) { + link.click(); + return; + } + } + } + + document.addEventListener('keydown', function (e) { + if (isTyping()) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + + if (e.key === 'p') { + if (chordPending) { chordPending = false; return; } + e.preventDefault(); + e.stopPropagation(); + openQuoteTweet(); + } + }); + + console.log('[QuoteHotkey] Loaded: p=open quote tweet'); +})(); + diff --git a/moz/scripts/twitter-usercell-hotkeys.js b/moz/scripts/twitter-usercell-hotkeys.js new file mode 100644 index 0000000..24f4ae7 --- /dev/null +++ b/moz/scripts/twitter-usercell-hotkeys.js @@ -0,0 +1,252 @@ +// twitter-custom-keys.lib.js — Shared library for registering custom keyboard shortcuts +// @require'd by individual hotkey userscripts. Injects a "Custom" section into Twitter's +// built-in keyboard shortcuts dialog (opened with ?). +// +// Usage: window.__twitterCustomKeys.register(key, description) + +(function () { + 'use strict'; + + // Singleton guard — only the first script to load initializes + if (window.__twitterCustomKeys) return; + + const entries = []; + const SECTION_ID = 'tm-custom-keys-section'; + + window.__twitterCustomKeys = { + register(key, description) { + entries.push({ key, description }); + } + }; + + // Find the container holding all shortcut sections. + // Desktop: [role="dialog"] > ... > [data-viewportview="true"] > sections + // Mobile:
> ... > scrollable div > sections + // Identified by the h2#modal-header heading near [role="table"] elements. + // Uses structural markers (id, roles) instead of text to support all languages. + function findSectionsContainer() { + const header = document.getElementById('modal-header'); + if (!header) return null; + // Walk up from the header to find the ancestor containing [role="table"] sections + let el = header.parentElement; + while (el) { + if (el.querySelector('[role="table"]')) return el; + el = el.parentElement; + } + return null; + } + + function renderSection(container) { + // Remove previous render if any + const old = document.getElementById(SECTION_ID); + if (old) old.remove(); + + if (entries.length === 0) return; + + // Find an existing section to clone structure from. + // Each section wraps a heading + [role="table"]. The section is the + // table's parent (which may have varying CSS classes across views). + const existingTable = container.querySelector('[role="table"]'); + if (!existingTable) return; + const existingRow = existingTable.querySelector('[role="row"]'); + if (!existingRow) return; + const existingSection = existingTable.parentElement; + if (!existingSection) return; + + // Clone the entire section as our template + const section = existingSection.cloneNode(true); + section.id = SECTION_ID; + + // Update the heading text to "Custom" + const headingSpan = section.querySelector('h2[role="heading"] span'); + if (headingSpan) { + headingSpan.textContent = 'Custom'; + } + + // Get reference to the table, clear its rows, and rebuild + const table = section.querySelector('[role="table"]'); + table.innerHTML = ''; + + for (const { key, description } of entries) { + // Clone a row from the original dialog for correct classes + const row = existingRow.cloneNode(true); + + // First cell = description + const cells = row.querySelectorAll('[role="cell"]'); + const descCell = cells[0]; + const keyCell = cells[1]; + + // Set description text + const descSpan = descCell.querySelector('span'); + if (descSpan) { + descSpan.textContent = description; + } else { + descCell.textContent = description; + } + + // Set key — clear existing content and rebuild from a single-key template + keyCell.innerHTML = ''; + const existingKeyDiv = existingRow.querySelector('[role="cell"]:last-child > div'); + if (existingKeyDiv) { + const keyDiv = existingKeyDiv.cloneNode(true); + keyDiv.textContent = key; + keyCell.appendChild(keyDiv); + } else { + keyCell.textContent = key; + } + + table.appendChild(row); + } + + // Force onto its own row in the desktop flex layout (which assumes 3 columns) + section.style.flexBasis = '100%'; + + // Append after the last existing section + existingSection.parentElement.appendChild(section); + } + + function checkForShortcutsView() { + // Already injected + if (document.getElementById(SECTION_ID)) return; + + const container = findSectionsContainer(); + if (container) { + renderSection(container); + } + } + + // Watch for shortcuts view appearance (dialog on desktop, page on mobile) + const observer = new MutationObserver(checkForShortcutsView); + + function startObserver() { + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + startObserver(); + } else { + document.addEventListener('DOMContentLoaded', startObserver); + } + + console.log('[CustomKeys] Shared library loaded'); +})(); + + + +(function () { + 'use strict'; + + window.__twitterCustomKeys?.register('x', 'Block user (user cell)'); + window.__twitterCustomKeys?.register('f', 'Open profile (user cell)'); + window.__twitterCustomKeys?.register('u', 'Mute user (user cell)'); + window.__twitterCustomKeys?.register('w', 'Follow user (user cell)'); + + function isTyping() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return true; + if (el.getAttribute('contenteditable') === 'true') return true; + if (el.closest('[contenteditable="true"]')) return true; + return false; + } + + function getFocusedUserCell() { + let el = document.activeElement; + while (el) { + if (el.matches && el.matches('[data-testid="UserCell"]')) return el; + el = el.parentElement; + } + return null; + } + + function openProfile(cell) { + const link = cell.querySelector('a[href^="/"][role="link"]'); + if (link) { link.click(); return; } + const links = cell.querySelectorAll('a[href^="/"]'); + for (const l of links) { + const href = l.getAttribute('href'); + if (href && /^\/[a-zA-Z0-9_]+$/.test(href)) { + l.click(); + return; + } + } + } + + function followUser(cell) { + const btn = cell.querySelector('[data-testid$="-follow"]'); + if (btn) { btn.click(); return; } + const buttons = cell.querySelectorAll('[role="button"]'); + for (const b of buttons) { + if (b.textContent.trim() === 'Follow') { b.click(); return; } + } + } + + async function clickMenuItem(cell, label) { + // Find the three-dot / more button in the cell + const caret = cell.querySelector('[data-testid="caret"]') + || cell.querySelector('[aria-label="More"]') + || cell.querySelector('[data-testid^="UserCell"] [role="button"]:last-of-type'); + + // Some user cells have inline action buttons instead of a caret menu + // Try finding a button with matching aria-label + const inlineBtn = cell.querySelector(`[aria-label*="${label}" i]`); + if (inlineBtn) { inlineBtn.click(); return; } + + if (!caret) return; + caret.click(); + + let menu = null; + for (let i = 0; i < 10; i++) { + await new Promise(r => setTimeout(r, 50)); + menu = document.querySelector('[role="menu"]'); + if (menu) break; + } + if (!menu) return; + + const items = menu.querySelectorAll('[role="menuitem"]'); + for (const item of items) { + if (item.textContent.includes(label)) { + item.click(); + return; + } + } + + // Not found — close menu + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + } + + document.addEventListener('keydown', function (e) { + if (isTyping()) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + + const cell = getFocusedUserCell(); + if (!cell) return; + + switch (e.key) { + case 'x': + e.preventDefault(); + e.stopPropagation(); + clickMenuItem(cell, 'Block'); + break; + case 'f': + e.preventDefault(); + e.stopPropagation(); + openProfile(cell); + break; + case 'u': + e.preventDefault(); + e.stopPropagation(); + clickMenuItem(cell, 'Mute'); + break; + case 'w': + e.preventDefault(); + e.stopPropagation(); + followUser(cell); + break; + } + }); + + console.log('[UserCellHotkeys] Loaded: x=block, f=profile, u=mute, w=follow'); +})(); + diff --git a/package.json b/package.json new file mode 100644 index 0000000..9ce95f4 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "twitter-userscripts", + "private": true, + "version": "1.0.0", + "description": "Build tooling for packaging Twitter/X userscripts as Chromium and Firefox extensions.", + "type": "module", + "scripts": { + "clean:extensions": "rm -rf blink moz", + "build:extensions": "node tools/build-extensions.mjs" + } +} diff --git a/tools/build-extensions.mjs b/tools/build-extensions.mjs new file mode 100644 index 0000000..dd15104 --- /dev/null +++ b/tools/build-extensions.mjs @@ -0,0 +1,170 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const rootDir = process.cwd(); +const targetConfigs = [ + { dir: 'blink', browserName: 'Chromium' }, + { dir: 'moz', browserName: 'Firefox' } +]; + +const RUN_AT_MAP = { + 'document-start': 'document_start', + 'document-end': 'document_end', + 'document-idle': 'document_idle' +}; + +const USER_SCRIPT_SUFFIX = '.user.js'; +const META_START = '// ==UserScript=='; +const META_END = '// ==/UserScript=='; +const RAW_PREFIX = 'https://raw.githubusercontent.com/digitalby/twitter-userscripts/main/'; + +function parseMetadata(source) { + const start = source.indexOf(META_START); + const end = source.indexOf(META_END); + if (start === -1 || end === -1 || end < start) { + throw new Error('Missing userscript metadata block.'); + } + + const metaLines = source.slice(start, end + META_END.length).split('\n'); + const metadata = {}; + + for (const line of metaLines) { + const match = line.match(/^\/\/\s*@([\w-]+)\s+(.+)$/); + if (!match) continue; + const [, key, rawValue] = match; + const value = rawValue.trim(); + if (!metadata[key]) metadata[key] = []; + metadata[key].push(value); + } + + const codeWithoutMetadata = + source.slice(0, start).trim() + '\n' + source.slice(end + META_END.length).trimStart(); + + return { metadata, codeWithoutMetadata }; +} + +async function loadRequire(requireValue) { + if (!requireValue.startsWith(RAW_PREFIX)) { + throw new Error(`Unsupported @require source: ${requireValue}`); + } + + const localFileName = requireValue.slice(RAW_PREFIX.length); + const localPath = path.join(rootDir, localFileName); + return fs.readFile(localPath, 'utf8'); +} + +async function loadScriptEntries() { + const fileNames = await fs.readdir(rootDir); + const userScriptFiles = fileNames + .filter((file) => file.endsWith(USER_SCRIPT_SUFFIX)) + .sort(); + + if (userScriptFiles.length === 0) { + throw new Error(`No ${USER_SCRIPT_SUFFIX} files found in ${rootDir}`); + } + + const scriptEntries = []; + + for (const fileName of userScriptFiles) { + const fullPath = path.join(rootDir, fileName); + const source = await fs.readFile(fullPath, 'utf8'); + const { metadata, codeWithoutMetadata } = parseMetadata(source); + + const requires = metadata.require ?? []; + const requireChunks = []; + for (const requireValue of requires) { + requireChunks.push(await loadRequire(requireValue)); + } + + const outFileName = fileName.replace(USER_SCRIPT_SUFFIX, '.js'); + const runAt = RUN_AT_MAP[(metadata['run-at'] ?? ['document-idle'])[0]] ?? 'document_idle'; + + scriptEntries.push({ + sourceFile: fileName, + outFileName, + name: (metadata.name ?? [fileName])[0], + version: (metadata.version ?? ['0.0.0'])[0], + matches: metadata.match ?? [], + runAt, + bundle: [...requireChunks, codeWithoutMetadata].join('\n\n') + '\n' + }); + } + + return scriptEntries; +} + +function makeManifest({ browserName, scriptEntries }) { + const contentScripts = scriptEntries.map((entry) => ({ + matches: entry.matches, + js: [`scripts/${entry.outFileName}`], + run_at: entry.runAt + })); + + const maxVersion = scriptEntries + .map((entry) => entry.version) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) + .at(-1) ?? '1.0.0'; + + const manifest = { + manifest_version: 3, + name: `Twitter Userscripts (${browserName})`, + version: maxVersion, + description: `Built from userscripts in this repository. Source files stay in place for userscript-manager installs.`, + content_scripts: contentScripts + }; + + if (browserName === 'Firefox') { + manifest.browser_specific_settings = { + gecko: { + id: 'twitter-userscripts@digitalby.github', + strict_min_version: '109.0' + } + }; + } + + return manifest; +} + +async function buildTarget({ dir, browserName }, scriptEntries) { + const targetDir = path.join(rootDir, dir); + const scriptsDir = path.join(targetDir, 'scripts'); + + await fs.rm(targetDir, { recursive: true, force: true }); + await fs.mkdir(scriptsDir, { recursive: true }); + + for (const entry of scriptEntries) { + const outPath = path.join(scriptsDir, entry.outFileName); + await fs.writeFile(outPath, entry.bundle, 'utf8'); + } + + const manifest = makeManifest({ browserName, scriptEntries }); + await fs.writeFile( + path.join(targetDir, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + 'utf8' + ); + + const readme = [ + `# ${browserName} build output`, + '', + 'Generated by `npm run build:extensions`.', + 'Do not edit files in this folder by hand.', + '' + ].join('\n'); + await fs.writeFile(path.join(targetDir, 'README.md'), readme, 'utf8'); +} + +async function main() { + const scriptEntries = await loadScriptEntries(); + + for (const target of targetConfigs) { + await buildTarget(target, scriptEntries); + } + + console.log(`Built ${scriptEntries.length} userscripts into blink/ and moz/.`); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +});