Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/build-extensions.yml
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions blink/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Chromium build output

Generated by `npm run build:extensions`.
Do not edit files in this folder by hand.
78 changes: 78 additions & 0 deletions blink/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
156 changes: 156 additions & 0 deletions blink/scripts/twitter-backspace-back.js
Original file line number Diff line number Diff line change
@@ -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: <main> > ... > 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();
});
})();

Loading
Loading